AndroidEvent.kt 33.1 KB
Newer Older
Ricki Hirner's avatar
Ricki Hirner committed
1
/*
Ricki Hirner's avatar
Ricki Hirner committed
2 3 4 5 6
 * 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
Ricki Hirner's avatar
Ricki Hirner committed
7 8 9 10 11 12 13 14 15
 */

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
Ricki Hirner's avatar
Ricki Hirner committed
16
import android.database.DatabaseUtils
Ricki Hirner's avatar
Ricki Hirner committed
17 18 19 20 21 22 23 24 25 26 27 28
import android.net.Uri
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.CalendarContract.*
import android.util.Base64
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
29 30 31 32 33
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.FileNotFoundException
import java.io.ObjectInputStream
Ricki Hirner's avatar
Ricki Hirner committed
34 35 36 37 38 39 40 41
import java.net.URI
import java.net.URISyntaxException
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Level

/**
Ricki Hirner's avatar
Ricki Hirner committed
42 43 44 45
 * 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.
Ricki Hirner's avatar
Ricki Hirner committed
46 47 48 49 50
 *
 * 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(
51
        val calendar: AndroidCalendar<AndroidEvent>
Ricki Hirner's avatar
Ricki Hirner committed
52 53 54 55
) {

    companion object {

Ricki Hirner's avatar
Ricki Hirner committed
56
        /** [ExtendedProperties.NAME] for unknown iCal properties */
57
        @Deprecated("New serialization format", ReplaceWith("EXT_UNKNOWN_PROPERTY2"))
Ricki Hirner's avatar
Ricki Hirner committed
58
        const val EXT_UNKNOWN_PROPERTY = "unknown-property"
59
        const val EXT_UNKNOWN_PROPERTY2 = "unknown-property.v2"
Ricki Hirner's avatar
Ricki Hirner committed
60
        const val MAX_UNKNOWN_PROPERTY_SIZE = 25000
Ricki Hirner's avatar
Ricki Hirner committed
61

Ricki Hirner's avatar
Ricki Hirner committed
62 63 64
        // not declared in ical4j Parameters class yet
        private const val PARAMETER_EMAIL = "EMAIL"

Ricki Hirner's avatar
Ricki Hirner committed
65 66 67
    }

    var id: Long? = null
Ricki Hirner's avatar
Ricki Hirner committed
68
        protected set
Ricki Hirner's avatar
Ricki Hirner committed
69

Ricki Hirner's avatar
Ricki Hirner committed
70 71 72 73 74 75 76
    /**
     * 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.
Ricki Hirner's avatar
Ricki Hirner committed
77 78
    }

Ricki Hirner's avatar
Ricki Hirner committed
79 80 81 82
    /**
     * 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
     */
83
    constructor(calendar: AndroidCalendar<AndroidEvent>, event: Event): this(calendar) {
Ricki Hirner's avatar
Ricki Hirner committed
84 85 86 87
        this.event = event
    }

    var event: Event? = null
Ricki Hirner's avatar
Ricki Hirner committed
88 89 90 91 92 93 94 95 96 97 98
        /**
         * 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)
Ricki Hirner's avatar
Ricki Hirner committed
99

Ricki Hirner's avatar
Ricki Hirner committed
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
            var iterEvents: EntityIterator? = null
            try {
                iterEvents = CalendarContract.EventsEntity.newEntityIterator(
                        calendar.provider.query(
                                calendar.syncAdapterURI(ContentUris.withAppendedId(CalendarContract.EventsEntity.CONTENT_URI, id)),
                                null, null, null, null),
                        calendar.provider
                )
                if (iterEvents.hasNext()) {
                    val event = Event()
                    field = event

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

                    for (subValue in e.subValues)
                        when (subValue.uri) {
                            Attendees.CONTENT_URI -> populateAttendee(subValue.values)
                            Reminders.CONTENT_URI -> populateReminder(subValue.values)
                            CalendarContract.ExtendedProperties.CONTENT_URI -> populateExtended(subValue.values)
                        }
                    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 }
                    }
131

Ricki Hirner's avatar
Ricki Hirner committed
132
                    return field
Ricki Hirner's avatar
Ricki Hirner committed
133
                }
Ricki Hirner's avatar
Ricki Hirner committed
134 135
            } finally {
                iterEvents?.close()
Ricki Hirner's avatar
Ricki Hirner committed
136
            }
Ricki Hirner's avatar
Ricki Hirner committed
137
            throw FileNotFoundException("Couldn't find event $id")
Ricki Hirner's avatar
Ricki Hirner committed
138 139
        }

Ricki Hirner's avatar
Ricki Hirner committed
140 141 142 143
    /**
     * Reads event data from the calendar provider.
     * @param row values of an [Events] row, as returned by the calendar provider
     */
Ricki Hirner's avatar
Ricki Hirner committed
144
    protected open fun populateEvent(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
145
        val event = requireNotNull(event)
Ricki Hirner's avatar
Ricki Hirner committed
146

Ricki Hirner's avatar
Ricki Hirner committed
147
        Constants.log.log(Level.FINE, "Read event entity from calender provider", row)
Ricki Hirner's avatar
Ricki Hirner committed
148 149
        MiscUtils.removeEmptyStrings(row)

Ricki Hirner's avatar
Ricki Hirner committed
150 151 152 153
        event.summary = row.getAsString(Events.TITLE)
        event.location = row.getAsString(Events.EVENT_LOCATION)
        event.description = row.getAsString(Events.DESCRIPTION)

Ricki Hirner's avatar
Ricki Hirner committed
154
        row.getAsString(Events.EVENT_COLOR_KEY)?.let { name ->
Ricki Hirner's avatar
Ricki Hirner committed
155
            try {
Ricki Hirner's avatar
Ricki Hirner committed
156
                event.color = Css3Color.valueOf(name)
Ricki Hirner's avatar
Ricki Hirner committed
157 158 159 160 161
            } catch(e: IllegalArgumentException) {
                Constants.log.warning("Ignoring unknown color $name from Calendar Provider")
            }
        }

Ricki Hirner's avatar
Ricki Hirner committed
162
        val allDay = (row.getAsInteger(Events.ALL_DAY) ?: 0) != 0
Ricki Hirner's avatar
Ricki Hirner committed
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
        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)
182
            tz?.let { start.timeZone = it }
Ricki Hirner's avatar
Ricki Hirner committed
183 184 185 186 187
            event.dtStart = DtStart(start)

            when {
                tsEnd != null -> {
                    val end = DateTime(tsEnd)
188
                    tz?.let { end.timeZone = it }
Ricki Hirner's avatar
Ricki Hirner committed
189 190 191 192 193 194 195 196
                    event.dtEnd = DtEnd(end)
                }
                duration != null -> event.duration = Duration(Dur(duration))
            }
        }

        // recurrence
        try {
Ricki Hirner's avatar
Ricki Hirner committed
197 198 199
            row.getAsString(Events.RRULE)?.let { event.rRule = RRule(it) }
            row.getAsString(Events.RDATE)?.let {
                val rDate = DateUtils.androidStringToRecurrenceSet(it, RDate::class.java, allDay)
200
                event.rDates += rDate
Ricki Hirner's avatar
Ricki Hirner committed
201 202
            }

Ricki Hirner's avatar
Ricki Hirner committed
203
            row.getAsString(Events.EXRULE)?.let {
Ricki Hirner's avatar
Ricki Hirner committed
204
                val exRule = ExRule()
Ricki Hirner's avatar
Ricki Hirner committed
205
                exRule.value = it
Ricki Hirner's avatar
Ricki Hirner committed
206 207
                event.exRule = exRule
            }
Ricki Hirner's avatar
Ricki Hirner committed
208 209
            row.getAsString(Events.EXDATE)?.let {
                val exDate = DateUtils.androidStringToRecurrenceSet(it, ExDate::class.java, allDay)
210
                event.exDates += exDate
Ricki Hirner's avatar
Ricki Hirner committed
211 212 213 214 215 216 217 218
            }
        } 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
219 220 221 222
        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
Ricki Hirner's avatar
Ricki Hirner committed
223 224 225 226 227 228 229 230 231 232 233 234 235 236
        }

        // 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
237 238 239 240
        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
Ricki Hirner's avatar
Ricki Hirner committed
241 242 243
        }

        // exceptions from recurring events
Ricki Hirner's avatar
Ricki Hirner committed
244
        row.getAsLong(Events.ORIGINAL_INSTANCE_TIME)?.let { originalInstanceTime ->
Ricki Hirner's avatar
Ricki Hirner committed
245 246 247 248 249 250 251 252 253 254 255 256
            var originalAllDay = false
            row.getAsInteger(Events.ORIGINAL_ALL_DAY)?.let { originalAllDay = it != 0 }

            val originalDate = if (originalAllDay)
                    Date(originalInstanceTime) else
                    DateTime(originalInstanceTime)
            if (originalDate is DateTime)
                originalDate.isUtc = true
            event.recurrenceId = RecurrenceId(originalDate)
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
257
    protected open fun populateAttendee(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
258
        Constants.log.log(Level.FINE, "Read event attendee from calender provider", row)
Ricki Hirner's avatar
Ricki Hirner committed
259 260
        MiscUtils.removeEmptyStrings(row)

Ricki Hirner's avatar
Ricki Hirner committed
261 262 263
        try {
            val attendee: Attendee
            val email = row.getAsString(Attendees.ATTENDEE_EMAIL)
Ricki Hirner's avatar
Ricki Hirner committed
264 265
            val idNS = row.getAsString(Attendees.ATTENDEE_ID_NAMESPACE)
            val id = row.getAsString(Attendees.ATTENDEE_IDENTITY)
Ricki Hirner's avatar
Ricki Hirner committed
266 267 268 269

            if (idNS != null || id != null) {
                // attendee identified by namespace and ID
                attendee = Attendee(URI(idNS, id, null))
Ricki Hirner's avatar
Ricki Hirner committed
270
                email?.let { attendee.parameters.add(Email(it)) }
Ricki Hirner's avatar
Ricki Hirner committed
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289
            } 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
            val relationship = row.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)
            when (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)
290
                    params.add(Rsvp(true))     // ask server to send invitations
Ricki Hirner's avatar
Ricki Hirner committed
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309
                }
                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)
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
310
    protected open fun populateReminder(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
311 312
        Constants.log.log(Level.FINE, "Read event reminder from calender provider", row)

Ricki Hirner's avatar
Ricki Hirner committed
313 314 315 316
        val event = requireNotNull(event)
        val alarm = VAlarm(Dur(0, 0, -row.getAsInteger(Reminders.MINUTES), 0))

        val props = alarm.properties
Ricki Hirner's avatar
Ricki Hirner committed
317
        props += when (row.getAsInteger(Reminders.METHOD)) {
Ricki Hirner's avatar
Ricki Hirner committed
318 319
            Reminders.METHOD_ALARM,
            Reminders.METHOD_ALERT ->
Ricki Hirner's avatar
Ricki Hirner committed
320
                Action.DISPLAY
Ricki Hirner's avatar
Ricki Hirner committed
321 322
            Reminders.METHOD_EMAIL,
            Reminders.METHOD_SMS ->
Ricki Hirner's avatar
Ricki Hirner committed
323
                Action.EMAIL
Ricki Hirner's avatar
Ricki Hirner committed
324 325
            else ->
                // show alarm by default
Ricki Hirner's avatar
Ricki Hirner committed
326
                Action.DISPLAY
Ricki Hirner's avatar
Ricki Hirner committed
327
        }
328 329
        props += Description(event.summary)
        event.alarms += alarm
Ricki Hirner's avatar
Ricki Hirner committed
330 331
    }

Ricki Hirner's avatar
Ricki Hirner committed
332
    protected open fun populateExtended(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
333
        Constants.log.log(Level.FINE, "Read extended property from calender provider", row.getAsString(ExtendedProperties.NAME))
334
        val event = requireNotNull(event)
Ricki Hirner's avatar
Ricki Hirner committed
335

336 337 338 339 340 341 342 343 344 345 346 347 348
        try {
            when (row.getAsString(ExtendedProperties.NAME)) {
                EXT_UNKNOWN_PROPERTY -> {
                    // deserialize unknown property v1 (deprecated)
                    val stream = ByteArrayInputStream(Base64.decode(row.getAsString(ExtendedProperties.VALUE), Base64.NO_WRAP))
                    ObjectInputStream(stream).use {
                        event.unknownProperties += it.readObject() as Property
                    }
                }

                EXT_UNKNOWN_PROPERTY2 -> {
                    // deserialize unknown property v2
                    event.unknownProperties += UnknownProperty.fromExtendedProperty(row.getAsString(ExtendedProperties.VALUE))
Ricki Hirner's avatar
Ricki Hirner committed
349 350
                }
            }
351 352
        } catch(e: Exception) {
            Constants.log.log(Level.WARNING, "Couldn't parse extended property", e)
Ricki Hirner's avatar
Ricki Hirner committed
353 354 355
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
356
    protected open fun populateExceptions() {
Ricki Hirner's avatar
Ricki Hirner committed
357 358 359 360
        requireNotNull(id)
        val event = requireNotNull(event)

        calendar.provider.query(calendar.syncAdapterURI(Events.CONTENT_URI),
Ricki Hirner's avatar
Ricki Hirner committed
361
                null,
362 363
                Events.ORIGINAL_ID + "=?", arrayOf(id.toString()), null)?.use { c ->
            while (c.moveToNext()) {
Ricki Hirner's avatar
Ricki Hirner committed
364 365
                val values = ContentValues(c.columnCount)
                DatabaseUtils.cursorRowToContentValues(c, values)
Ricki Hirner's avatar
Ricki Hirner committed
366
                try {
Ricki Hirner's avatar
Ricki Hirner committed
367
                    val exception = calendar.eventFactory.fromProvider(calendar, values)
Ricki Hirner's avatar
Ricki Hirner committed
368 369 370 371

                    // make sure that all components have the same ORGANIZER [RFC 6638 3.1]
                    val exceptionEvent = exception.event!!
                    exceptionEvent.organizer = event.organizer
372
                    event.exceptions += exceptionEvent
Ricki Hirner's avatar
Ricki Hirner committed
373 374 375 376 377 378 379
                } catch (e: Exception) {
                    Constants.log.log(Level.WARNING, "Couldn't find exception details", e)
                }
            }
        }
    }

380 381 382 383 384 385 386 387 388 389
    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
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
390

Ricki Hirner's avatar
Ricki Hirner committed
391 392 393 394 395
    /**
     * 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
     */
Ricki Hirner's avatar
Ricki Hirner committed
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
    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
421
        retainClassification()
Ricki Hirner's avatar
Ricki Hirner committed
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440
        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).
             */

Ricki Hirner's avatar
Ricki Hirner committed
441 442
            val exBuilder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(eventsSyncURI()))
            buildEvent(exception, exBuilder)
Ricki Hirner's avatar
Ricki Hirner committed
443 444 445 446 447 448 449 450 451 452 453

            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)
                }
            }
Ricki Hirner's avatar
Ricki Hirner committed
454
            exBuilder.withValue(Events.ORIGINAL_ALL_DAY, if (event.isAllDay()) 1 else 0)
Ricki Hirner's avatar
Ricki Hirner committed
455 456 457
                    .withValue(Events.ORIGINAL_INSTANCE_TIME, date.time)

            val idxException = batch.nextBackrefIdx()
Ricki Hirner's avatar
Ricki Hirner committed
458
            batch.enqueue(BatchOperation.Operation(exBuilder, Events.ORIGINAL_ID, idxEvent))
Ricki Hirner's avatar
Ricki Hirner committed
459 460 461 462 463 464 465 466 467 468 469

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

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

        return idxEvent
    }

Ricki Hirner's avatar
Ricki Hirner committed
470 471 472 473 474 475
    /**
     * 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
     */
Ricki Hirner's avatar
Ricki Hirner committed
476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491
    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
    }

Ricki Hirner's avatar
Ricki Hirner committed
492 493 494 495
    /**
     * Deletes an existing event from the calendar storage.
     * @throws RemoteException on calendar provider errors
     */
Ricki Hirner's avatar
Ricki Hirner committed
496 497 498 499 500 501 502
    fun delete(): Int {
        val batch = BatchOperation(calendar.provider)
        delete(batch)
        return batch.commit()
    }

    protected fun delete(batch: BatchOperation) {
503
        // remove exceptions of event, too (CalendarProvider doesn't do this)
Ricki Hirner's avatar
Ricki Hirner committed
504 505
        batch.enqueue(BatchOperation.Operation(ContentProviderOperation.newDelete(eventsSyncURI())
                .withSelection(Events.ORIGINAL_ID + "=?", arrayOf(id.toString()))))
506 507 508

        // remove event
        batch.enqueue(BatchOperation.Operation(ContentProviderOperation.newDelete(eventSyncURI())))
Ricki Hirner's avatar
Ricki Hirner committed
509 510
    }

Ricki Hirner's avatar
Ricki Hirner committed
511 512

    protected open fun buildEvent(recurrence: Event?, builder: Builder) {
Ricki Hirner's avatar
Ricki Hirner committed
513 514 515 516 517 518
        val isException = recurrence != null
        val event = if (isException)
            recurrence!!
        else
            requireNotNull(event)

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

Ricki Hirner's avatar
Ricki Hirner committed
522 523
        builder .withValue(Events.CALENDAR_ID, calendar.id)
                .withValue(Events.ALL_DAY, if (event.isAllDay()) 1 else 0)
524
                .withValue(Events.EVENT_TIMEZONE, MiscUtils.getTzId(dtStart))
Ricki Hirner's avatar
Ricki Hirner committed
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
                .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
555
        MiscUtils.androidifyTimeZone(dtEnd)
Ricki Hirner's avatar
Ricki Hirner committed
556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577

        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)
578
                    .withValue(Events.EVENT_END_TIMEZONE, MiscUtils.getTzId(dtEnd))
Ricki Hirner's avatar
Ricki Hirner committed
579 580 581 582

        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) }
583 584 585 586 587 588 589 590 591 592 593
        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)")
            }
        }
Ricki Hirner's avatar
Ricki Hirner committed
594 595 596 597

        event.organizer?.let { organizer ->
            val email: String?
            val uri = organizer.calAddress
Ricki Hirner's avatar
Ricki Hirner committed
598 599
            email = if (uri.scheme.equals("mailto", true))
                uri.schemeSpecificPart
Ricki Hirner's avatar
Ricki Hirner committed
600
            else {
Ricki Hirner's avatar
Ricki Hirner committed
601
                val emailParam = organizer.getParameter(PARAMETER_EMAIL) as? Email
Ricki Hirner's avatar
Ricki Hirner committed
602
                emailParam?.value
Ricki Hirner's avatar
Ricki Hirner committed
603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619
            }
            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)

620 621 622 623
        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)
Ricki Hirner's avatar
Ricki Hirner committed
624 625 626 627 628
        }

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

Ricki Hirner's avatar
Ricki Hirner committed
629
    protected open fun insertReminder(batch: BatchOperation, idxEvent: Int, alarm: VAlarm) {
Ricki Hirner's avatar
Ricki Hirner committed
630 631 632 633 634 635 636 637 638 639
        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(Reminders.CONTENT_URI))

        val action = alarm.action
        val method = when (action?.value) {
            Action.DISPLAY.value,
            Action.AUDIO.value -> Reminders.METHOD_ALERT
            Action.EMAIL.value -> Reminders.METHOD_EMAIL
            else               -> Reminders.METHOD_DEFAULT
        }

Ricki Hirner's avatar
Ricki Hirner committed
640
        val minutes = ICalendar.alarmMinBefore(alarm)
Ricki Hirner's avatar
Ricki Hirner committed
641 642 643
        builder .withValue(Reminders.METHOD, method)
                .withValue(Reminders.MINUTES, minutes)

Ricki Hirner's avatar
Ricki Hirner committed
644
        Constants.log.log(Level.FINE, "Built alarm $minutes minutes before event", builder.build())
Ricki Hirner's avatar
Ricki Hirner committed
645 646 647
        batch.enqueue(BatchOperation.Operation(builder, Reminders.EVENT_ID, idxEvent))
    }

Ricki Hirner's avatar
Ricki Hirner committed
648
    protected open fun insertAttendee(batch: BatchOperation, idxEvent: Int, attendee: Attendee) {
Ricki Hirner's avatar
Ricki Hirner committed
649 650 651 652 653 654
        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)
Ricki Hirner's avatar
Ricki Hirner committed
655
        else {
Ricki Hirner's avatar
Ricki Hirner committed
656 657 658
            // attendee identified by other URI
            builder .withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.scheme)
                    .withValue(Attendees.ATTENDEE_IDENTITY, member.schemeSpecificPart)
Ricki Hirner's avatar
Ricki Hirner committed
659
            (attendee.getParameter(PARAMETER_EMAIL) as? Email)?.let { email ->
Ricki Hirner's avatar
Ricki Hirner committed
660 661 662 663
                builder.withValue(Attendees.ATTENDEE_EMAIL, email.value)
            }
        }

bernhard's avatar
bernhard committed
664
        (attendee.getParameter(Parameter.CN) as Cn)?.let { cn ->
Ricki Hirner's avatar
Ricki Hirner committed
665 666 667 668
            builder.withValue(Attendees.ATTENDEE_NAME, cn.value)
        }

        var type = Attendees.TYPE_NONE
Ricki Hirner's avatar
Ricki Hirner committed
669
        val cutype = attendee.getParameter(Parameter.CUTYPE) as? CuType
Ricki Hirner's avatar
Ricki Hirner committed
670
        if (cutype in arrayOf(CuType.RESOURCE, CuType.ROOM))
Ricki Hirner's avatar
Ricki Hirner committed
671 672 673 674
            // "attendee" is a (physical) resource
            type = Attendees.TYPE_RESOURCE
        else {
            // attendee is not a (physical) resource
Ricki Hirner's avatar
Ricki Hirner committed
675
            val role = attendee.getParameter(Parameter.ROLE) as? Role
Ricki Hirner's avatar
Ricki Hirner committed
676 677 678 679 680 681 682 683 684 685 686 687 688
            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)
        }

Ricki Hirner's avatar
Ricki Hirner committed
689
        val partStat = attendee.getParameter(Parameter.PARTSTAT) as? PartStat
Ricki Hirner's avatar
Ricki Hirner committed
690 691 692 693 694 695 696 697 698 699 700 701
        val status = when(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)

Ricki Hirner's avatar
Ricki Hirner committed
702
        Constants.log.log(Level.FINE, "Built attendee", builder.build())
Ricki Hirner's avatar
Ricki Hirner committed
703 704 705
        batch.enqueue(BatchOperation.Operation(builder, Attendees.EVENT_ID, idxEvent))
    }

Ricki Hirner's avatar
Ricki Hirner committed
706
    protected open fun insertUnknownProperty(batch: BatchOperation, idxEvent: Int, property: Property) {
707 708 709 710
        if (property.value.length > MAX_UNKNOWN_PROPERTY_SIZE) {
            Constants.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
            return
        }
Ricki Hirner's avatar
Ricki Hirner committed
711

712 713 714
        val builder = ContentProviderOperation.newInsert(calendar.syncAdapterURI(ExtendedProperties.CONTENT_URI))
        builder .withValue(ExtendedProperties.NAME, EXT_UNKNOWN_PROPERTY2)
                .withValue(ExtendedProperties.VALUE, UnknownProperty.toExtendedProperty(property))
Ricki Hirner's avatar
Ricki Hirner committed
715

716
        batch.enqueue(BatchOperation.Operation(builder, ExtendedProperties.EVENT_ID, idxEvent))
Ricki Hirner's avatar
Ricki Hirner committed
717 718
    }

719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736
    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
    }

Ricki Hirner's avatar
Ricki Hirner committed
737 738 739 740 741 742 743 744

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

Ricki Hirner's avatar
Ricki Hirner committed
745
    override fun toString() = MiscUtils.reflectionToString(this)
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 789 790 791 792 793 794 795 796 797 798 799 800 801 802

    /**
     * Helpers to (de)serialize unknown properties as JSON to store it in an Android ExtendedProperty row.
     *
     * Format: `{ propertyName, propertyValue, { param1Name: param1Value, ... } }`, with the third
     * array (parameters) being optional.
     */
    object UnknownProperty {

        /**
         * Deserializes a JSON string from an ExtendedProperty value to an ical4j property.
         *
         * @param jsonString JSON representation of an ical4j property
         * @return ical4j property, generated from [jsonString]
         * @throws org.json.JSONException when the input value can't be parsed
         */
        fun fromExtendedProperty(jsonString: String): Property {
            val json = JSONArray(jsonString)
            val name = json.getString(0)
            val value = json.getString(1)

            val params = ParameterList()
            json.optJSONObject(2)?.let { jsonParams ->
                for (paramName in jsonParams.keys())
                    params.add(ICalendar.parameterFactoryRegistry.createParameter(
                            paramName,
                            jsonParams.getString(paramName)
                    ))
            }

            return ICalendar.propertyFactoryRegistry.createProperty(name, params, value)
        }

        /**
         * Serializes an ical4j property to a JSON string that can be stored in an ExtendedProperty.
         *
         * @param prop property to serialize as JSON
         * @return JSON representation of [prop]
         */
        fun toExtendedProperty(prop: Property): String {
            val json = JSONArray()
            json.put(prop.name)
            json.put(prop.value)

            if (!prop.parameters.isEmpty) {
                val jsonParams = JSONObject()
                for (param in prop.parameters)
                    jsonParams.put(param.name, param.value)
                json.put(jsonParams)
            }

            return json.toString()
        }

    }

Ricki Hirner's avatar
Ricki Hirner committed
803
}