Skip to content

Commit e48bdbd

Browse files
authored
Allow synchronization without Event data class (#128)
* UID handling: always process main event - Modify `UidProcessor` to always use the UID from the main event and refactor the UID extraction logic into a companion object. - Adjust `UidBuilder` to consistently use the UID from the main event. * Update AndroidEventProcessor to ensure events have UIDs - Add UidGenerator interface for generating new UIDs * AndroidEventProcessor: return `MappingResult` with UID and generation status * AndroidEventProcessor: also update SEQUENCE * Update SEQUENCE and UID handling; tests * Add comments * Improve organizer handling in calendar sync - Update OrganizerBuilder to handle ownerAccount more robustly - Extract emailFromOrganizer function for better clarity - Add logging for ignored organizers without email addresses * KDoc * KDoc * Add `findEventAndExceptions` method to retrieve events and exceptions * Add test
1 parent bf6a070 commit e48bdbd

File tree

17 files changed

+475
-142
lines changed

17 files changed

+475
-142
lines changed

lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,26 @@ class AndroidCalendarTest {
113113
assertEntitiesEqual(entity, result, onlyFieldsInExpected = true)
114114
}
115115

116+
@Test
117+
fun testFindEvent() {
118+
// no result
119+
assertNull(calendar.findEvent("${Events.DTSTART}=?", arrayOf(now.toString())))
120+
121+
// insert event
122+
val entity = Entity(contentValuesOf(
123+
Events.CALENDAR_ID to calendar.id,
124+
Events.DTSTART to now,
125+
Events.DTEND to now + 3600000,
126+
Events.TITLE to "Some Event"
127+
))
128+
calendar.addEvent(entity)
129+
130+
// not it finds a result
131+
val result = calendar.findEvents("${Events.DTSTART}=?", arrayOf(now.toString()))
132+
assertEquals(1, result.size)
133+
assertEntitiesEqual(entity, result.first(), onlyFieldsInExpected = true)
134+
}
135+
116136
@Test
117137
fun testFindEvents() {
118138
calendar.addEvent(Entity(contentValuesOf(

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventBuilder.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class AndroidEventBuilder(
7979
AvailabilityBuilder(),
8080
RecurrenceFieldsBuilder(),
8181
OriginalInstanceTimeBuilder(),
82-
OrganizerBuilder(calendar.ownerAccount),
82+
OrganizerBuilder(calendar.ownerAccount ?: calendar.account.name),
8383
UidBuilder(),
8484
// sub-rows (alphabetically, by class name)
8585
AttendeesBuilder(calendar),

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/AndroidEventProcessor.kt

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
package at.bitfire.synctools.mapping.calendar
88

99
import android.content.Entity
10+
import android.provider.CalendarContract
1011
import android.provider.CalendarContract.Events
12+
import android.provider.CalendarContract.ExtendedProperties
13+
import androidx.annotation.VisibleForTesting
1114
import at.bitfire.synctools.icalendar.AssociatedEvents
1215
import at.bitfire.synctools.mapping.calendar.processor.AccessLevelProcessor
1316
import at.bitfire.synctools.mapping.calendar.processor.AndroidEventFieldProcessor
@@ -21,7 +24,6 @@ import at.bitfire.synctools.mapping.calendar.processor.EndTimeProcessor
2124
import at.bitfire.synctools.mapping.calendar.processor.LocationProcessor
2225
import at.bitfire.synctools.mapping.calendar.processor.OrganizerProcessor
2326
import at.bitfire.synctools.mapping.calendar.processor.OriginalInstanceTimeProcessor
24-
import at.bitfire.synctools.mapping.calendar.processor.ProdIdGenerator
2527
import at.bitfire.synctools.mapping.calendar.processor.RecurrenceFieldsProcessor
2628
import at.bitfire.synctools.mapping.calendar.processor.RemindersProcessor
2729
import at.bitfire.synctools.mapping.calendar.processor.SequenceProcessor
@@ -32,6 +34,7 @@ import at.bitfire.synctools.mapping.calendar.processor.UidProcessor
3234
import at.bitfire.synctools.mapping.calendar.processor.UnknownPropertiesProcessor
3335
import at.bitfire.synctools.mapping.calendar.processor.UrlProcessor
3436
import at.bitfire.synctools.storage.calendar.EventAndExceptions
37+
import at.bitfire.synctools.storage.calendar.EventsContract
3538
import net.fortuna.ical4j.model.DateList
3639
import net.fortuna.ical4j.model.Property
3740
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
@@ -42,12 +45,13 @@ import net.fortuna.ical4j.model.property.RDate
4245
import net.fortuna.ical4j.model.property.RRule
4346
import net.fortuna.ical4j.model.property.RecurrenceId
4447
import java.util.LinkedList
48+
import java.util.UUID
4549

4650
/**
4751
* Mapper from Android event main + data rows to [VEvent].
4852
*
49-
* @param accountName account name (used to generate self-attendee)
50-
* @param prodIdGenerator generator for `PRODID`
53+
* @param accountName account name (used to generate reminder email address)
54+
* @param prodIdGenerator generates the `PRODID` to use
5155
*/
5256
class AndroidEventProcessor(
5357
accountName: String,
@@ -84,21 +88,55 @@ class AndroidEventProcessor(
8488
)
8589

8690

87-
fun populate(eventAndExceptions: EventAndExceptions): AssociatedEvents {
88-
// main event
89-
val main = populateEvent(
91+
/**
92+
* Result of the [mapToVEvents] operation.
93+
*
94+
* @param associatedEvents mapped events
95+
* @param uid UID of the mapped events
96+
* @param generatedUid whether [uid] was generated by [mapToVEvents] (*false*: `UID` was already present before mapping)
97+
* @param updatedSequence the new increased `SEQUENCE` of the main event (*null* if sequence was not increased by [mapToVEvents])
98+
*/
99+
class MappingResult(
100+
val associatedEvents: AssociatedEvents,
101+
val uid: String,
102+
val generatedUid: Boolean,
103+
val updatedSequence: Int?
104+
)
105+
106+
/**
107+
* Maps an Android event with its exceptions to VEVENTs.
108+
*
109+
* VEVENTs must have a valid `UID`, so this method (or better to say, the [UidProcessor] that it calls)
110+
* generates an UID, if necessary. If an `UID` was generated, it is noted in the result.
111+
*
112+
* This method also increases the `SEQUENCE`, if necessary, so that the mapped `SEQUENCE`
113+
* can be different than the original `SEQUENCE` in [eventAndExceptions]. If the `SEQUENCE` was
114+
* increased, it is noted in the result.
115+
*/
116+
fun mapToVEvents(eventAndExceptions: EventAndExceptions): MappingResult {
117+
// make sure that main event has a UID
118+
var generatedUid = false
119+
val uid = provideUid(eventAndExceptions.main) {
120+
generatedUid = true
121+
UUID.randomUUID().toString()
122+
}
123+
124+
val updatedSequence = increaseSequence(eventAndExceptions.main)
125+
126+
// map main event
127+
val main = mapEvent(
90128
entity = eventAndExceptions.main,
91129
main = eventAndExceptions.main
92130
)
93131

94-
// Add exceptions of recurring main event
132+
// add exceptions of recurring main event
95133
val rRules = main.getProperties<RRule>(Property.RRULE)
96134
val rDates = main.getProperties<RDate>(Property.RDATE)
97135
val exceptions = LinkedList<VEvent>()
98136
if (rRules.isNotEmpty() || rDates.isNotEmpty()) {
99137
for (exception in eventAndExceptions.exceptions) {
100138
// convert exception to Event
101-
val exceptionEvent = populateEvent(
139+
val exceptionEvent = mapEvent(
102140
entity = exception,
103141
main = eventAndExceptions.main
104142
)
@@ -114,11 +152,17 @@ class AndroidEventProcessor(
114152
}
115153
}
116154

117-
return AssociatedEvents(
155+
val mappedEvents = AssociatedEvents(
118156
main = main,
119157
exceptions = exceptions,
120158
prodId = generateProdId(eventAndExceptions.main)
121159
)
160+
return MappingResult(
161+
associatedEvents = mappedEvents,
162+
uid = uid,
163+
generatedUid = generatedUid,
164+
updatedSequence = updatedSequence
165+
)
122166
}
123167

124168
private fun asExDate(entity: Entity, recurrenceId: RecurrenceId): ExDate {
@@ -145,6 +189,61 @@ class AndroidEventProcessor(
145189
return prodIdGenerator.generateProdId(packages)
146190
}
147191

192+
/**
193+
* Increases the event's SEQUENCE, if necessary.
194+
*
195+
* @param main event to be checked (**will be modified** when SEQUENCE needs to be increased)
196+
*
197+
* @return updated sequence (or *null* if sequence was not increased/modified)
198+
*/
199+
@VisibleForTesting
200+
internal fun increaseSequence(main: Entity): Int? {
201+
val mainValues = main.entityValues
202+
val currentSeq = mainValues.getAsInteger(EventsContract.COLUMN_SEQUENCE)
203+
204+
if (currentSeq == null) {
205+
/* First upload, request to set to 0 in calendar provider after upload.
206+
We can let it empty in the Entity because then no SEQUENCE property will be generated,
207+
which is equal to SEQUENCE:0. */
208+
return 0
209+
}
210+
211+
val groupScheduled = main.subValues.any { it.uri == CalendarContract.Attendees.CONTENT_URI }
212+
if (groupScheduled) {
213+
/* Note: Events.IS_ORGANIZER is defined in CalendarDatabaseHelper.java as
214+
COALESCE(Events.IS_ORGANIZER, Events.ORGANIZER = Calendars.OWNER_ACCOUNT), so it's non-null when it's
215+
- either explicitly set for an event,
216+
- or the event's ORGANIZER is the same as the calendar's OWNER_ACCOUNT. */
217+
val weAreOrganizer = when (mainValues.getAsInteger(Events.IS_ORGANIZER)) {
218+
null, 0 -> false
219+
/* explicitly set to non-zero, or 1 by provider calculation */ else -> true
220+
}
221+
222+
return if (weAreOrganizer) {
223+
/* Upload of a group-scheduled event and we are the organizer, so we increase the SEQUENCE.
224+
We also have to store it into the Entity so that the new value will be mapped. */
225+
(currentSeq + 1).also { newSeq ->
226+
mainValues.put(EventsContract.COLUMN_SEQUENCE, newSeq)
227+
}
228+
} else
229+
/* Upload of a group-scheduled event and we are not the organizer, so we don't increase the SEQUENCE. */
230+
null
231+
232+
} else /* not group-scheduled */ {
233+
return if (currentSeq == 0) {
234+
/* The event was uploaded once and has SEQUENCE of 0 (which is mapped to an empty SEQUENCE property).
235+
We don't need to increase the SEQUENCE because the event is not group-scheduled. */
236+
null
237+
} else {
238+
/* Upload of a non-group-scheduled event where a SEQUENCE > 0 is present. Increase by one after upload.
239+
We also have to store it into the Entity so that the new value will be mapped. */
240+
(currentSeq + 1).also { newSeq ->
241+
mainValues.put(EventsContract.COLUMN_SEQUENCE, newSeq)
242+
}
243+
}
244+
}
245+
}
246+
148247
/**
149248
* Reads data of an event from the calendar provider, i.e. converts the [entity] values into a [VEvent].
150249
*
@@ -153,13 +252,51 @@ class AndroidEventProcessor(
153252
*
154253
* @return generated data object
155254
*/
156-
private fun populateEvent(entity: Entity, main: Entity): VEvent {
157-
val vEvent = VEvent()
255+
private fun mapEvent(entity: Entity, main: Entity): VEvent {
256+
// initialization adds DTSTAMP
257+
val vEvent = VEvent(/* initialise = */ true)
258+
158259
for (processor in fieldProcessors)
159260
processor.process(from = entity, main = main, to = vEvent)
160261
return vEvent
161262
}
162263

264+
/**
265+
* Makes sure that the event has a UID ([Events.UID_2445] in the main event row).
266+
*
267+
* If the event doesn't have a UID, a new one is generated using [generateUid] and
268+
* put into [main].
269+
*
270+
* @param main event to be checked (**will be modified** if it doesn't already have a UID)
271+
*/
272+
private fun provideUid(
273+
main: Entity,
274+
generateUid: () -> String
275+
): String {
276+
val mainValues = main.entityValues
277+
val existingUid = mainValues.getAsString(Events.UID_2445)
278+
if (existingUid != null) {
279+
// UID already present, nothing to do
280+
return existingUid
281+
}
282+
283+
// have a look at extended properties (Google Calendar)
284+
val googleCalendarUid = main.subValues.firstOrNull {
285+
it.uri == ExtendedProperties.CONTENT_URI &&
286+
it.values.getAsString(ExtendedProperties.NAME) == EventsContract.EXTNAME_GOOGLE_CALENDAR_UID
287+
}?.values?.getAsString(ExtendedProperties.VALUE)
288+
if (googleCalendarUid != null) {
289+
// copy to UID_2445 so that it will be processed by UidProcessor and return
290+
mainValues.put(Events.UID_2445, googleCalendarUid)
291+
return googleCalendarUid
292+
}
293+
294+
// still no UID, generate one
295+
val newUid = generateUid()
296+
mainValues.put(Events.UID_2445, newUid)
297+
return newUid
298+
}
299+
163300

164301
companion object {
165302

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.mapping.calendar
8+
9+
fun interface ProdIdGenerator {
10+
11+
/**
12+
* Generates a `PRODID` string using additional package names.
13+
*
14+
* @param packages package names that have modified/generated the iCalendar (like `com.example.app.calendar`; may be empty)
15+
*
16+
* @return the full `PRODID` string, with the package names and probably additional information added
17+
* (like `MyApp/1.0 (com.example.app.calendar)`)
18+
*/
19+
fun generateProdId(packages: List<String>): String
20+
21+
}
22+
23+
class DefaultProdIdGenerator(
24+
private val baseId: String
25+
): ProdIdGenerator {
26+
27+
override fun generateProdId(packages: List<String>): String {
28+
val builder = StringBuilder(baseId)
29+
if (packages.isNotEmpty())
30+
builder .append(" (")
31+
.append(packages.joinToString(", "))
32+
.append(")")
33+
return builder.toString()
34+
}
35+
36+
}

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/OrganizerBuilder.kt

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import net.fortuna.ical4j.model.Property
1313
import net.fortuna.ical4j.model.component.VEvent
1414
import net.fortuna.ical4j.model.parameter.Email
1515
import net.fortuna.ical4j.model.property.Attendee
16+
import net.fortuna.ical4j.model.property.Organizer
17+
import java.net.URI
18+
import java.util.logging.Level
1619
import java.util.logging.Logger
1720

1821
class OrganizerBuilder(
19-
private val ownerAccount: String?
22+
private val ownerAccount: String
2023
): AndroidEntityBuilder {
2124

2225
private val logger
@@ -27,24 +30,33 @@ class OrganizerBuilder(
2730
val groupScheduled = from.getProperties<Attendee>(Property.ATTENDEE).isNotEmpty()
2831
if (groupScheduled) {
2932
values.put(Events.HAS_ATTENDEE_DATA, 1)
30-
values.put(Events.ORGANIZER, from.organizer?.let { organizer ->
31-
val uri = organizer.calAddress
32-
val email = if (uri.scheme.equals("mailto", true))
33-
uri.schemeSpecificPart
34-
else
35-
organizer.getParameter<Email>(Parameter.EMAIL)?.value
3633

37-
if (email != null)
38-
return@let email
39-
40-
logger.warning("Ignoring ORGANIZER without email address (not supported by Android)")
41-
null
42-
} ?: ownerAccount)
34+
// We prefer the ORGANIZER from the main event and not the exception (it must be the same).
35+
// See RFC 6638 3.1 and 3.2.4.2.
36+
values.put(Events.ORGANIZER, emailFromOrganizer(main.organizer ?: from.organizer) ?: ownerAccount)
4337

4438
} else { /* !groupScheduled */
4539
values.put(Events.HAS_ATTENDEE_DATA, 0)
4640
values.put(Events.ORGANIZER, ownerAccount)
4741
}
4842
}
4943

44+
fun emailFromOrganizer(organizer: Organizer?): String? {
45+
if (organizer == null)
46+
return null
47+
48+
// Take from mailto: value or EMAIL parameter
49+
val uri: URI? = organizer.calAddress
50+
val email = if (uri?.scheme.equals("mailto", true))
51+
uri?.schemeSpecificPart
52+
else
53+
organizer.getParameter<Email>(Parameter.EMAIL)?.value
54+
55+
if (email != null)
56+
return email
57+
58+
logger.log(Level.WARNING, "Ignoring ORGANIZER without email address (not supported by Android)", organizer)
59+
return null
60+
}
61+
5062
}

lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/UidBuilder.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import net.fortuna.ical4j.model.component.VEvent
1313
class UidBuilder: AndroidEntityBuilder {
1414

1515
override fun build(from: VEvent, main: VEvent, to: Entity) {
16-
to.entityValues.put(Events.UID_2445, from.uid?.value)
16+
// Always take UID from main event because exceptions must have the same UID anyway.
17+
// Note: RFC 5545 requires UID for VEVENTs, however the obsoleted RFC 2445 does not.
18+
to.entityValues.put(Events.UID_2445, main.uid?.value)
1719
}
1820

1921
}

0 commit comments

Comments
 (0)