Welcome to mirror list, hosted at ThFree Co, Russian Federation.

AndroidEvent.kt « ical4android « bitfire « at « java « main « src - github.com/bitfireAT/ical4android.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 54531a4c03789a084fcdf3f84520f9e6091cef39 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
/*
 * Copyright © Ricki Hirner (bitfire web engineering).
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 */

package at.bitfire.ical4android

import android.content.ContentProviderOperation
import android.content.ContentProviderOperation.Builder
import android.content.ContentUris
import android.content.ContentValues
import android.content.EntityIterator
import android.net.Uri
import android.os.RemoteException
import android.provider.CalendarContract.*
import android.util.Base64
import android.util.Patterns
import at.bitfire.ical4android.MiscUtils.CursorHelper.toValues
import net.fortuna.ical4j.model.*
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.TimeZone
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.parameter.*
import net.fortuna.ical4j.model.property.*
import net.fortuna.ical4j.util.TimeZones
import java.io.ByteArrayInputStream
import java.io.FileNotFoundException
import java.io.ObjectInputStream
import java.net.URI
import java.net.URISyntaxException
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Level

/**
 * Stores and retrieves VEVENT iCalendar objects (represented as [Event]s) to/from the
 * Android Calendar provider.
 *
 * Extend this class to process specific fields of the event.
 *
 * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID
 * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient.
 */
abstract class AndroidEvent(
        val calendar: AndroidCalendar<AndroidEvent>
) {

    companion object {

        @Deprecated("New serialization format", ReplaceWith("EXT_UNKNOWN_PROPERTY2"))
        const val EXT_UNKNOWN_PROPERTY = "unknown-property"

        @Deprecated("New content item MIME type", ReplaceWith("UnknownProperty.CONTENT_ITEM_TYPE"))
        const val EXT_UNKNOWN_PROPERTY2 = "unknown-property.v2"

        /**
         * VEVENT CATEGORIES will be stored as an extended property with this [ExtendedProperties.NAME].
         *
         * The [ExtendedProperties.VALUE] format is the same as used by the AOSP Exchange ActiveSync adapter:
         * the category values are stored as list, separated by [EXT_CATEGORIES_SEPARATOR]. (If a category
         * value contains [EXT_CATEGORIES_SEPARATOR], [EXT_CATEGORIES_SEPARATOR] will be dropped.)
         *
         * Example: `Cat1\Cat2`
         */
        const val EXT_CATEGORIES = "categories"
        const val EXT_CATEGORIES_SEPARATOR = '\\'

        /**
         * EMAIL parameter name (as used for ORGANIZER). Not declared in ical4j Parameters class yet.
         */
        private const val PARAMETER_EMAIL = "EMAIL"

    }

    var id: Long? = null
        protected set

    /**
     * Creates a new object from an event which already exists in the calendar storage.
     * @param values database row with all columns, as returned by the calendar provider
     */
    constructor(calendar: AndroidCalendar<AndroidEvent>, values: ContentValues): this(calendar) {
        this.id = values.getAsLong(Events._ID)
        // derived classes process SYNC1 etc.
    }

    /**
     * Creates a new object from an event which doesn't exist in the calendar storage yet.
     * @param event event that can be saved into the calendar storage
     */
    constructor(calendar: AndroidCalendar<AndroidEvent>, event: Event): this(calendar) {
        this.event = event
    }

    var event: Event? = null
        /**
         * This getter returns the full event data, either from [event] or, if [event] is null, by reading event
         * number [id] from the Android calendar storage
         * @throws IllegalArgumentException if event has not been saved yet
         * @throws FileNotFoundException if there's no event with [id] in the calendar storage
         * @throws RemoteException on calendar provider errors
         */
        get() {
            if (field != null)
                return field
            val id = requireNotNull(id)

            var iterEvents: EntityIterator? = null
            try {
                iterEvents = EventsEntity.newEntityIterator(
                        calendar.provider.query(
                                calendar.syncAdapterURI(ContentUris.withAppendedId(EventsEntity.CONTENT_URI, id)),
                                null, null, null, null),
                        calendar.provider
                )
                if (iterEvents.hasNext()) {
                    val event = Event()
                    field = event

                    val e = iterEvents.next()
                    populateEvent(MiscUtils.removeEmptyStrings(e.entityValues))

                    for (subValue in e.subValues) {
                        val subValues = MiscUtils.removeEmptyStrings(subValue.values)
                        when (subValue.uri) {
                            Attendees.CONTENT_URI -> populateAttendee(subValues)
                            Reminders.CONTENT_URI -> populateReminder(subValues)
                            ExtendedProperties.CONTENT_URI -> populateExtended(subValues)
                        }
                    }
                    populateExceptions()

                    useRetainedClassification()

                    /* remove ORGANIZER from all components if there are no attendees
                       (i.e. this is not a group-scheduled calendar entity) */
                    if (event.attendees.isEmpty()) {
                        event.organizer = null
                        event.exceptions.forEach { it.organizer = null }
                    }

                    return field
                }
            } finally {
                iterEvents?.close()
            }
            throw FileNotFoundException("Couldn't find event $id")
        }

    /**
     * Reads event data from the calendar provider.
     * @param row values of an [Events] row, as returned by the calendar provider
     */
    protected open fun populateEvent(row: ContentValues) {
        Constants.log.log(Level.FINE, "Read event entity from calender provider", row)
        val event = requireNotNull(event)

        event.summary = row.getAsString(Events.TITLE)
        event.location = row.getAsString(Events.EVENT_LOCATION)
        event.description = row.getAsString(Events.DESCRIPTION)

        row.getAsString(Events.EVENT_COLOR_KEY)?.let { name ->
            try {
                event.color = Css3Color.valueOf(name)
            } catch(e: IllegalArgumentException) {
                Constants.log.warning("Ignoring unknown color $name from Calendar Provider")
            }
        }

        val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0
        val tsStart = row.getAsLong(Events.DTSTART)
        val tsEnd = row.getAsLong(Events.DTEND)
        val duration = row.getAsString(Events.DURATION)

        if (allDay) {
            // use DATE values
            event.dtStart = DtStart(Date(tsStart))
            when {
                tsEnd != null -> event.dtEnd = DtEnd(Date(tsEnd))
                duration != null -> event.duration = Duration(Dur(duration))
            }
        } else {
            // use DATE-TIME values
            var tz: TimeZone? = null
            row.getAsString(Events.EVENT_TIMEZONE)?.let { tzId ->
                tz = DateUtils.tzRegistry.getTimeZone(tzId)
            }

            val start = DateTime(tsStart)
            tz?.let { start.timeZone = it }
            event.dtStart = DtStart(start)

            when {
                tsEnd != null -> {
                    val end = DateTime(tsEnd)
                    tz?.let { end.timeZone = it }
                    event.dtEnd = DtEnd(end)
                }
                duration != null -> event.duration = Duration(Dur(duration))
            }
        }

        // recurrence
        try {
            row.getAsString(Events.RRULE)?.let { event.rRule = RRule(it) }
            row.getAsString(Events.RDATE)?.let {
                val rDate = DateUtils.androidStringToRecurrenceSet(it, RDate::class.java, allDay)
                event.rDates += rDate
            }

            row.getAsString(Events.EXRULE)?.let {
                val exRule = ExRule()
                exRule.value = it
                event.exRule = exRule
            }
            row.getAsString(Events.EXDATE)?.let {
                val exDate = DateUtils.androidStringToRecurrenceSet(it, ExDate::class.java, allDay)
                event.exDates += exDate
            }
        } catch (e: ParseException) {
            Constants.log.log(Level.WARNING, "Couldn't parse recurrence rules, ignoring", e)
        } catch (e: IllegalArgumentException) {
            Constants.log.log(Level.WARNING, "Invalid recurrence rules, ignoring", e)
        }

        // status
        when (row.getAsInteger(Events.STATUS)) {
            Events.STATUS_CONFIRMED -> event.status = Status.VEVENT_CONFIRMED
            Events.STATUS_TENTATIVE -> event.status = Status.VEVENT_TENTATIVE
            Events.STATUS_CANCELED  -> event.status = Status.VEVENT_CANCELLED
        }

        // availability
        event.opaque = row.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE

        // set ORGANIZER if there's attendee data
        if (row.getAsInteger(Events.HAS_ATTENDEE_DATA) != 0 && row.containsKey(Events.ORGANIZER))
            try {
                event.organizer = Organizer(URI("mailto", row.getAsString(Events.ORGANIZER), null))
            } catch (e: URISyntaxException) {
                Constants.log.log(Level.WARNING, "Error when creating ORGANIZER mailto URI, ignoring", e)
            }

        // classification
        when (row.getAsInteger(Events.ACCESS_LEVEL)) {
            Events.ACCESS_PUBLIC       -> event.classification = Clazz.PUBLIC
            Events.ACCESS_PRIVATE      -> event.classification = Clazz.PRIVATE
            Events.ACCESS_CONFIDENTIAL -> event.classification = Clazz.CONFIDENTIAL
        }

        // exceptions from recurring events
        row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime ->
            val originalAllDay = (row.getAsInteger(Events.ORIGINAL_ALL_DAY) ?: 0) != 0
            val originalDate = if (originalAllDay)
                    Date(originalInstanceTime) else
                    DateTime(originalInstanceTime)
            if (originalDate is DateTime)
                originalDate.isUtc = true
            event.recurrenceId = RecurrenceId(originalDate)
        }
    }

    protected open fun populateAttendee(row: ContentValues) {
        Constants.log.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

            row.getAsString(Attendees.ATTENDEE_NAME)?.let { cn -> params.add(Cn(cn)) }

            // type
            val type = row.getAsInteger(Attendees.ATTENDEE_TYPE)
            params.add(if (type == Attendees.TYPE_RESOURCE) CuType.RESOURCE else CuType.INDIVIDUAL)

            // role
            when (row.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) {
                Attendees.RELATIONSHIP_ORGANIZER,
                Attendees.RELATIONSHIP_ATTENDEE,
                Attendees.RELATIONSHIP_PERFORMER,
                Attendees.RELATIONSHIP_SPEAKER -> {
                    params.add(if (type == Attendees.TYPE_REQUIRED) Role.REQ_PARTICIPANT else Role.OPT_PARTICIPANT)
                    params.add(Rsvp(true))     // ask server to send invitations
                }
                else /* RELATIONSHIP_NONE */ ->
                    params.add(Role.NON_PARTICIPANT)
            }

            // 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)
            }

            event!!.attendees.add(attendee)
        } catch (e: URISyntaxException) {
            Constants.log.log(Level.WARNING, "Couldn't parse attendee information, ignoring", e)
        }
    }

    protected open fun populateReminder(row: ContentValues) {
        Constants.log.log(Level.FINE, "Read event reminder from calender provider", row)
        val event = requireNotNull(event)

        val alarm = VAlarm(Dur(0, 0, -row.getAsInteger(Reminders.MINUTES), 0))

        val props = alarm.properties
        when (row.getAsInteger(Reminders.METHOD)) {
            Reminders.METHOD_EMAIL -> {
                val accountName = calendar.account.name
                if (Patterns.EMAIL_ADDRESS.matcher(accountName).matches()) {
                    props += Action.EMAIL
                    // ACTION:EMAIL requires SUMMARY, DESCRIPTION, ATTENDEE
                    props += Summary(event.summary)
                    props += Description(event.description ?: event.summary)
                    // Android doesn't allow to save email reminder recipients, so we always use the
                    // account name (should be account owner's email address)
                    props += Attendee(URI("mailto", calendar.account.name, null))
                } else {
                    Constants.log.warning("Account name is not an email address; changing EMAIL reminder to DISPLAY")
                    props += Action.DISPLAY
                    props += Description(event.summary)
                }
            }

            // default: set ACTION:DISPLAY (requires DESCRIPTION)
            else -> {
                props += Action.DISPLAY
                props += Description(event.summary)
            }
        }
        event.alarms += alarm
    }

    protected open fun populateExtended(row: ContentValues) {
        val name = row.getAsString(ExtendedProperties.NAME)
        Constants.log.log(Level.FINE, "Read extended property from calender provider (name=$name)")
        val event = requireNotNull(event)

        try {
            when (row.getAsString(ExtendedProperties.NAME)) {
                EXT_CATEGORIES -> {
                    val rawCategories = row.getAsString(ExtendedProperties.VALUE)
                    event.categories += rawCategories.split(EXT_CATEGORIES_SEPARATOR)
                }

                EXT_UNKNOWN_PROPERTY -> {
                    // deserialize unknown property (deprecated format)
                    val stream = ByteArrayInputStream(Base64.decode(row.getAsString(ExtendedProperties.VALUE), Base64.NO_WRAP))
                    ObjectInputStream(stream).use {
                        event.unknownProperties += it.readObject() as Property
                    }
                }

                EXT_UNKNOWN_PROPERTY2, UnknownProperty.CONTENT_ITEM_TYPE ->
                    event.unknownProperties += UnknownProperty.fromJsonString(row.getAsString(ExtendedProperties.VALUE))
            }
        } catch(e: Exception) {
            Constants.log.log(Level.WARNING, "Couldn't parse extended property", e)
        }
    }

    protected open fun populateExceptions() {
        requireNotNull(id)
        val event = requireNotNull(event)

        calendar.provider.query(calendar.syncAdapterURI(Events.CONTENT_URI),
                null,
                Events.ORIGINAL_ID + "=?", arrayOf(id.toString()), null)?.use { c ->
            while (c.moveToNext()) {
                val values = c.toValues(true)
                try {
                    val exception = calendar.eventFactory.fromProvider(calendar, values)

                    // make sure that all components have the same ORGANIZER [RFC 6638 3.1]
                    val exceptionEvent = exception.event!!
                    exceptionEvent.organizer = event.organizer
                    event.exceptions += exceptionEvent
                } catch (e: Exception) {
                    Constants.log.log(Level.WARNING, "Couldn't find exception details", e)
                }
            }
        }
    }

    private fun retainClassification() {
        /* retain classification other than PUBLIC and PRIVATE as unknown property so
           that it can be reused when "server default" is selected */
        val event = requireNotNull(event)
        event.classification?.let {
            if (it != Clazz.PUBLIC && it != Clazz.PRIVATE)
                event.unknownProperties += it
        }
    }


    /**
     * Saves an unsaved instance into the calendar storage.
     * @throws CalendarStorageException when the calendar provider doesn't return a result row
     * @throws RemoteException on calendar provider errors
     */
    fun add(): Uri {
        val batch = BatchOperation(calendar.provider)
        val idxEvent = add(batch)
        batch.commit()

        val result = batch.getResult(idxEvent) ?: throw CalendarStorageException("Empty result from content provider when adding event")
        id = ContentUris.parseId(result.uri)
        return result.uri
    }

    fun add(batch: BatchOperation): Int {
        val event = requireNotNull(event)
        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(eventsSyncURI()))

        val idxEvent = batch.nextBackrefIdx()
        buildEvent(null, builder)
        batch.enqueue(BatchOperation.Operation(builder))

        // add reminders
        event.alarms.forEach { insertReminder(batch, idxEvent, it) }

        // add attendees
        event.attendees.forEach { insertAttendee(batch, idxEvent, it) }

        // add unknown properties
        retainClassification()
        if (event.categories.isNotEmpty())
            insertCategories(batch, idxEvent)
        event.unknownProperties.forEach { insertUnknownProperty(batch, idxEvent, it) }

        // add exceptions
        for (exception in event.exceptions) {
            /* I guess exceptions should be inserted using Events.CONTENT_EXCEPTION_URI so that we could
               benefit from some provider logic (for recurring exceptions e.g.). However, this method
               has some caveats:
               - For instance, only Events.SYNC_DATA1, SYNC_DATA3 and SYNC_DATA7 can be used
               in exception events (that's hardcoded in the CalendarProvider, don't ask me why).
               - Also, CONTENT_EXCEPTIONS_URI doesn't deal with exceptions for recurring events defined by RDATE
               (it checks for RRULE and aborts if no RRULE is found).
               So I have chosen the method of inserting the exception event manually.

               It's also noteworthy that the link between the "master event" and the exception is not
               between ID and ORIGINAL_ID (as one could assume), but between _SYNC_ID and ORIGINAL_SYNC_ID.
               So, if you don't set _SYNC_ID in the master event and ORIGINAL_SYNC_ID in the exception,
               the exception will appear additionally (and not *instead* of the instance).
             */

            val exBuilder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(eventsSyncURI()))
            buildEvent(exception, exBuilder)

            var date = exception.recurrenceId!!.date
            if (event.isAllDay() && date is DateTime) {       // correct VALUE=DATE-TIME RECURRENCE-IDs to VALUE=DATE for all-day events
                val dateFormatDate = SimpleDateFormat("yyyyMMdd", Locale.US)
                val dateString = dateFormatDate.format(date)
                try {
                    date = Date(dateString)
                } catch (e: ParseException) {
                    Constants.log.log(Level.WARNING, "Couldn't parse DATE part of DATE-TIME RECURRENCE-ID", e)
                }
            }
            exBuilder.withValue(Events.ORIGINAL_ALL_DAY, if (event.isAllDay()) 1 else 0)
                    .withValue(Events.ORIGINAL_INSTANCE_TIME, date.time)

            val idxException = batch.nextBackrefIdx()
            batch.enqueue(BatchOperation.Operation(exBuilder, Events.ORIGINAL_ID, idxEvent))

            // add exception reminders
            exception.alarms.forEach { insertReminder(batch, idxException, it) }

            // add exception attendees
            exception.attendees.forEach { insertAttendee(batch, idxException, it) }
        }

        return idxEvent
    }

    /**
     * Updates an already existing event in the calendar storage with the values
     * from the instance.
     * @throws CalendarStorageException when the calendar provider doesn't return a result row
     * @throws RemoteException on calendar provider errors
     */
    fun update(event: Event): Uri {
        this.event = event

        val batch = BatchOperation(calendar.provider)
        delete(batch)

        val idxEvent = batch.nextBackrefIdx()

        add(batch)
        batch.commit()

        val uri = batch.getResult(idxEvent)?.uri ?: throw CalendarStorageException("Couldn't update event")
        id = ContentUris.parseId(uri)
        return uri
    }

    /**
     * Deletes an existing event from the calendar storage.
     * @throws RemoteException on calendar provider errors
     */
    fun delete(): Int {
        val batch = BatchOperation(calendar.provider)
        delete(batch)
        return batch.commit()
    }

    protected fun delete(batch: BatchOperation) {
        // remove exceptions of event, too (CalendarProvider doesn't do this)
        batch.enqueue(BatchOperation.Operation(ContentProviderOperation.newDelete(eventsSyncURI())
                .withSelection(Events.ORIGINAL_ID + "=?", arrayOf(id.toString()))))

        // remove event
        batch.enqueue(BatchOperation.Operation(ContentProviderOperation.newDelete(eventSyncURI())))
    }


    protected open fun buildEvent(recurrence: Event?, builder: Builder) {
        val isException = recurrence != null
        val event = if (isException)
            recurrence!!
        else
            requireNotNull(event)

        val dtStart = event.dtStart ?: throw CalendarStorageException("Events must have dtStart")
        MiscUtils.androidifyTimeZone(dtStart)

        builder .withValue(Events.CALENDAR_ID, calendar.id)
                .withValue(Events.ALL_DAY, if (event.isAllDay()) 1 else 0)
                .withValue(Events.EVENT_TIMEZONE, MiscUtils.getTzId(dtStart))
                .withValue(Events.HAS_ATTENDEE_DATA, 1 /* we know information about all attendees and not only ourselves */)

        dtStart.date?.time.let { builder.withValue(Events.DTSTART, it) }

        /* For cases where a "VEVENT" calendar component
           specifies a "DTSTART" property with a DATE value type but no
           "DTEND" nor "DURATION" property, the event's duration is taken to
           be one day. [RFC 5545 3.6.1] */
        var dtEnd = event.dtEnd
        if (event.isAllDay() && (dtEnd == null || !dtEnd.date.after(dtStart.date))) {
            // ical4j is not set to use floating times, so DATEs are UTC times internally
            Constants.log.log(Level.INFO, "Changing all-day event for Android compatibility: dtend := dtstart + 1 day")
            val c = java.util.Calendar.getInstance(TimeZone.getTimeZone(TimeZones.UTC_ID))
            c.time = dtStart.date
            c.add(java.util.Calendar.DATE, 1)
            event.dtEnd = DtEnd(Date(c.timeInMillis))
            dtEnd = event.dtEnd
            event.duration = null
        }

        /* For cases where a "VEVENT" calendar component
           specifies a "DTSTART" property with a DATE-TIME value type but no
           "DTEND" property, the event ends on the same calendar date and
           time of day specified by the "DTSTART" property. [RFC 5545 3.6.1] */
        else if (dtEnd == null || dtEnd.date.before(dtStart.date)) {
            Constants.log.info("Event without duration, setting dtend := dtstart")
            event.dtEnd = DtEnd(dtStart.date)
            dtEnd = event.dtEnd
        }
        dtEnd = requireNotNull(dtEnd)     // dtEnd is now guaranteed to not be null
        MiscUtils.androidifyTimeZone(dtEnd)

        var recurring = false
        event.rRule?.let { rRule ->
            recurring = true
            builder.withValue(Events.RRULE, rRule.value)
        }
        if (!event.rDates.isEmpty()) {
            recurring = true
            builder.withValue(Events.RDATE, DateUtils.recurrenceSetsToAndroidString(event.rDates, event.isAllDay()))
        }
        event.exRule?.let { exRule -> builder.withValue(Events.EXRULE, exRule.value) }
        if (!event.exDates.isEmpty())
            builder.withValue(Events.EXDATE, DateUtils.recurrenceSetsToAndroidString(event.exDates, event.isAllDay()))

        // set either DTEND for single-time events or DURATION for recurring events
        // because that's the way Android likes it
        if (recurring) {
            // calculate DURATION from start and end date
            val duration = event.duration ?: Duration(dtStart.date, dtEnd.date)
            builder .withValue(Events.DURATION, duration.value)
        } else
            builder .withValue(Events.DTEND, dtEnd.date.time)
                    .withValue(Events.EVENT_END_TIMEZONE, MiscUtils.getTzId(dtEnd))

        event.summary?.let { builder.withValue(Events.TITLE, it) }
        event.location?.let { builder.withValue(Events.EVENT_LOCATION, it) }
        event.description?.let { builder.withValue(Events.DESCRIPTION, it) }
        event.color?.let {
            val colorName = it.name
            // set event color (if it's available for this account)
            calendar.provider.query(calendar.syncAdapterURI(Colors.CONTENT_URI), arrayOf(Colors.COLOR_KEY),
                    "${Colors.COLOR_KEY}=? AND ${Colors.COLOR_TYPE}=${Colors.TYPE_EVENT}", arrayOf(colorName), null)?.use { cursor ->
                if (cursor.moveToNext())
                    builder.withValue(Events.EVENT_COLOR_KEY, colorName)
                else
                    Constants.log.fine("Ignoring event color: $colorName (not available for this account)")
            }
        }

        event.organizer?.let { organizer ->
            val email: String?
            val uri = organizer.calAddress
            email = if (uri.scheme.equals("mailto", true))
                uri.schemeSpecificPart
            else {
                val emailParam = organizer.getParameter(PARAMETER_EMAIL) as? Email
                emailParam?.value
            }
            if (email != null)
                builder.withValue(Events.ORGANIZER, email)
            else
                Constants.log.warning("Ignoring ORGANIZER without email address (not supported by Android)")
        }

        event.status?.let {
            builder.withValue(Events.STATUS, when(it) {
                Status.VEVENT_CONFIRMED -> Events.STATUS_CONFIRMED
                Status.VEVENT_CANCELLED -> Events.STATUS_CANCELED
                else                    -> Events.STATUS_TENTATIVE
            })
        }

        builder.withValue(Events.AVAILABILITY, if (event.opaque) Events.AVAILABILITY_BUSY else Events.AVAILABILITY_FREE)

        when (event.classification) {
            Clazz.PUBLIC       -> builder.withValue(Events.ACCESS_LEVEL, Events.ACCESS_PUBLIC)
            Clazz.PRIVATE      -> builder.withValue(Events.ACCESS_LEVEL, Events.ACCESS_PRIVATE)
            Clazz.CONFIDENTIAL -> builder.withValue(Events.ACCESS_LEVEL, Events.ACCESS_CONFIDENTIAL)
        }

        Constants.log.log(Level.FINE, "Built event object", builder.build())
    }

    protected open fun insertReminder(batch: BatchOperation, idxEvent: Int, alarm: VAlarm) {
        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(Reminders.CONTENT_URI))

        val method = when (alarm.action?.value?.toUpperCase(Locale.US)) {
            Action.DISPLAY.value,
            Action.AUDIO.value -> Reminders.METHOD_ALERT

            // Note: The calendar provider doesn't support saving specific attendees for email reminders.
            Action.EMAIL.value -> Reminders.METHOD_EMAIL

            else               -> Reminders.METHOD_DEFAULT
        }

        val (_, minutes) = ICalendar.vAlarmToMin(alarm, event!!, false) ?: return

        builder .withValue(Reminders.METHOD, method)
                .withValue(Reminders.MINUTES, minutes)

        Constants.log.log(Level.FINE, "Built alarm $minutes minutes before event", builder.build())
        batch.enqueue(BatchOperation.Operation(builder, Reminders.EVENT_ID, idxEvent))
    }

    protected open fun insertAttendee(batch: BatchOperation, idxEvent: Int, attendee: Attendee) {
        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(Attendees.CONTENT_URI))

        val member = attendee.calAddress
        if (member.scheme.equals("mailto", true))
            // attendee identified by email
            builder.withValue(Attendees.ATTENDEE_EMAIL, member.schemeSpecificPart)
        else {
            // attendee identified by other URI
            builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme)
                    .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart)
            (attendee.getParameter(PARAMETER_EMAIL) as? Email)?.let { email ->
                builder.withValue(Attendees.ATTENDEE_EMAIL, email.value)
            }
        }

        attendee.getParameter(Parameter.CN)?.let { cn ->
            builder.withValue(Attendees.ATTENDEE_NAME, cn.value)
        }

        var type = Attendees.TYPE_NONE
        val cutype = attendee.getParameter(Parameter.CUTYPE) as? CuType
        if (cutype in arrayOf(CuType.RESOURCE, CuType.ROOM))
            // "attendee" is a (physical) resource
            type = Attendees.TYPE_RESOURCE
        else {
            // attendee is not a (physical) resource
            val role = attendee.getParameter(Parameter.ROLE) as? Role
            val relationship: Int
            if (role == Role.CHAIR)
                relationship = Attendees.RELATIONSHIP_ORGANIZER
            else {
                relationship = Attendees.RELATIONSHIP_ATTENDEE
                when(role) {
                    Role.OPT_PARTICIPANT -> type = Attendees.TYPE_OPTIONAL
                    Role.REQ_PARTICIPANT -> type = Attendees.TYPE_REQUIRED
                }
            }
            builder.withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship)
        }

        val status = when(attendee.getParameter(Parameter.PARTSTAT) as? PartStat) {
            null,
            PartStat.NEEDS_ACTION -> Attendees.ATTENDEE_STATUS_INVITED
            PartStat.ACCEPTED     -> Attendees.ATTENDEE_STATUS_ACCEPTED
            PartStat.DECLINED     -> Attendees.ATTENDEE_STATUS_DECLINED
            PartStat.TENTATIVE    -> Attendees.ATTENDEE_STATUS_TENTATIVE
            else -> Attendees.ATTENDEE_STATUS_NONE
        }

        builder .withValue(Attendees.ATTENDEE_TYPE, type)
                .withValue(Attendees.ATTENDEE_STATUS, status)

        Constants.log.log(Level.FINE, "Built attendee", builder.build())
        batch.enqueue(BatchOperation.Operation(builder, Attendees.EVENT_ID, idxEvent))
    }

    protected open fun insertCategories(batch: BatchOperation, idxEvent: Int) {
        val rawCategories = event!!.categories
                .map { it.filter { it != EXT_CATEGORIES_SEPARATOR } }   // drop backslashes
                .joinToString(EXT_CATEGORIES_SEPARATOR.toString())      // concatenate, separate by backslash
        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(ExtendedProperties.CONTENT_URI))
                .withValue(ExtendedProperties.NAME, EXT_CATEGORIES)
                .withValue(ExtendedProperties.VALUE, rawCategories)

        Constants.log.log(Level.FINE, "Built categories", builder.build())
        batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent))
    }

    protected open fun insertUnknownProperty(batch: BatchOperation, idxEvent: Int, property: Property) {
        if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) {
            Constants.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
            return
        }

        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(ExtendedProperties.CONTENT_URI))
                .withValue(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
                .withValue(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))

        Constants.log.log(Level.FINE, "Built unknown property: ${property.name}")
        batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent))
    }

    private fun useRetainedClassification() {
        val event = requireNotNull(event)

        var retainedClazz: Clazz? = null
        val it = event.unknownProperties.iterator()
        while (it.hasNext()) {
            val prop = it.next()
            if (prop is Clazz) {
                retainedClazz = prop
                it.remove()
            }
        }

        if (event.classification == null)
            // no classification, use retained one if possible
            event.classification = retainedClazz
    }


    protected fun eventsSyncURI() = calendar.syncAdapterURI(Events.CONTENT_URI)

    protected fun eventSyncURI(): Uri {
        val id = requireNotNull(id)
        return calendar.syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))
    }

    override fun toString() = MiscUtils.reflectionToString(this)


}