From 64aca5419c9cd95677f4e67aa4734848bcdab4a5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 19 Sep 2025 13:26:44 +0200 Subject: [PATCH 01/19] Add AttendeesProcessor --- .../LegacyAndroidEventProcessorTest.kt | 240 ------------- .../calendar/LegacyAndroidEventProcessor.kt | 61 +--- .../processor/AndroidEventFieldProcessor.kt | 16 + .../calendar/processor/AttendeesProcessor.kt | 75 ++++ .../processor/AttendeesProcessorTest.kt | 325 ++++++++++++++++++ 5 files changed, 427 insertions(+), 290 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index acc39f19..5f7a4ef0 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -36,14 +36,8 @@ import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm -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.Role -import net.fortuna.ical4j.model.parameter.Rsvp 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.DtStart @@ -682,240 +676,6 @@ class LegacyAndroidEventProcessorTest { } - private fun populateAttendee(builder: ContentValues.() -> Unit): Attendee? { - populateEvent(true, insertCallback = { id -> - val attendeeValues = ContentValues() - attendeeValues.put(Attendees.EVENT_ID, id) - builder(attendeeValues) - client.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), attendeeValues) - }).let { result -> - return result.attendees.firstOrNull() - } - } - - @Test - fun testPopulateAttendee_Email() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - }!!.let { attendee -> - assertEquals(URI("mailto:attendee@example.com"), attendee.calAddress) - } - } - - @Test - fun testPopulateAttendee_OtherUri() { - populateAttendee { - put(Attendees.ATTENDEE_ID_NAMESPACE, "https") - put(Attendees.ATTENDEE_IDENTITY, "//example.com/principals/attendee") - }!!.let { attendee -> - assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) - } - } - - @Test - fun testPopulateAttendee_EmailAndOtherUri() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_ID_NAMESPACE, "https") - put(Attendees.ATTENDEE_IDENTITY, "//example.com/principals/attendee") - }!!.let { attendee -> - assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) - assertEquals("attendee@example.com", attendee.getParameter(Parameter.EMAIL).value) - } - } - - @Test - fun testPopulateAttendee_AttendeeOrganizer() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER)) - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_Performer() { - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertEquals(CuType.GROUP, attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_Speaker() { - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) - } - // TYPE_RESOURCE - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_SPEAKER) - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - }!!.let { attendee -> - assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) - assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_RelNone() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_NONE, null)) - for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - if (type != null) - put(Attendees.ATTENDEE_TYPE, type as Int?) - }!!.let { attendee -> - assertEquals(CuType.UNKNOWN, attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_TypeNone() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_Required() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_REQUIRED) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_Optional() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_OPTIONAL) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertEquals(Role.OPT_PARTICIPANT, attendee.getParameter(Parameter.ROLE)) - } - } - - @Test - fun testPopulateAttendee_Resource() { - for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_NONE, null)) - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - if (relationship != null) - put(Attendees.ATTENDEE_RELATIONSHIP, relationship) - }!!.let { attendee -> - assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) - } - // RELATIONSHIP_PERFORMER - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_RESOURCE) - put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_PERFORMER) - }!!.let { attendee -> - assertEquals(CuType.ROOM, attendee.getParameter(Parameter.CUTYPE)) - } - } - - @Test - fun testPopulateAttendee_Status_Null() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Invited() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_INVITED) - }!!.let { attendee -> - assertEquals(PartStat.NEEDS_ACTION, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Accepted() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_ACCEPTED) - }!!.let { attendee -> - assertEquals(PartStat.ACCEPTED, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Declined() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_DECLINED) - }!!.let { attendee -> - assertEquals(PartStat.DECLINED, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_Tentative() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_TENTATIVE) - }!!.let { attendee -> - assertEquals(PartStat.TENTATIVE, attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Status_None() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - put(Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_STATUS_NONE) - }!!.let { attendee -> - assertNull(attendee.getParameter(Parameter.PARTSTAT)) - } - } - - @Test - fun testPopulateAttendee_Rsvp() { - populateAttendee { - put(Attendees.ATTENDEE_EMAIL, "attendee@example.com") - }!!.let { attendee -> - assertTrue(attendee.getParameter(Parameter.RSVP).rsvp) - } - } - @Test fun testPopulateUnknownProperty() { val params = ParameterList() 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 index 593c8226..006e90e5 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -21,6 +21,8 @@ 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.mapping.calendar.processor.AndroidEventFieldProcessor +import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions import net.fortuna.ical4j.model.Date @@ -28,10 +30,6 @@ import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.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 @@ -76,6 +74,10 @@ class LegacyAndroidEventProcessor( private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + private val fieldProcessors: Array = arrayOf( + AttendeesProcessor() + ) + fun populate(eventAndExceptions: EventAndExceptions, to: Event) { populateEvent(eventAndExceptions.main, to = to) @@ -94,25 +96,25 @@ class LegacyAndroidEventProcessor( * an [Event] data object. * * @param entity event row as returned by the calendar provider - * @param groupScheduled whether the event is group-scheduled (= the main event has attendees) * @param to destination data object */ private fun populateEvent(entity: Entity, to: Event) { - // calculate some scheduling properties + // legacy processors val hasAttendees = entity.subValues.any { it.uri == Attendees.CONTENT_URI } - - // main row populateEventRow(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) } } + + // new processors + for (processor in fieldProcessors) + processor.process(entity, to) } private fun populateEventRow(row: ContentValues, groupScheduled: Boolean, to: Event) { @@ -306,47 +308,6 @@ class LegacyAndroidEventProcessor( } } - 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) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt new file mode 100644 index 00000000..d61a620a --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt @@ -0,0 +1,16 @@ +/* + * 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.processor + +import android.content.Entity +import at.bitfire.ical4android.Event + +interface AndroidEventFieldProcessor { + + fun process(entity: Entity, to: Event) + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt new file mode 100644 index 00000000..f4bb70b3 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt @@ -0,0 +1,75 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import at.bitfire.ical4android.Event +import at.bitfire.synctools.mapping.calendar.AttendeeMappings +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.property.Attendee +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Level +import java.util.logging.Logger + +class AttendeesProcessor: AndroidEventFieldProcessor { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(entity: Entity, to: Event) { + for (row in entity.subValues.filter { it.uri == Attendees.CONTENT_URI }) + populateAttendee(row.values, to) + } + + 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) + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt new file mode 100644 index 00000000..d23bfe3b --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt @@ -0,0 +1,325 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.Email +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Rsvp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.net.URI + +@RunWith(RobolectricTestRunner::class) +class AttendeesProcessorTest { + + private val processor = AttendeesProcessor() + + @Test + fun `Attendee is email address`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com" + )) + val result = Event() + processor.process(entity, result) + assertEquals(URI("mailto:attendee@example.com"), result.attendees.first().calAddress) + } + + @Test + fun `Attendee is other URI`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_ID_NAMESPACE to "https", + Attendees.ATTENDEE_IDENTITY to "//example.com/principals/attendee" + )) + val result = Event() + processor.process(entity, result) + assertEquals(URI("https://example.com/principals/attendee"), result.attendees.first().calAddress) + } + + @Test + fun `Attendee is email address with other URI`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_ID_NAMESPACE to "https", + Attendees.ATTENDEE_IDENTITY to "//example.com/principals/attendee" + )) + val result = Event() + processor.process(entity, result) + assertEquals(1, result.attendees.size) + val attendee = result.attendees.first() + assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) + assertEquals("attendee@example.com", attendee.getParameter(Parameter.EMAIL).value) + } + + + @Test + fun `Attendee with relationship ATTENDEE or ORGANIZER generates empty user-type`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER)) + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun `Attendee with relationship PERFORMER generates user-type GROUP`() { + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.GROUP, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun `Attendee with relationship SPEAKER generates chair role (user-type person)`() { + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.CUTYPE)) + assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with relationship SPEAKER generates chair role (user-type RESOURCE)`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_SPEAKER, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) + assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) + } + + @Test + fun `Attendee with relationship NONE generates user-type UNKNOWN`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_NONE, null)) + for (type in arrayOf(Attendees.TYPE_REQUIRED, Attendees.TYPE_OPTIONAL, Attendees.TYPE_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to type + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.UNKNOWN, attendee.getParameter(Parameter.CUTYPE)) + } + } + + + @Test + fun `Attendee with type NONE doesn't generate ROLE`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with type REQUIRED doesn't generate ROLE`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with type OPTIONAL generates OPTIONAL role`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_PERFORMER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(Role.OPT_PARTICIPANT, attendee.getParameter(Parameter.ROLE)) + } + } + + @Test + fun `Attendee with type RESOURCE generates user-type RESOURCE`() { + for (relationship in arrayOf(Attendees.RELATIONSHIP_ATTENDEE, Attendees.RELATIONSHIP_ORGANIZER, Attendees.RELATIONSHIP_NONE, null)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to relationship, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) + } + } + + @Test + fun `Attendee with type RESOURCE (relationship PERFORMER) generates user-type ROOM`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_RELATIONSHIP to Attendees.RELATIONSHIP_PERFORMER, + Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(CuType.ROOM, attendee.getParameter(Parameter.CUTYPE)) + } + + + @Test + fun `Attendee without participation status`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com" + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status INVITED`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_INVITED + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.NEEDS_ACTION, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status ACCEPTED`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_ACCEPTED + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.ACCEPTED, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status DECLINED`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_DECLINED + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.DECLINED, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status TENTATIVE`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_TENTATIVE + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertEquals(PartStat.TENTATIVE, attendee.getParameter(Parameter.PARTSTAT)) + } + + @Test + fun `Attendee with participation status NONE`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com", + Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_NONE + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertNull(attendee.getParameter(Parameter.PARTSTAT)) + } + + + @Test + fun `Attendee RSVP`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "attendee@example.com" + )) + val result = Event() + processor.process(entity, result) + val attendee = result.attendees.first() + assertTrue(attendee.getParameter(Parameter.RSVP).rsvp) + } + +} \ No newline at end of file From c81479fcc53a7118961ad7f40ad4d5059b164f64 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 19 Sep 2025 13:38:15 +0200 Subject: [PATCH 02/19] Add UidProcessor --- .../LegacyAndroidEventProcessorTest.kt | 35 ---------- .../calendar/LegacyAndroidEventProcessor.kt | 8 +-- .../processor/AndroidEventFieldProcessor.kt | 9 +++ .../calendar/processor/UidProcessor.kt | 32 +++++++++ .../calendar/processor/UidProcessorTest.kt | 70 +++++++++++++++++++ 5 files changed, 113 insertions(+), 41 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index 5f7a4ef0..3927c516 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -153,41 +153,6 @@ class LegacyAndroidEventProcessorTest { return LegacyAndroidCalendar(destinationCalendar).getEvent(androidEvent.id)!! } - @Test - fun testPopulateEvent_Uid_iCalUid() { - populateEvent( - true, - extendedProperties = mapOf( - AndroidEvent2.EXTNAME_ICAL_UID to "event1@example.com" - ) - ).let { result -> - assertEquals("event1@example.com", result.uid) - } - } - - @Test - fun testPopulateEvent_Uid_UID_2445() { - populateEvent(true) { - put(Events.UID_2445, "event1@example.com") - }.let { result -> - assertEquals("event1@example.com", result.uid) - } - } - - @Test - fun testPopulateEvent_Uid_UID_2445_and_iCalUid() { - populateEvent( - true, - extendedProperties = mapOf( - AndroidEvent2.EXTNAME_ICAL_UID to "event1@example.com" - ) - ) { - put(Events.UID_2445, "event2@example.com") - }.let { result -> - assertEquals("event2@example.com", result.uid) - } - } - @Test fun testPopulateEvent_Sequence_Int() { 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 index 006e90e5..e102e095 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -23,6 +23,7 @@ import at.bitfire.synctools.exception.InvalidLocalResourceException import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor +import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions import net.fortuna.ical4j.model.Date @@ -75,6 +76,7 @@ class LegacyAndroidEventProcessor( private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } private val fieldProcessors: Array = arrayOf( + UidProcessor(), AttendeesProcessor() ) @@ -238,7 +240,6 @@ class LegacyAndroidEventProcessor( logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) } - to.uid = row.getAsString(Events.UID_2445) to.sequence = row.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) to.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) @@ -357,11 +358,6 @@ class LegacyAndroidEventProcessor( logger.warning("Won't process invalid local URL: $rawValue") } - AndroidEvent2.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) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt index d61a620a..afe837e9 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt @@ -11,6 +11,15 @@ import at.bitfire.ical4android.Event interface AndroidEventFieldProcessor { + /** + * Takes specific data from an event (= event row plus data rows, taken from the content provider) + * and maps it to the [Event] data class. + * + * In a later step of refactoring, it should map to [net.fortuna.ical4j.model.component.VEvent]. + * + * @param entity event from content provider + * @param to data object where the mapped data shall be stored + */ fun process(entity: Entity, to: Event) } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt new file mode 100644 index 00000000..ea77bcde --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt @@ -0,0 +1,32 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 + +class UidProcessor: AndroidEventFieldProcessor { + + override fun process(entity: Entity, to: Event) { + // take from event row or Google Calendar extended property + to.uid = entity.entityValues.getAsString(Events.UID_2445) ?: + uidFromExtendedProperties(entity.subValues) + } + + private fun uidFromExtendedProperties(rows: List): String? { + val uidRow = rows.firstOrNull { + it.uri == ExtendedProperties.CONTENT_URI && + it.values.getAsString(ExtendedProperties.NAME) == AndroidEvent2.EXTNAME_ICAL_UID + } + + return uidRow?.values?.getAsString(ExtendedProperties.VALUE) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt new file mode 100644 index 00000000..5d0d3424 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt @@ -0,0 +1,70 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UidProcessorTest { + + private val processor = UidProcessor() + + @Test + fun `No UID`() { + val result = Event() + processor.process(Entity(ContentValues()), result) + assertNull(result.uid) + } + + @Test + fun `UID from event row`() { + val entity = Entity(contentValuesOf( + Events.UID_2445 to "from-event" + )) + val result = Event() + processor.process(entity, result) + assertEquals("from-event", result.uid) + } + + @Test + fun `UID from extended row`() { + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_ICAL_UID, + ExtendedProperties.VALUE to "from-extended" + )) + val result = Event() + processor.process(entity, result) + assertEquals("from-extended", result.uid) + } + + @Test + fun `UID from event and extended row`() { + val entity = Entity(contentValuesOf( + Events.UID_2445 to "from-event" + )) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_ICAL_UID, + ExtendedProperties.VALUE to "from-extended" + )) + val result = Event() + processor.process(entity, result) + assertEquals("from-event", result.uid) + } + +} \ No newline at end of file From 24dcc85b8cbf41a25babc436fa0262accf048647 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 19 Sep 2025 14:05:10 +0200 Subject: [PATCH 03/19] Add RemindersProcessor --- .../LegacyAndroidEventProcessorTest.kt | 82 ------------- .../calendar/LegacyAndroidEventProcessor.kt | 44 +------ .../calendar/processor/RemindersProcessor.kt | 68 +++++++++++ .../processor/RemindersProcessorTest.kt | 108 ++++++++++++++++++ 4 files changed, 179 insertions(+), 123 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index 3927c516..f5efed67 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -15,7 +15,6 @@ import android.provider.CalendarContract.AUTHORITY import android.provider.CalendarContract.Attendees 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.Event @@ -35,9 +34,7 @@ import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.Language -import net.fortuna.ical4j.model.property.Action import net.fortuna.ical4j.model.property.Clazz import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart @@ -47,16 +44,13 @@ import net.fortuna.ical4j.model.property.XProperty import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import java.net.URI -import java.time.Duration /** * Tests mapping from [at.bitfire.synctools.storage.calendar.EventAndExceptions] to [Event]. @@ -565,82 +559,6 @@ class LegacyAndroidEventProcessorTest { } - private fun populateReminder(destinationCalendar: AndroidCalendar = calendar, builder: ContentValues.() -> Unit): VAlarm? { - populateEvent(true, destinationCalendar = destinationCalendar, insertCallback = { id -> - val reminderValues = ContentValues() - reminderValues.put(Reminders.EVENT_ID, id) - builder(reminderValues) - client.insert(Reminders.CONTENT_URI.asSyncAdapter(testAccount), reminderValues) - }).let { result -> - return result.alarms.firstOrNull() - } - } - - @Test - fun testPopulateReminder_TypeEmail_AccountNameEmail() { - // account name looks like an email address - assumeTrue(testAccount.name.endsWith("@example.com")) - - populateReminder { - put(Reminders.METHOD, Reminders.METHOD_EMAIL) - put(Reminders.MINUTES, 10) - }!!.let { alarm -> - assertEquals(Action.EMAIL, alarm.action) - assertNotNull(alarm.summary) - assertNotNull(alarm.description) - } - } - - @Test - fun testPopulateReminder_TypeEmail_AccountNameNotEmail() { - // test account name that doesn't look like an email address - val nonEmailAccount = Account("ical4android", ACCOUNT_TYPE_LOCAL) - val testCalendar = TestCalendar.findOrCreate(nonEmailAccount, client) - try { - populateReminder(testCalendar) { - put(Reminders.METHOD, Reminders.METHOD_EMAIL) - }!!.let { alarm -> - assertEquals(Action.DISPLAY, alarm.action) - assertNotNull(alarm.description) - } - } finally { - testCalendar.delete() - } - } - - @Test - fun testPopulateReminder_TypeNotEmail() { - for (type in arrayOf(null, Reminders.METHOD_ALARM, Reminders.METHOD_ALERT, Reminders.METHOD_DEFAULT, Reminders.METHOD_SMS)) - populateReminder { - put(Reminders.METHOD, type) - put(Reminders.MINUTES, 10) - }!!.let { alarm -> - assertEquals(Action.DISPLAY, alarm.action) - assertNotNull(alarm.description) - } - } - - @Test - fun testPopulateReminder_Minutes_Positive() { - populateReminder { - put(Reminders.METHOD, Reminders.METHOD_ALERT) - put(Reminders.MINUTES, 10) - }!!.let { alarm -> - assertEquals(Duration.ofMinutes(-10), alarm.trigger.duration) - } - } - - @Test - fun testPopulateReminder_Minutes_Negative() { - populateReminder { - put(Reminders.METHOD, Reminders.METHOD_ALERT) - put(Reminders.MINUTES, -10) - }!!.let { alarm -> - assertEquals(Duration.ofMinutes(10), alarm.trigger.duration) - } - } - - @Test fun testPopulateUnknownProperty() { val params = ParameterList() 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 index e102e095..72bcd041 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -11,8 +11,6 @@ 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.UnknownProperty import at.bitfire.ical4android.util.AndroidTimeUtils @@ -23,6 +21,7 @@ import at.bitfire.synctools.exception.InvalidLocalResourceException import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor +import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions @@ -30,12 +29,8 @@ import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.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 @@ -45,7 +40,6 @@ 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 @@ -77,7 +71,8 @@ class LegacyAndroidEventProcessor( private val fieldProcessors: Array = arrayOf( UidProcessor(), - AttendeesProcessor() + AttendeesProcessor(), + RemindersProcessor(accountName) ) @@ -109,7 +104,6 @@ class LegacyAndroidEventProcessor( for (subValue in entity.subValues) { val subValues = subValue.values when (subValue.uri) { - Reminders.CONTENT_URI -> populateReminder(subValues, to = to) ExtendedProperties.CONTENT_URI -> populateExtended(subValues, to = to) } } @@ -309,38 +303,6 @@ class LegacyAndroidEventProcessor( } } - 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 -> { - 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", accountName, 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) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt new file mode 100644 index 00000000..f36d24e0 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt @@ -0,0 +1,68 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Reminders +import android.util.Patterns +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attendee +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.Summary +import java.net.URI +import java.time.Duration +import java.util.logging.Level +import java.util.logging.Logger + +class RemindersProcessor( + private val accountName: String +): AndroidEventFieldProcessor { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(entity: Entity, to: Event) { + for (row in entity.subValues.filter { it.uri == Reminders.CONTENT_URI }) + populateReminder(row.values, to) + } + + 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 -> { + 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", accountName, 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 + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt new file mode 100644 index 00000000..82aa037d --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt @@ -0,0 +1,108 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Reminders +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.Action +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +class RemindersProcessorTest { + + private val accountName = "user@example.com" + private val processor = RemindersProcessor(accountName) + + @Test + fun `Email reminder`() { + // account name looks like an email address + assumeTrue(accountName.endsWith("@example.com")) + + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_EMAIL, + Reminders.MINUTES to 10 + )) + val result = Event() + processor.process(entity, result) + val alarm = result.alarms.first() + assertEquals(Action.EMAIL, alarm.action) + assertNotNull(alarm.summary) + assertNotNull(alarm.description) + } + + @Test + fun `Email reminder (account name is not an email address)`() { + // test account name that doesn't look like an email address + val nonEmailAccountName = "ical4android" + val processor2 = RemindersProcessor(nonEmailAccountName) + + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_EMAIL, + Reminders.MINUTES to 10 + )) + val result = Event() + processor2.process(entity, result) + val alarm = result.alarms.first() + assertEquals(Action.DISPLAY, alarm.action) + assertNotNull(alarm.description) + } + + @Test + fun `Non-email reminder`() { + for (type in arrayOf(null, Reminders.METHOD_ALARM, Reminders.METHOD_ALERT, Reminders.METHOD_DEFAULT, Reminders.METHOD_SMS)) { + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to type, + Reminders.MINUTES to 10 + )) + val result = Event() + processor.process(entity, result) + val alarm = result.alarms.first() + assertEquals(Action.DISPLAY, alarm.action) + assertNotNull(alarm.description) + } + } + + + @Test + fun `Number of minutes is positive`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_ALERT, + Reminders.MINUTES to 10 + )) + val result = Event() + processor.process(entity, result) + val alarm = result.alarms.first() + assertEquals(Duration.ofMinutes(-10), alarm.trigger.duration) + } + + @Test + fun `Number of minutes is negative`() { + val entity = Entity(ContentValues()) + entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.METHOD to Reminders.METHOD_ALERT, + Reminders.MINUTES to -10 + )) + val result = Event() + processor.process(entity, result) + val alarm = result.alarms.first() + assertEquals(Duration.ofMinutes(10), alarm.trigger.duration) + } + +} \ No newline at end of file From fd8f1ecbfd065fffac7ebb3297da5e8bb97772ac Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 19 Sep 2025 14:11:11 +0200 Subject: [PATCH 04/19] Fix typo --- .../synctools/mapping/calendar/processor/AttendeesProcessor.kt | 2 +- .../synctools/mapping/calendar/processor/RemindersProcessor.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt index f4bb70b3..30cdb687 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt @@ -32,7 +32,7 @@ class AttendeesProcessor: AndroidEventFieldProcessor { } private fun populateAttendee(row: ContentValues, to: Event) { - logger.log(Level.FINE, "Read event attendee from calender provider", row) + logger.log(Level.FINE, "Read event attendee from calendar provider", row) try { val attendee: Attendee diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt index f36d24e0..4a5a40c4 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt @@ -34,7 +34,7 @@ class RemindersProcessor( } private fun populateReminder(row: ContentValues, to: Event) { - logger.log(Level.FINE, "Read event reminder from calender provider", row) + logger.log(Level.FINE, "Read event reminder from calendar provider", row) val alarm = VAlarm(Duration.ofMinutes(-row.getAsLong(Reminders.MINUTES))) From 5fde6979c8f96c7bd76a195d2abbe6f1e1dba70f Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 19 Sep 2025 14:20:34 +0200 Subject: [PATCH 05/19] Also provide main Entity to processors --- .../calendar/LegacyAndroidEventProcessor.kt | 16 +++++++++++----- .../processor/AndroidEventFieldProcessor.kt | 17 ++++++++++++++--- .../calendar/processor/AttendeesProcessor.kt | 4 ++-- .../calendar/processor/RemindersProcessor.kt | 4 ++-- .../mapping/calendar/processor/UidProcessor.kt | 6 +++--- 5 files changed, 32 insertions(+), 15 deletions(-) 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 index 72bcd041..619e01e7 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -77,9 +77,14 @@ class LegacyAndroidEventProcessor( fun populate(eventAndExceptions: EventAndExceptions, to: Event) { - populateEvent(eventAndExceptions.main, to = to) + populateEvent( + entity = eventAndExceptions.main, + main = eventAndExceptions.main, + to = to + ) populateExceptions( exceptions = eventAndExceptions.exceptions, + main = eventAndExceptions.main, originalAllDay = DateUtils.isDate(to.dtStart), to = to ) @@ -93,9 +98,10 @@ class LegacyAndroidEventProcessor( * an [Event] data object. * * @param entity event row as returned by the calendar provider + * @param main main event row as returned by the calendar provider * @param to destination data object */ - private fun populateEvent(entity: Entity, to: Event) { + private fun populateEvent(entity: Entity, main: Entity, to: Event) { // legacy processors val hasAttendees = entity.subValues.any { it.uri == Attendees.CONTENT_URI } populateEventRow(entity.entityValues, groupScheduled = hasAttendees, to = to) @@ -110,7 +116,7 @@ class LegacyAndroidEventProcessor( // new processors for (processor in fieldProcessors) - processor.process(entity, to) + processor.process(from = entity, main = main, to = to) } private fun populateEventRow(row: ContentValues, groupScheduled: Boolean, to: Event) { @@ -328,12 +334,12 @@ class LegacyAndroidEventProcessor( } } - private fun populateExceptions(exceptions: List, originalAllDay: Boolean, to: Event) { + private fun populateExceptions(exceptions: List, main: Entity, originalAllDay: Boolean, to: Event) { for (exception in exceptions) { val exceptionEvent = Event() // convert exception row to Event - populateEvent(exception, to = exceptionEvent) + populateEvent(exception, main, to = exceptionEvent) // exceptions are required to have a RECURRENCE-ID val recurrenceId = exceptionEvent.recurrenceId ?: continue diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt index afe837e9..83f450ea 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt @@ -15,11 +15,22 @@ interface AndroidEventFieldProcessor { * Takes specific data from an event (= event row plus data rows, taken from the content provider) * and maps it to the [Event] data class. * + * If [from] references the same object as [main], this method is called for a main event (not an exception). + * If [from] references another object as [main], this method is called for an exception (not a main event). + * + * So you can use (note the referential equality operator): + * + * ``` + * val isMainEvent = from === main // or + * val isException = from !== main + * ``` + * * In a later step of refactoring, it should map to [net.fortuna.ical4j.model.component.VEvent]. * - * @param entity event from content provider - * @param to data object where the mapped data shall be stored + * @param from event from content provider + * @param main main event from content provider + * @param to destination object where the mapped data are stored */ - fun process(entity: Entity, to: Event) + fun process(from: Entity, main: Entity, to: Event) } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt index 30cdb687..5b748766 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt @@ -26,8 +26,8 @@ class AttendeesProcessor: AndroidEventFieldProcessor { private val logger get() = Logger.getLogger(javaClass.name) - override fun process(entity: Entity, to: Event) { - for (row in entity.subValues.filter { it.uri == Attendees.CONTENT_URI }) + override fun process(from: Entity, to: Event) { + for (row in from.subValues.filter { it.uri == Attendees.CONTENT_URI }) populateAttendee(row.values, to) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt index 4a5a40c4..c8e52225 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt @@ -28,8 +28,8 @@ class RemindersProcessor( private val logger get() = Logger.getLogger(javaClass.name) - override fun process(entity: Entity, to: Event) { - for (row in entity.subValues.filter { it.uri == Reminders.CONTENT_URI }) + override fun process(from: Entity, to: Event) { + for (row in from.subValues.filter { it.uri == Reminders.CONTENT_URI }) populateReminder(row.values, to) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt index ea77bcde..ce85112e 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt @@ -14,10 +14,10 @@ import at.bitfire.synctools.storage.calendar.AndroidEvent2 class UidProcessor: AndroidEventFieldProcessor { - override fun process(entity: Entity, to: Event) { + override fun process(from: Entity, to: Event) { // take from event row or Google Calendar extended property - to.uid = entity.entityValues.getAsString(Events.UID_2445) ?: - uidFromExtendedProperties(entity.subValues) + to.uid = from.entityValues.getAsString(Events.UID_2445) ?: + uidFromExtendedProperties(from.subValues) } private fun uidFromExtendedProperties(rows: List): String? { From f283121a735a2e7a2c80ae86d1b781bb847fda53 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 19 Sep 2025 14:22:46 +0200 Subject: [PATCH 06/19] Indenting --- .../mapping/calendar/processor/RemindersProcessorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt index 82aa037d..748040ec 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt @@ -33,7 +33,7 @@ class RemindersProcessorTest { val entity = Entity(ContentValues()) entity.addSubValue(Reminders.CONTENT_URI, contentValuesOf( - Reminders.METHOD to Reminders.METHOD_EMAIL, + Reminders.METHOD to Reminders.METHOD_EMAIL, Reminders.MINUTES to 10 )) val result = Event() From 3654075b6b7d2f4edba4e78c69a01f96c3911ead Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 10:14:06 +0200 Subject: [PATCH 07/19] Fix processor calls + tests --- .../calendar/processor/AttendeesProcessor.kt | 2 +- .../calendar/processor/RemindersProcessor.kt | 2 +- .../calendar/processor/UidProcessor.kt | 2 +- .../processor/AttendeesProcessorTest.kt | 40 +++++++++---------- .../processor/RemindersProcessorTest.kt | 10 ++--- .../calendar/processor/UidProcessorTest.kt | 9 +++-- 6 files changed, 33 insertions(+), 32 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt index 5b748766..9eabef2b 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessor.kt @@ -26,7 +26,7 @@ class AttendeesProcessor: AndroidEventFieldProcessor { private val logger get() = Logger.getLogger(javaClass.name) - override fun process(from: Entity, to: Event) { + override fun process(from: Entity, main: Entity, to: Event) { for (row in from.subValues.filter { it.uri == Attendees.CONTENT_URI }) populateAttendee(row.values, to) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt index c8e52225..265465ec 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessor.kt @@ -28,7 +28,7 @@ class RemindersProcessor( private val logger get() = Logger.getLogger(javaClass.name) - override fun process(from: Entity, to: Event) { + override fun process(from: Entity, main: Entity, to: Event) { for (row in from.subValues.filter { it.uri == Reminders.CONTENT_URI }) populateReminder(row.values, to) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt index ce85112e..415ab038 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessor.kt @@ -14,7 +14,7 @@ import at.bitfire.synctools.storage.calendar.AndroidEvent2 class UidProcessor: AndroidEventFieldProcessor { - override fun process(from: Entity, to: Event) { + override fun process(from: Entity, main: Entity, to: Event) { // take from event row or Google Calendar extended property to.uid = from.entityValues.getAsString(Events.UID_2445) ?: uidFromExtendedProperties(from.subValues) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt index d23bfe3b..c7dbfca7 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AttendeesProcessorTest.kt @@ -37,7 +37,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_EMAIL to "attendee@example.com" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) assertEquals(URI("mailto:attendee@example.com"), result.attendees.first().calAddress) } @@ -49,7 +49,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_IDENTITY to "//example.com/principals/attendee" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) assertEquals(URI("https://example.com/principals/attendee"), result.attendees.first().calAddress) } @@ -62,7 +62,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_IDENTITY to "//example.com/principals/attendee" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) assertEquals(1, result.attendees.size) val attendee = result.attendees.first() assertEquals(URI("https://example.com/principals/attendee"), attendee.calAddress) @@ -81,7 +81,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to type )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertNull(attendee.getParameter(Parameter.CUTYPE)) } @@ -97,7 +97,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to type )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(CuType.GROUP, attendee.getParameter(Parameter.CUTYPE)) } @@ -113,7 +113,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to type )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertNull(attendee.getParameter(Parameter.CUTYPE)) assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) @@ -129,7 +129,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) assertEquals(Role.CHAIR, attendee.getParameter(Parameter.ROLE)) @@ -146,7 +146,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to type )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(CuType.UNKNOWN, attendee.getParameter(Parameter.CUTYPE)) } @@ -163,7 +163,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to Attendees.TYPE_NONE )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertNull(attendee.getParameter(Parameter.ROLE)) } @@ -179,7 +179,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to Attendees.TYPE_REQUIRED )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertNull(attendee.getParameter(Parameter.ROLE)) } @@ -195,7 +195,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to Attendees.TYPE_OPTIONAL )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(Role.OPT_PARTICIPANT, attendee.getParameter(Parameter.ROLE)) } @@ -211,7 +211,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(CuType.RESOURCE, attendee.getParameter(Parameter.CUTYPE)) } @@ -226,7 +226,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_TYPE to Attendees.TYPE_RESOURCE )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(CuType.ROOM, attendee.getParameter(Parameter.CUTYPE)) } @@ -239,7 +239,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_EMAIL to "attendee@example.com" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertNull(attendee.getParameter(Parameter.PARTSTAT)) } @@ -252,7 +252,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_INVITED )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(PartStat.NEEDS_ACTION, attendee.getParameter(Parameter.PARTSTAT)) } @@ -265,7 +265,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_ACCEPTED )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(PartStat.ACCEPTED, attendee.getParameter(Parameter.PARTSTAT)) } @@ -278,7 +278,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_DECLINED )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(PartStat.DECLINED, attendee.getParameter(Parameter.PARTSTAT)) } @@ -291,7 +291,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_TENTATIVE )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertEquals(PartStat.TENTATIVE, attendee.getParameter(Parameter.PARTSTAT)) } @@ -304,7 +304,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_STATUS to Attendees.ATTENDEE_STATUS_NONE )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertNull(attendee.getParameter(Parameter.PARTSTAT)) } @@ -317,7 +317,7 @@ class AttendeesProcessorTest { Attendees.ATTENDEE_EMAIL to "attendee@example.com" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val attendee = result.attendees.first() assertTrue(attendee.getParameter(Parameter.RSVP).rsvp) } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt index 748040ec..746f69b4 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/RemindersProcessorTest.kt @@ -37,7 +37,7 @@ class RemindersProcessorTest { Reminders.MINUTES to 10 )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val alarm = result.alarms.first() assertEquals(Action.EMAIL, alarm.action) assertNotNull(alarm.summary) @@ -56,7 +56,7 @@ class RemindersProcessorTest { Reminders.MINUTES to 10 )) val result = Event() - processor2.process(entity, result) + processor2.process(entity, entity, result) val alarm = result.alarms.first() assertEquals(Action.DISPLAY, alarm.action) assertNotNull(alarm.description) @@ -71,7 +71,7 @@ class RemindersProcessorTest { Reminders.MINUTES to 10 )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val alarm = result.alarms.first() assertEquals(Action.DISPLAY, alarm.action) assertNotNull(alarm.description) @@ -87,7 +87,7 @@ class RemindersProcessorTest { Reminders.MINUTES to 10 )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val alarm = result.alarms.first() assertEquals(Duration.ofMinutes(-10), alarm.trigger.duration) } @@ -100,7 +100,7 @@ class RemindersProcessorTest { Reminders.MINUTES to -10 )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) val alarm = result.alarms.first() assertEquals(Duration.ofMinutes(10), alarm.trigger.duration) } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt index 5d0d3424..07069509 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UidProcessorTest.kt @@ -27,7 +27,8 @@ class UidProcessorTest { @Test fun `No UID`() { val result = Event() - processor.process(Entity(ContentValues()), result) + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) assertNull(result.uid) } @@ -37,7 +38,7 @@ class UidProcessorTest { Events.UID_2445 to "from-event" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) assertEquals("from-event", result.uid) } @@ -49,7 +50,7 @@ class UidProcessorTest { ExtendedProperties.VALUE to "from-extended" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) assertEquals("from-extended", result.uid) } @@ -63,7 +64,7 @@ class UidProcessorTest { ExtendedProperties.VALUE to "from-extended" )) val result = Event() - processor.process(entity, result) + processor.process(entity, entity, result) assertEquals("from-event", result.uid) } From 97ae39ff038c23e3a0f05245e07b0c43b6208fd5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 10:32:37 +0200 Subject: [PATCH 08/19] Add CategoriesProcessor --- .../calendar/LegacyAndroidEventProcessor.kt | 5 +- .../processor/AndroidEventFieldProcessor.kt | 1 + .../calendar/processor/CategoriesProcessor.kt | 24 ++++++++++ .../processor/CategoriesProcessorTest.kt | 46 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessorTest.kt 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 index 619e01e7..38b940fe 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -21,6 +21,7 @@ import at.bitfire.synctools.exception.InvalidLocalResourceException import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor +import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 @@ -71,6 +72,7 @@ class LegacyAndroidEventProcessor( private val fieldProcessors: Array = arrayOf( UidProcessor(), + CategoriesProcessor(), AttendeesProcessor(), RemindersProcessor(accountName) ) @@ -316,9 +318,6 @@ class LegacyAndroidEventProcessor( try { when (name) { - AndroidEvent2.EXTNAME_CATEGORIES -> - to.categories += rawValue.split(AndroidEvent2.CATEGORIES_SEPARATOR) - AndroidEvent2.EXTNAME_URL -> try { to.url = URI(rawValue) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt index 83f450ea..8aa80988 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AndroidEventFieldProcessor.kt @@ -30,6 +30,7 @@ interface AndroidEventFieldProcessor { * @param from event from content provider * @param main main event from content provider * @param to destination object where the mapped data are stored + * (no explicit `null` values needed for fields that are not present) */ fun process(from: Entity, main: Entity, to: Event) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessor.kt new file mode 100644 index 00000000..0dc333f6 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessor.kt @@ -0,0 +1,24 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.ExtendedProperties +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 + +class CategoriesProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + val extended = from.subValues.filter { it.uri == ExtendedProperties.CONTENT_URI }.map { it.values } + val categories = extended.firstOrNull { it.getAsString(ExtendedProperties.NAME) == AndroidEvent2.EXTNAME_CATEGORIES } + val listValue = categories?.getAsString(ExtendedProperties.VALUE) + if (listValue != null) + to.categories += listValue.split(AndroidEvent2.CATEGORIES_SEPARATOR) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessorTest.kt new file mode 100644 index 00000000..e3b00157 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/CategoriesProcessorTest.kt @@ -0,0 +1,46 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.ExtendedProperties +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CategoriesProcessorTest { + + private val processor = CategoriesProcessor() + + @Test + fun `No categories`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertTrue(result.categories.isEmpty()) + } + + @Test + fun `Multiple categories`() { + val result = Event() + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_CATEGORIES, + ExtendedProperties.VALUE to "Cat 1\\Cat 2" + )) + processor.process(entity, entity, result) + assertEquals(listOf("Cat 1", "Cat 2"), result.categories) + } + +} \ No newline at end of file From dd04c45b72352e6ae4362e2fb0d0e2c7f3831c71 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 10:38:02 +0200 Subject: [PATCH 09/19] Add UrlProcessor --- .../calendar/LegacyAndroidEventProcessor.kt | 16 +++-- .../calendar/processor/UrlProcessor.kt | 30 ++++++++++ .../calendar/processor/UrlProcessorTest.kt | 59 +++++++++++++++++++ 3 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessorTest.kt 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 index 38b940fe..4b1b00cf 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -24,6 +24,7 @@ import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor +import at.bitfire.synctools.mapping.calendar.processor.UrlProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions import net.fortuna.ical4j.model.Date @@ -71,10 +72,14 @@ class LegacyAndroidEventProcessor( private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } private val fieldProcessors: Array = arrayOf( + // event row fields UidProcessor(), - CategoriesProcessor(), + // data rows (sub-values) AttendeesProcessor(), - RemindersProcessor(accountName) + RemindersProcessor(accountName), + // extended properties + CategoriesProcessor(), + UrlProcessor() ) @@ -318,13 +323,6 @@ class LegacyAndroidEventProcessor( try { when (name) { - AndroidEvent2.EXTNAME_URL -> - try { - to.url = URI(rawValue) - } catch(_: URISyntaxException) { - logger.warning("Won't process invalid local URL: $rawValue") - } - UnknownProperty.CONTENT_ITEM_TYPE -> to.unknownProperties += UnknownProperty.fromJsonString(rawValue) } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessor.kt new file mode 100644 index 00000000..546e3ec6 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessor.kt @@ -0,0 +1,30 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.ExtendedProperties +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import java.net.URI +import java.net.URISyntaxException + +class UrlProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + val extended = from.subValues.filter { it.uri == ExtendedProperties.CONTENT_URI }.map { it.values } + val urlRow = extended.firstOrNull { it.getAsString(ExtendedProperties.NAME) == AndroidEvent2.EXTNAME_URL } + val url = urlRow?.getAsString(ExtendedProperties.VALUE) + if (url != null) + to.url = try { + URI(url) + } catch (_: URISyntaxException) { + null + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessorTest.kt new file mode 100644 index 00000000..0fbab5bb --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UrlProcessorTest.kt @@ -0,0 +1,59 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.ExtendedProperties +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.net.URI + +@RunWith(RobolectricTestRunner::class) +class UrlProcessorTest { + + private val processor = UrlProcessor() + + @Test + fun `No URL`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.url) + } + + @Test + fun `Invalid URL`() { + val result = Event() + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL, + ExtendedProperties.VALUE to "invalid\\uri" + )) + processor.process(entity, entity, result) + assertNull(result.url) + } + + @Test + fun `Valid URL`() { + val result = Event() + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL, + ExtendedProperties.VALUE to "https://example.com" + )) + processor.process(entity, entity, result) + assertEquals(URI("https://example.com"), result.url) + } + +} \ No newline at end of file From 6580272ca581d6c05ac34be3a19555dd1c019eb0 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 11:26:10 +0200 Subject: [PATCH 10/19] Add UnknownPropertiesProcessor --- .../calendar/LegacyAndroidEventProcessor.kt | 27 +-------- .../processor/UnknownPropertiesProcessor.kt | 34 +++++++++++ .../UnknownPropertiesProcessorTest.kt | 57 +++++++++++++++++++ 3 files changed, 93 insertions(+), 25 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt 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 index 4b1b00cf..f178bb6c 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -10,9 +10,7 @@ import android.content.ContentValues import android.content.Entity import android.provider.CalendarContract.Attendees import android.provider.CalendarContract.Events -import android.provider.CalendarContract.ExtendedProperties import at.bitfire.ical4android.Event -import at.bitfire.ical4android.UnknownProperty import at.bitfire.ical4android.util.AndroidTimeUtils import at.bitfire.ical4android.util.DateUtils import at.bitfire.ical4android.util.TimeApiExtensions @@ -24,6 +22,7 @@ import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor +import at.bitfire.synctools.mapping.calendar.processor.UnknownPropertiesProcessor import at.bitfire.synctools.mapping.calendar.processor.UrlProcessor import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions @@ -79,6 +78,7 @@ class LegacyAndroidEventProcessor( RemindersProcessor(accountName), // extended properties CategoriesProcessor(), + UnknownPropertiesProcessor(), UrlProcessor() ) @@ -113,14 +113,6 @@ class LegacyAndroidEventProcessor( val hasAttendees = entity.subValues.any { it.uri == Attendees.CONTENT_URI } populateEventRow(entity.entityValues, groupScheduled = hasAttendees, to = to) - // data rows - for (subValue in entity.subValues) { - val subValues = subValue.values - when (subValue.uri) { - ExtendedProperties.CONTENT_URI -> populateExtended(subValues, to = to) - } - } - // new processors for (processor in fieldProcessors) processor.process(from = entity, main = main, to = to) @@ -316,21 +308,6 @@ class LegacyAndroidEventProcessor( } } - 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) { - 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(exceptions: List, main: Entity, originalAllDay: Boolean, to: Event) { for (exception in exceptions) { val exceptionEvent = Event() diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt new file mode 100644 index 00000000..038a0826 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt @@ -0,0 +1,34 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.ExtendedProperties +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import org.json.JSONException +import java.util.logging.Level +import java.util.logging.Logger + +class UnknownPropertiesProcessor: AndroidEventFieldProcessor { + + val logger: Logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: Event) { + val extended = from.subValues.filter { it.uri == ExtendedProperties.CONTENT_URI }.map { it.values } + val unknownProperties = extended.filter { it.getAsString(ExtendedProperties.NAME) == UnknownProperty.CONTENT_ITEM_TYPE } + val jsonProperties = unknownProperties.mapNotNull { it.getAsString(ExtendedProperties.VALUE) } + for (json in jsonProperties) + try { + to.unknownProperties += UnknownProperty.fromJsonString(json) + } catch (e: JSONException) { + logger.log(Level.WARNING, "Couldn't parse unknown properties", e) + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt new file mode 100644 index 00000000..2800020e --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt @@ -0,0 +1,57 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.ExtendedProperties +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.XProperty +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UnknownPropertiesProcessorTest { + + private val processor = UnknownPropertiesProcessor() + + @Test + fun `No unknown properties`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertTrue(result.unknownProperties.isEmpty()) + } + + @Test + fun `Two unknown properties`() { + val result = Event() + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, + ExtendedProperties.VALUE to "[\"X-PROP1\", \"value 1\"]" + )) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, + ExtendedProperties.VALUE to "[\"X-PROP2\", \"value 2\", {\"arg1\": \"arg-value\"}]" + )) + processor.process(entity, entity, result) + assertEquals(listOf( + XProperty("X-PROP1", "value 1"), + XProperty("X-PROP2", "value 2").apply { + parameters.add(XParameter("ARG1", "arg-value")) + }, + ), result.unknownProperties) + } + +} \ No newline at end of file From cc86aff0d6ccb3d9040e7ef315d66f1245d82d0b Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 12:15:22 +0200 Subject: [PATCH 11/19] Add AccessLevelProcessor --- .../calendar/LegacyAndroidEventProcessor.kt | 31 +----- .../processor/AccessLevelProcessor.kt | 55 ++++++++++ .../processor/UnknownPropertiesProcessor.kt | 19 +++- .../processor/AccessLevelProcessorTest.kt | 102 ++++++++++++++++++ .../UnknownPropertiesProcessorTest.kt | 6 +- 5 files changed, 183 insertions(+), 30 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessorTest.kt 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 index f178bb6c..a37f57db 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -17,6 +17,7 @@ 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.mapping.calendar.processor.AccessLevelProcessor import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor @@ -31,7 +32,6 @@ import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.parameter.Value -import net.fortuna.ical4j.model.property.Clazz import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.ExDate @@ -71,8 +71,9 @@ class LegacyAndroidEventProcessor( private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } private val fieldProcessors: Array = arrayOf( - // event row fields + // event row fields (order by targeted property as in RFC 5545 3.6.1) UidProcessor(), + AccessLevelProcessor(), // data rows (sub-values) AttendeesProcessor(), RemindersProcessor(accountName), @@ -95,9 +96,6 @@ class LegacyAndroidEventProcessor( originalAllDay = DateUtils.isDate(to.dtStart), to = to ) - - // post-processing - useRetainedClassification(to) } /** @@ -281,13 +279,6 @@ class LegacyAndroidEventProcessor( } } - // 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 @@ -345,20 +336,4 @@ class LegacyAndroidEventProcessor( } } - 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/mapping/calendar/processor/AccessLevelProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessor.kt new file mode 100644 index 00000000..13d1c0a5 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessor.kt @@ -0,0 +1,55 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import net.fortuna.ical4j.model.property.Clazz +import org.json.JSONException + +class AccessLevelProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + val values = from.entityValues + + // take classification from main row + to.classification = when (values.getAsInteger(Events.ACCESS_LEVEL)) { + Events.ACCESS_PUBLIC -> + Clazz.PUBLIC + + Events.ACCESS_PRIVATE -> + Clazz.PRIVATE + + Events.ACCESS_CONFIDENTIAL -> + Clazz.CONFIDENTIAL + + else /* Events.ACCESS_DEFAULT */ -> + retainedClassification(from) + } + } + + private fun retainedClassification(from: Entity): Clazz? { + val extendedProperties = from.subValues.filter { it.uri == ExtendedProperties.CONTENT_URI }.map { it.values } + val unknownProperties = extendedProperties.filter { it.getAsString(ExtendedProperties.NAME) == UnknownProperty.CONTENT_ITEM_TYPE } + val retainedClassification: Clazz? = unknownProperties.firstNotNullOfOrNull { + val json = it.getAsString(ExtendedProperties.VALUE) + val prop = try { + UnknownProperty.fromJsonString(json) + } catch (_: JSONException) { + // not parseable + null + } + prop as? Clazz + } + + return retainedClassification + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt index 038a0826..21480d3b 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt @@ -10,6 +10,7 @@ import android.content.Entity import android.provider.CalendarContract.ExtendedProperties import at.bitfire.ical4android.Event import at.bitfire.ical4android.UnknownProperty +import net.fortuna.ical4j.model.Property import org.json.JSONException import java.util.logging.Level import java.util.logging.Logger @@ -25,10 +26,26 @@ class UnknownPropertiesProcessor: AndroidEventFieldProcessor { val jsonProperties = unknownProperties.mapNotNull { it.getAsString(ExtendedProperties.VALUE) } for (json in jsonProperties) try { - to.unknownProperties += UnknownProperty.fromJsonString(json) + val prop = UnknownProperty.fromJsonString(json) + if (!EXCLUDED.contains(prop.name)) + to.unknownProperties += prop } catch (e: JSONException) { logger.log(Level.WARNING, "Couldn't parse unknown properties", e) } } + + companion object { + + /** + * These properties are not restored into the [Event.unknownProperties] list. + * Usually they're used by other processors instead. + * + * In the future, this shouldn't be necessary anymore because when other builders/processors store data, + * they shouldn't use an unknown property, but instead define their own extended property. + */ + val EXCLUDED = arrayOf(Property.CLASS) + + } + } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessorTest.kt new file mode 100644 index 00000000..f380ec02 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AccessLevelProcessorTest.kt @@ -0,0 +1,102 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.ExtendedProperties +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.UnknownProperty +import net.fortuna.ical4j.model.property.Clazz +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AccessLevelProcessorTest { + + private val processor = AccessLevelProcessor() + + @Test + fun `No access-level`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.classification) + } + + @Test + fun `No access-level, but retained classification`() { + val result = Event() + val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, + ExtendedProperties.VALUE to "[\"CLASS\",\"x-other\"]" + )) + processor.process(entity, entity, result) + assertEquals(Clazz("x-other"), result.classification) + } + + @Test + fun `Access-level DEFAULT`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ACCESS_LEVEL to Events.ACCESS_DEFAULT + )) + processor.process(entity, entity, result) + assertNull(result.classification) + } + + @Test + fun `Access-level DEFAULT plus retained classification`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ACCESS_LEVEL to Events.ACCESS_DEFAULT + )) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( + ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, + ExtendedProperties.VALUE to "[\"CLASS\",\"x-other\"]" + )) + processor.process(entity, entity, result) + assertEquals(Clazz("x-other"), result.classification) + } + + @Test + fun `Access-level PUBLIC`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ACCESS_LEVEL to Events.ACCESS_PUBLIC + )) + processor.process(entity, entity, result) + assertEquals(Clazz.PUBLIC, result.classification) + } + + @Test + fun `Access-level PRIVATE`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ACCESS_LEVEL to Events.ACCESS_PRIVATE + )) + processor.process(entity, entity, result) + assertEquals(Clazz.PRIVATE, result.classification) + } + + @Test + fun `Access-level CONFIDENTIAL`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ACCESS_LEVEL to Events.ACCESS_CONFIDENTIAL + )) + processor.process(entity, entity, result) + assertEquals(Clazz.CONFIDENTIAL, result.classification) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt index 2800020e..826152ac 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessorTest.kt @@ -34,9 +34,13 @@ class UnknownPropertiesProcessorTest { } @Test - fun `Two unknown properties`() { + fun `Three unknown properties, one of them excluded`() { val result = Event() val entity = Entity(ContentValues()) + entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( // used by ClassificationProcessor + ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, + ExtendedProperties.VALUE to "[\"CLASS\", \"CONFIDENTIAL\"]" + )) entity.addSubValue(ExtendedProperties.CONTENT_URI, contentValuesOf( ExtendedProperties.NAME to UnknownProperty.CONTENT_ITEM_TYPE, ExtendedProperties.VALUE to "[\"X-PROP1\", \"value 1\"]" From 2b484c53d3411bab6275ac5a32a6a02a6457d2e5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 12:30:57 +0200 Subject: [PATCH 12/19] Add processors for title, location, description --- .../LegacyAndroidEventProcessorTest.kt | 108 ------------------ .../calendar/LegacyAndroidEventProcessor.kt | 21 ++-- .../processor/DescriptionProcessor.kt | 20 ++++ .../calendar/processor/LocationProcessor.kt | 20 ++++ .../calendar/processor/TitleProcessor.kt | 20 ++++ .../processor/DescriptionProcessorTest.kt | 53 +++++++++ .../processor/LocationProcessorTest.kt | 53 +++++++++ .../calendar/processor/TitleProcessorTest.kt | 53 +++++++++ 8 files changed, 230 insertions(+), 118 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessor.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessor.kt create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessorTest.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessorTest.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index f5efed67..69b35326 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -35,7 +35,6 @@ import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.parameter.Language -import net.fortuna.ical4j.model.property.Clazz import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RecurrenceId @@ -50,7 +49,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import java.net.URI /** * Tests mapping from [at.bitfire.synctools.storage.calendar.EventAndExceptions] to [Event]. @@ -344,42 +342,6 @@ class LegacyAndroidEventProcessorTest { } } - @Test - fun testPopulateEvent_Summary() { - populateEvent(true) { - put(Events.TITLE, "Sample Title") - }.let { result -> - assertEquals("Sample Title", result.summary) - } - } - - @Test - fun testPopulateEvent_Location() { - populateEvent(true) { - put(Events.EVENT_LOCATION, "Sample Location") - }.let { result -> - assertEquals("Sample Location", result.location) - } - } - - @Test - fun testPopulateEvent_Url() { - populateEvent(true, - extendedProperties = mapOf(AndroidEvent2.EXTNAME_URL to "https://example.com") - ).let { result -> - assertEquals(URI("https://example.com"), result.url) - } - } - - @Test - fun testPopulateEvent_Description() { - populateEvent(true) { - put(Events.DESCRIPTION, "Sample Description") - }.let { result -> - assertEquals("Sample Description", result.description) - } - } - @Test fun testPopulateEvent_Color_FromIndex() { val provider = AndroidCalendarProvider(testAccount, client) @@ -488,76 +450,6 @@ class LegacyAndroidEventProcessorTest { } } - @Test - fun testPopulateEvent_Classification_Public() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_PUBLIC) - }.let { result -> - assertEquals(Clazz.PUBLIC, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Private() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_PRIVATE) - }.let { result -> - assertEquals(Clazz.PRIVATE, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Confidential() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_CONFIDENTIAL) - }.let { result -> - assertEquals(Clazz.CONFIDENTIAL, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Confidential_Retained() { - populateEvent(true, - extendedProperties = mapOf(UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz.CONFIDENTIAL)) - ) { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }.let { result -> - assertEquals(Clazz.CONFIDENTIAL, result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Default() { - populateEvent(true) { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }.let { result -> - assertNull(result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_Custom() { - populateEvent( - true, - valuesBuilder = { - put(Events.ACCESS_LEVEL, Events.ACCESS_DEFAULT) - }, - extendedProperties = mapOf( - UnknownProperty.CONTENT_ITEM_TYPE to UnknownProperty.toJsonString(Clazz("TOP-SECRET")) - ) - ).let { result -> - assertEquals(Clazz("TOP-SECRET"), result.classification) - } - } - - @Test - fun testPopulateEvent_Classification_None() { - populateEvent(true) { - }.let { result -> - assertNull(result.classification) - } - } - @Test fun testPopulateUnknownProperty() { 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 index af0d22c5..5b48ca32 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -21,7 +21,10 @@ import at.bitfire.synctools.mapping.calendar.processor.AccessLevelProcessor import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor +import at.bitfire.synctools.mapping.calendar.processor.DescriptionProcessor +import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor +import at.bitfire.synctools.mapping.calendar.processor.TitleProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.mapping.calendar.processor.UnknownPropertiesProcessor import at.bitfire.synctools.mapping.calendar.processor.UrlProcessor @@ -41,7 +44,6 @@ 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 @@ -72,16 +74,19 @@ class LegacyAndroidEventProcessor( private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } private val fieldProcessors: Array = arrayOf( - // event row fields (order by targeted property as in RFC 5545 3.6.1) + // event row fields UidProcessor(), + TitleProcessor(), + LocationProcessor(), AccessLevelProcessor(), - // data rows (sub-values) - AttendeesProcessor(), - RemindersProcessor(accountName), + DescriptionProcessor(), // extended properties CategoriesProcessor(), UnknownPropertiesProcessor(), - UrlProcessor() + UrlProcessor(), + // data rows (sub-values) + AttendeesProcessor(), + RemindersProcessor(accountName) ) @@ -241,10 +246,6 @@ class LegacyAndroidEventProcessor( to.sequence = row.getAsInteger(AndroidEvent2.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 diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessor.kt new file mode 100644 index 00000000..5acb3bb4 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessor.kt @@ -0,0 +1,20 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import at.bitfire.vcard4android.Utils.trimToNull + +class DescriptionProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + to.description = from.entityValues.getAsString(Events.DESCRIPTION).trimToNull() + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessor.kt new file mode 100644 index 00000000..62d27d5c --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessor.kt @@ -0,0 +1,20 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import at.bitfire.vcard4android.Utils.trimToNull + +class LocationProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + to.location = from.entityValues.getAsString(Events.EVENT_LOCATION).trimToNull() + } + +} \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessor.kt new file mode 100644 index 00000000..466fdc1f --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessor.kt @@ -0,0 +1,20 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import at.bitfire.vcard4android.Utils.trimToNull + +class TitleProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + to.summary = from.entityValues.getAsString(Events.TITLE).trimToNull() + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessorTest.kt new file mode 100644 index 00000000..d87d3e38 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/DescriptionProcessorTest.kt @@ -0,0 +1,53 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DescriptionProcessorTest { + + private val processor = DescriptionProcessor() + + @Test + fun `No description`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.description) + } + + @Test + fun `Blank description`() { + val entity = Entity(contentValuesOf( + Events.DESCRIPTION to " " + )) + val result = Event() + processor.process(entity, entity, result) + assertNull(result.description) + } + + @Test + fun `Description with two words`() { + val entity = Entity(contentValuesOf( + Events.DESCRIPTION to "Two words " + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals("Two words", result.description) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessorTest.kt new file mode 100644 index 00000000..365f2c02 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/LocationProcessorTest.kt @@ -0,0 +1,53 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LocationProcessorTest { + + private val processor = LocationProcessor() + + @Test + fun `No event location`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.location) + } + + @Test + fun `Blank event location`() { + val entity = Entity(contentValuesOf( + Events.EVENT_LOCATION to " " + )) + val result = Event() + processor.process(entity, entity, result) + assertNull(result.location) + } + + @Test + fun `Event location with two words`() { + val entity = Entity(contentValuesOf( + Events.EVENT_LOCATION to "Two words " + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals("Two words", result.location) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessorTest.kt new file mode 100644 index 00000000..6885914e --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/TitleProcessorTest.kt @@ -0,0 +1,53 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TitleProcessorTest { + + private val processor = TitleProcessor() + + @Test + fun `No title`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.summary) + } + + @Test + fun `Blank title`() { + val entity = Entity(contentValuesOf( + Events.TITLE to " " + )) + val result = Event() + processor.process(entity, entity, result) + assertNull(result.summary) + } + + @Test + fun `Title with two words`() { + val entity = Entity(contentValuesOf( + Events.TITLE to "Two words " + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals("Two words", result.summary) + } + +} \ No newline at end of file From 00a37a7bac811580c1b1ad0c09aa3eed4b9c86e6 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:07:55 +0200 Subject: [PATCH 13/19] Add StatusProcessor --- .../LegacyAndroidEventProcessorTest.kt | 34 +--------- .../calendar/LegacyAndroidEventProcessor.kt | 13 ++-- .../calendar/processor/StatusProcessor.kt | 32 ++++++++++ .../calendar/processor/StatusProcessorTest.kt | 64 +++++++++++++++++++ 4 files changed, 101 insertions(+), 42 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index 69b35326..ce2fa1d4 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -38,7 +38,6 @@ import net.fortuna.ical4j.model.parameter.Language import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RecurrenceId -import net.fortuna.ical4j.model.property.Status import net.fortuna.ical4j.model.property.XProperty import org.junit.After import org.junit.Assert.assertEquals @@ -361,38 +360,7 @@ class LegacyAndroidEventProcessorTest { assertEquals(Css3Color.silver, result.color) } } - - @Test - fun testPopulateEvent_Status_Confirmed() { - populateEvent(true) { - put(Events.STATUS, Events.STATUS_CONFIRMED) - }.let { result -> - assertEquals(Status.VEVENT_CONFIRMED, result.status) - } - } - - @Test - fun testPopulateEvent_Status_Tentative() { - populateEvent(true) { - put(Events.STATUS, Events.STATUS_TENTATIVE) - }.let { result -> - assertEquals(Status.VEVENT_TENTATIVE, result.status) - } - } - - @Test - fun testPopulateEvent_Status_Cancelled() { - populateEvent(true) { - put(Events.STATUS, Events.STATUS_CANCELED) - }.let { result -> - assertEquals(Status.VEVENT_CANCELLED, result.status) - } - } - - @Test - fun testPopulateEvent_Status_None() { - assertNull(populateEvent(true).status) - } + @Test fun testPopulateEvent_Availability_Busy() { 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 index 5b48ca32..29eef4ca 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -24,6 +24,7 @@ import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.DescriptionProcessor import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor +import at.bitfire.synctools.mapping.calendar.processor.StatusProcessor import at.bitfire.synctools.mapping.calendar.processor.TitleProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.mapping.calendar.processor.UnknownPropertiesProcessor @@ -78,13 +79,14 @@ class LegacyAndroidEventProcessor( UidProcessor(), TitleProcessor(), LocationProcessor(), - AccessLevelProcessor(), DescriptionProcessor(), + AccessLevelProcessor(), + StatusProcessor(), // extended properties CategoriesProcessor(), UnknownPropertiesProcessor(), UrlProcessor(), - // data rows (sub-values) + // data rows (sub-values) AttendeesProcessor(), RemindersProcessor(accountName) ) @@ -260,13 +262,6 @@ class LegacyAndroidEventProcessor( 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 diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessor.kt new file mode 100644 index 00000000..e5b3d647 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessor.kt @@ -0,0 +1,32 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.Status + +class StatusProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + to.status = when (from.entityValues.getAsInteger(Events.STATUS)) { + Events.STATUS_CONFIRMED -> + Status.VEVENT_CONFIRMED + + Events.STATUS_TENTATIVE -> + Status.VEVENT_TENTATIVE + + Events.STATUS_CANCELED -> + Status.VEVENT_CANCELLED + + else -> + null + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessorTest.kt new file mode 100644 index 00000000..48802dcc --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/StatusProcessorTest.kt @@ -0,0 +1,64 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.Status +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StatusProcessorTest { + + private val processor = StatusProcessor() + + @Test + fun `No status`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.status) + } + + @Test + fun `Status CONFIRMED`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.STATUS to Events.STATUS_CONFIRMED + )) + processor.process(entity, entity, result) + assertEquals(Status.VEVENT_CONFIRMED, result.status) + } + + @Test + fun `Status TENTATIVE`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.STATUS to Events.STATUS_TENTATIVE + )) + processor.process(entity, entity, result) + assertEquals(Status.VEVENT_TENTATIVE, result.status) + } + + @Test + fun `Status CANCELLED`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.STATUS to Events.STATUS_CANCELED + )) + processor.process(entity, entity, result) + assertEquals(Status.VEVENT_CANCELLED, result.status) + } + +} \ No newline at end of file From 2fa998ab87a5db64e8ad32c72cf78f3168e9ec38 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:11:51 +0200 Subject: [PATCH 14/19] Add AvailabilityProcessor --- .../LegacyAndroidEventProcessorTest.kt | 27 -------- .../calendar/LegacyAndroidEventProcessor.kt | 12 ++-- .../processor/AvailabilityProcessor.kt | 19 ++++++ .../processor/AvailabilityProcessorTest.kt | 63 +++++++++++++++++++ 4 files changed, 87 insertions(+), 34 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index ce2fa1d4..d11b6b4f 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -360,34 +360,7 @@ class LegacyAndroidEventProcessorTest { assertEquals(Css3Color.silver, result.color) } } - - @Test - fun testPopulateEvent_Availability_Busy() { - populateEvent(true) { - put(Events.AVAILABILITY, Events.AVAILABILITY_BUSY) - }.let { result -> - assertTrue(result.opaque) - } - } - - @Test - fun testPopulateEvent_Availability_Tentative() { - populateEvent(true) { - put(Events.AVAILABILITY, Events.AVAILABILITY_TENTATIVE) - }.let { result -> - assertTrue(result.opaque) - } - } - - @Test - fun testPopulateEvent_Availability_Free() { - populateEvent(true) { - put(Events.AVAILABILITY, Events.AVAILABILITY_FREE) - }.let { result -> - assertFalse(result.opaque) - } - } @Test fun testPopulateEvent_Organizer_NotGroupScheduled() { 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 index 29eef4ca..2a20d5bf 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -20,6 +20,7 @@ import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.mapping.calendar.processor.AccessLevelProcessor import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor +import at.bitfire.synctools.mapping.calendar.processor.AvailabilityProcessor import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.DescriptionProcessor import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor @@ -81,6 +82,7 @@ class LegacyAndroidEventProcessor( LocationProcessor(), DescriptionProcessor(), AccessLevelProcessor(), + AvailabilityProcessor(), StatusProcessor(), // extended properties CategoriesProcessor(), @@ -257,13 +259,9 @@ class LegacyAndroidEventProcessor( 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 } - } - - // availability - to.opaque = row.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE + } ?: row.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value + Css3Color.entries.firstOrNull { it.argb == color } + } // scheduling if (groupScheduled) { diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessor.kt new file mode 100644 index 00000000..0442d52f --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessor.kt @@ -0,0 +1,19 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event + +class AvailabilityProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + to.opaque = from.entityValues.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessorTest.kt new file mode 100644 index 00000000..7b7e6f24 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/AvailabilityProcessorTest.kt @@ -0,0 +1,63 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AvailabilityProcessorTest { + + private val processor = AvailabilityProcessor() + + @Test + fun `No availability`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertTrue(result.opaque) + } + + @Test + fun `Availability BUSY`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.AVAILABILITY to Events.AVAILABILITY_BUSY + )) + processor.process(entity, entity, result) + assertTrue(result.opaque) + } + + @Test + fun `Availability FREE`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.AVAILABILITY to Events.AVAILABILITY_FREE + )) + processor.process(entity, entity, result) + assertFalse(result.opaque) + } + + @Test + fun `Availability TENTATIVE`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.AVAILABILITY to Events.AVAILABILITY_TENTATIVE + )) + processor.process(entity, entity, result) + assertTrue(result.opaque) + } + +} \ No newline at end of file From 4e2c3f282ec267b0111116141edba2c6f1303e2d Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:16:12 +0200 Subject: [PATCH 15/19] Add ColorProcessor --- .../LegacyAndroidEventProcessorTest.kt | 23 -------- .../calendar/LegacyAndroidEventProcessor.kt | 16 +----- .../calendar/processor/ColorProcessor.kt | 37 +++++++++++++ .../calendar/processor/ColorProcessorTest.kt | 54 +++++++++++++++++++ 4 files changed, 93 insertions(+), 37 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index d11b6b4f..86f55374 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -24,9 +24,7 @@ import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.util.AndroidTimeUtils 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.storage.calendar.AndroidEvent2 import at.bitfire.synctools.test.InitCalendarProviderRule import net.fortuna.ical4j.model.Date @@ -341,27 +339,6 @@ class LegacyAndroidEventProcessorTest { } } - @Test - fun testPopulateEvent_Color_FromIndex() { - val provider = AndroidCalendarProvider(testAccount, client) - provider.provideCss3ColorIndices() - populateEvent(true) { - put(Events.EVENT_COLOR_KEY, Css3Color.silver.name) - }.let { result -> - assertEquals(Css3Color.silver, result.color) - } - } - - @Test - fun testPopulateEvent_Color_FromValue() { - populateEvent(true) { - put(Events.EVENT_COLOR, Css3Color.silver.argb) - }.let { result -> - assertEquals(Css3Color.silver, result.color) - } - } - - @Test fun testPopulateEvent_Organizer_NotGroupScheduled() { assertNull(populateEvent(true).organizer) 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 index 2a20d5bf..5e4abe46 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -16,12 +16,12 @@ 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.mapping.calendar.processor.AccessLevelProcessor import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor import at.bitfire.synctools.mapping.calendar.processor.AttendeesProcessor import at.bitfire.synctools.mapping.calendar.processor.AvailabilityProcessor import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor +import at.bitfire.synctools.mapping.calendar.processor.ColorProcessor import at.bitfire.synctools.mapping.calendar.processor.DescriptionProcessor import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor @@ -81,6 +81,7 @@ class LegacyAndroidEventProcessor( TitleProcessor(), LocationProcessor(), DescriptionProcessor(), + ColorProcessor(), AccessLevelProcessor(), AvailabilityProcessor(), StatusProcessor(), @@ -250,19 +251,6 @@ class LegacyAndroidEventProcessor( to.sequence = row.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) to.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) - // 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 } - } - // scheduling if (groupScheduled) { // ORGANIZER must only be set for group-scheduled events (= events with attendees) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessor.kt new file mode 100644 index 00000000..49d742db --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessor.kt @@ -0,0 +1,37 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import at.bitfire.synctools.icalendar.Css3Color +import java.util.logging.Logger + +class ColorProcessor: AndroidEventFieldProcessor { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: Event) { + val values = from.entityValues + + // color can be specified as RGB value and/or as index key (CSS3 color of AndroidCalendar) + to.color = + values.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 + } + } ?: values.getAsInteger(Events.EVENT_COLOR)?.let { color -> // otherwise, try to find the color name from the value + Css3Color.entries.firstOrNull { it.argb == color } + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessorTest.kt new file mode 100644 index 00000000..69da8e56 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/ColorProcessorTest.kt @@ -0,0 +1,54 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.synctools.icalendar.Css3Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ColorProcessorTest { + + private val processor = ColorProcessor() + + @Test + fun `No color`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.color) + } + + @Test + fun `Color from index`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.EVENT_COLOR_KEY to Css3Color.silver.name + )) + processor.process(entity, entity, result) + assertEquals(Css3Color.silver, result.color) + } + + @Test + fun `Color from value`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.EVENT_COLOR to Css3Color.silver.argb + )) + processor.process(entity, entity, result) + assertEquals(Css3Color.silver, result.color) + } + +} \ No newline at end of file From 2f5935f02d96272804987312c8ecb999d590eec5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:31:00 +0200 Subject: [PATCH 16/19] Add OrganizerProcessor --- .../LegacyAndroidEventProcessorTest.kt | 58 ------------- .../calendar/LegacyAndroidEventProcessor.kt | 25 ++---- .../calendar/processor/OrganizerProcessor.kt | 41 ++++++++++ .../processor/OrganizerProcessorTest.kt | 82 +++++++++++++++++++ 4 files changed, 128 insertions(+), 78 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index 86f55374..4f07d50f 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -12,7 +12,6 @@ import android.content.ContentUris import android.content.ContentValues import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.AUTHORITY -import android.provider.CalendarContract.Attendees import android.provider.CalendarContract.Events import android.provider.CalendarContract.ExtendedProperties import androidx.core.content.contentValuesOf @@ -39,7 +38,6 @@ import net.fortuna.ical4j.model.property.RecurrenceId import net.fortuna.ical4j.model.property.XProperty import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -161,33 +159,6 @@ class LegacyAndroidEventProcessorTest { } } - @Test - fun testPopulateEvent_IsOrganizer_False() { - populateEvent(true, asSyncAdapter = true) { - put(Events.IS_ORGANIZER, "0") - }.let { result -> - assertFalse(result.isOrganizer!!) - } - } - - @Test - fun testPopulateEvent_IsOrganizer_Null() { - populateEvent(true, asSyncAdapter = true) { - putNull(Events.IS_ORGANIZER) - }.let { result -> - assertNull(result.isOrganizer) - } - } - - @Test - fun testPopulateEvent_IsOrganizer_True() { - populateEvent(true, asSyncAdapter = true) { - put(Events.IS_ORGANIZER, "1") - }.let { result -> - assertTrue(result.isOrganizer!!) - } - } - @Test fun testPopulateEvent_NonAllDay_NonRecurring() { populateEvent(false) { @@ -339,35 +310,6 @@ class LegacyAndroidEventProcessorTest { } } - @Test - fun testPopulateEvent_Organizer_NotGroupScheduled() { - assertNull(populateEvent(true).organizer) - } - - @Test - fun testPopulateEvent_Organizer_NotGroupScheduled_ExplicitOrganizer() { - populateEvent(true) { - put(Events.ORGANIZER, "sample@example.com") - }.let { result -> - assertNull(result.organizer) - } - } - - @Test - fun testPopulateEvent_Organizer_GroupScheduled() { - populateEvent(true, insertCallback = { id -> - client.insert(Attendees.CONTENT_URI.asSyncAdapter(testAccount), ContentValues().apply { - put(Attendees.EVENT_ID, id) - put(Attendees.ATTENDEE_EMAIL, "organizer@example.com") - put(Attendees.ATTENDEE_TYPE, Attendees.RELATIONSHIP_ORGANIZER) - }) - }) { - put(Events.ORGANIZER, "organizer@example.com") - }.let { result -> - assertEquals("mailto:organizer@example.com", result.organizer?.value) - } - } - @Test fun testPopulateUnknownProperty() { 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 index 5e4abe46..4ee897e0 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -24,6 +24,7 @@ import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.ColorProcessor import at.bitfire.synctools.mapping.calendar.processor.DescriptionProcessor import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor +import at.bitfire.synctools.mapping.calendar.processor.OrganizerProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor import at.bitfire.synctools.mapping.calendar.processor.StatusProcessor import at.bitfire.synctools.mapping.calendar.processor.TitleProcessor @@ -41,14 +42,11 @@ 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.util.TimeZones -import java.net.URI -import java.net.URISyntaxException import java.time.Duration import java.time.Instant import java.time.Period @@ -85,12 +83,14 @@ class LegacyAndroidEventProcessor( AccessLevelProcessor(), AvailabilityProcessor(), StatusProcessor(), + // scheduling + OrganizerProcessor(), + AttendeesProcessor(), // extended properties CategoriesProcessor(), UnknownPropertiesProcessor(), UrlProcessor(), - // data rows (sub-values) - AttendeesProcessor(), + // sub-components RemindersProcessor(accountName) ) @@ -249,18 +249,6 @@ class LegacyAndroidEventProcessor( } to.sequence = row.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) - to.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) - - // 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) - } - } // exceptions from recurring events row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> @@ -310,9 +298,6 @@ class LegacyAndroidEventProcessor( } } 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 } diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessor.kt new file mode 100644 index 00000000..cf72c57f --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessor.kt @@ -0,0 +1,41 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.Organizer +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Level +import java.util.logging.Logger + +class OrganizerProcessor: AndroidEventFieldProcessor { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: Event) { + // In case of an exception, we're taking ORGANIZER information from the main event and not the exception. See also RFC 6638 3.1. + val values = main.entityValues + + // IS_ORGANIZER helper in Event class (deprecated) + to.isOrganizer = values.getAsBoolean(Events.IS_ORGANIZER) + + // ORGANIZER must only be set for group-scheduled events (= events with attendees) + val hasAttendees = from.subValues.any { it.uri == Attendees.CONTENT_URI } + if (hasAttendees && values.containsKey(Events.ORGANIZER)) + try { + to.organizer = Organizer(URI("mailto", values.getAsString(Events.ORGANIZER), null)) + } catch (e: URISyntaxException) { + logger.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e) + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessorTest.kt new file mode 100644 index 00000000..9f1ed1a7 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/OrganizerProcessorTest.kt @@ -0,0 +1,82 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.Organizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OrganizerProcessorTest { + + private val processor = OrganizerProcessor() + + @Test + fun `isOrganizer not set`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.isOrganizer) + } + + @Test + fun `isOrganizer is 0`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.IS_ORGANIZER to 0, + )) + processor.process(entity, entity, result) + assertFalse(result.isOrganizer!!) + } + + @Test + fun `isOrganizer is 1`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.IS_ORGANIZER to 1, + )) + processor.process(entity, entity, result) + assertTrue(result.isOrganizer!!) + } + + + @Test + fun `No ORGANIZER for non-group-scheduled event`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ORGANIZER to "organizer@example.com" + )) + processor.process(entity, entity, result) + assertNull(result.organizer) + } + + @Test + fun `ORGANIZER for group-scheduled event`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.ORGANIZER to "organizer@example.com" + )) + entity.addSubValue(Attendees.CONTENT_URI, contentValuesOf( + Attendees.ATTENDEE_EMAIL to "organizer@example.com", + Attendees.ATTENDEE_TYPE to Attendees.RELATIONSHIP_ORGANIZER + )) + processor.process(entity, entity, result) + assertEquals(Organizer("mailto:organizer@example.com"), result.organizer) + } + +} \ No newline at end of file From 60a1d869a08546eca8092ea17f0fd71d42a89866 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:35:33 +0200 Subject: [PATCH 17/19] Add MutatorsProcessor --- .../calendar/LegacyAndroidEventProcessor.kt | 7 +-- .../calendar/processor/MutatorsProcessor.kt | 23 ++++++++++ .../processor/MutatorsProcessorTest.kt | 43 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessorTest.kt 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 index 4ee897e0..76b36d26 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -24,6 +24,7 @@ import at.bitfire.synctools.mapping.calendar.processor.CategoriesProcessor import at.bitfire.synctools.mapping.calendar.processor.ColorProcessor import at.bitfire.synctools.mapping.calendar.processor.DescriptionProcessor import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor +import at.bitfire.synctools.mapping.calendar.processor.MutatorsProcessor import at.bitfire.synctools.mapping.calendar.processor.OrganizerProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor import at.bitfire.synctools.mapping.calendar.processor.StatusProcessor @@ -75,6 +76,7 @@ class LegacyAndroidEventProcessor( private val fieldProcessors: Array = arrayOf( // event row fields + MutatorsProcessor(), // for PRODID UidProcessor(), TitleProcessor(), LocationProcessor(), @@ -130,11 +132,6 @@ class LegacyAndroidEventProcessor( private fun populateEventRow(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(AndroidEvent2.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") diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessor.kt new file mode 100644 index 00000000..838b77c9 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessor.kt @@ -0,0 +1,23 @@ +/* + * 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.processor + +import android.content.Entity +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 + +class MutatorsProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + from.entityValues.getAsString(Events.MUTATORS)?.let { strPackages -> + val packages = strPackages.split(AndroidEvent2.MUTATORS_SEPARATOR).toSet() + to.userAgents.addAll(packages) + } + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessorTest.kt new file mode 100644 index 00000000..4ed02475 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/MutatorsProcessorTest.kt @@ -0,0 +1,43 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MutatorsProcessorTest { + + private val processor = MutatorsProcessor() + + @Test + fun `No mutators`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertTrue(result.userAgents.isEmpty()) + } + + @Test + fun `Multiple mutators`() { + val result = Event() + val entity = Entity(contentValuesOf( + Events.MUTATORS to "com.example.calendar,com.example.another.calendar" + )) + processor.process(entity, entity, result) + assertEquals(listOf("com.example.calendar", "com.example.another.calendar"), result.userAgents) + } + +} \ No newline at end of file From 0ab0d3c1f1b26233ba38e28b207f0f6f8fd6eb1e Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:40:17 +0200 Subject: [PATCH 18/19] Add SequenceProcessor --- .../LegacyAndroidEventProcessorTest.kt | 18 ------- .../calendar/LegacyAndroidEventProcessor.kt | 5 +- .../calendar/processor/SequenceProcessor.kt | 19 +++++++ .../processor/SequenceProcessorTest.kt | 53 +++++++++++++++++++ 4 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessor.kt create mode 100644 lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessorTest.kt diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt index 4f07d50f..9989f263 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessorTest.kt @@ -141,24 +141,6 @@ class LegacyAndroidEventProcessorTest { } - @Test - fun testPopulateEvent_Sequence_Int() { - populateEvent(true, asSyncAdapter = true) { - put(AndroidEvent2.COLUMN_SEQUENCE, 5) - }.let { result -> - assertEquals(5, result.sequence) - } - } - - @Test - fun testPopulateEvent_Sequence_Null() { - populateEvent(true, asSyncAdapter = true) { - putNull(AndroidEvent2.COLUMN_SEQUENCE) - }.let { result -> - assertNull(result.sequence) - } - } - @Test fun testPopulateEvent_NonAllDay_NonRecurring() { populateEvent(false) { 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 index 76b36d26..180f8029 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/LegacyAndroidEventProcessor.kt @@ -27,12 +27,12 @@ import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor import at.bitfire.synctools.mapping.calendar.processor.MutatorsProcessor import at.bitfire.synctools.mapping.calendar.processor.OrganizerProcessor import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor +import at.bitfire.synctools.mapping.calendar.processor.SequenceProcessor import at.bitfire.synctools.mapping.calendar.processor.StatusProcessor import at.bitfire.synctools.mapping.calendar.processor.TitleProcessor import at.bitfire.synctools.mapping.calendar.processor.UidProcessor import at.bitfire.synctools.mapping.calendar.processor.UnknownPropertiesProcessor import at.bitfire.synctools.mapping.calendar.processor.UrlProcessor -import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.EventAndExceptions import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList @@ -86,6 +86,7 @@ class LegacyAndroidEventProcessor( AvailabilityProcessor(), StatusProcessor(), // scheduling + SequenceProcessor(), OrganizerProcessor(), AttendeesProcessor(), // extended properties @@ -245,8 +246,6 @@ class LegacyAndroidEventProcessor( logger.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e) } - to.sequence = row.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) - // exceptions from recurring events row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime -> val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0 diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessor.kt new file mode 100644 index 00000000..7c721806 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessor.kt @@ -0,0 +1,19 @@ +/* + * 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.processor + +import android.content.Entity +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 + +class SequenceProcessor: AndroidEventFieldProcessor { + + override fun process(from: Entity, main: Entity, to: Event) { + to.sequence = from.entityValues.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessorTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessorTest.kt new file mode 100644 index 00000000..3649c685 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/processor/SequenceProcessorTest.kt @@ -0,0 +1,53 @@ +/* + * 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.processor + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Event +import at.bitfire.synctools.storage.calendar.AndroidEvent2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SequenceProcessorTest { + + private val processor = SequenceProcessor() + + @Test + fun `No sequence`() { + val result = Event() + val entity = Entity(ContentValues()) + processor.process(entity, entity, result) + assertNull(result.sequence) + } + + @Test + fun `Sequence is 0`() { + val entity = Entity(contentValuesOf( + AndroidEvent2.COLUMN_SEQUENCE to 0 + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals(0, result.sequence) + } + + @Test + fun `Sequence is 1`() { + val entity = Entity(contentValuesOf( + AndroidEvent2.COLUMN_SEQUENCE to 1 + )) + val result = Event() + processor.process(entity, entity, result) + assertEquals(1, result.sequence) + } + +} \ No newline at end of file From 258d0c57d09121f8d8ffbdcc117d12a1db4c1fbd Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 22 Sep 2025 13:45:17 +0200 Subject: [PATCH 19/19] Make logger private --- .../mapping/calendar/processor/UnknownPropertiesProcessor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt index 21480d3b..ea06f50f 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/processor/UnknownPropertiesProcessor.kt @@ -17,7 +17,7 @@ import java.util.logging.Logger class UnknownPropertiesProcessor: AndroidEventFieldProcessor { - val logger: Logger + private val logger: Logger get() = Logger.getLogger(javaClass.name) override fun process(from: Entity, main: Entity, to: Event) {