From 495cdf7c7e47f2a9d4d694e851996985ff4288c5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Apr 2015 13:50:06 +0200 Subject: [PATCH] Synchronize exceptions of recurring events to the Calendar storage (server to client) * Event class finds and processes exceptions of recurring events * workaround for iCloud and other services that provide RECURRENCE-ID as DATETIME even if the original event is an all-day event * VEvents are generated with all time zone definitions (including time zone definitions of exceptions) --- .../at/bitfire/davdroid/resource/Event.java | 163 +++++++++++------- .../davdroid/resource/LocalCalendar.java | 78 ++++++++- .../davdroid/resource/LocalCollection.java | 2 +- 3 files changed, 175 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/at/bitfire/davdroid/resource/Event.java b/app/src/main/java/at/bitfire/davdroid/resource/Event.java index deb53d44..79ce5455 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/Event.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/Event.java @@ -58,8 +58,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.StringReader; import java.util.Calendar; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.SimpleTimeZone; import java.util.TimeZone; @@ -145,19 +147,44 @@ public class Event extends Resource { throw new InvalidResourceException(e); } - // event ComponentList events = ical.getComponents(Component.VEVENT); if (events == null || events.isEmpty()) throw new InvalidResourceException("No VEVENT found"); - VEvent event = (VEvent)events.get(0); - + + // find master VEVENT (the one that is not an exception, i.e. the one without RECURRENCE-ID) + VEvent master = null; + for (Object objEvent : events) { + VEvent event = (VEvent)objEvent; + if (event.getRecurrenceId() == null) { + master = event; + break; + } + } + if (master == null) + throw new InvalidResourceException("No VEVENT without RECURRENCE-ID found"); + // set event data from master VEVENT + fromVEvent(master); + + // find and process exceptions + for (Object objEvent : events) { + VEvent event = (VEvent)objEvent; + if (event.getRecurrenceId() != null) { + Event exception = new Event(name, null); + exception.fromVEvent(event); + exceptions.add(exception); + } + } + } + + protected void fromVEvent(VEvent event) throws InvalidResourceException { if (event.getUid() != null) uid = event.getUid().getValue(); else { Log.w(TAG, "Received VEVENT without UID, generating new one"); generateUID(); } - + recurrenceId = event.getRecurrenceId(); + if ((dtStart = event.getStartDate()) == null || (dtEnd = event.getEndDate()) == null) throw new InvalidResourceException("Invalid start time/end time/duration"); @@ -165,7 +192,7 @@ public class Event extends Resource { validateTimeZone(dtStart); validateTimeZone(dtEnd); } - + // all-day events and "events on that day": // * related UNIX times must be in UTC // * must have a duration (set to one day if missing) @@ -176,26 +203,26 @@ public class Event extends Resource { c.add(Calendar.DATE, 1); dtEnd.setDate(new Date(c.getTimeInMillis())); } - + rrule = (RRule)event.getProperty(Property.RRULE); rdate = (RDate)event.getProperty(Property.RDATE); exrule = (ExRule)event.getProperty(Property.EXRULE); exdate = (ExDate)event.getProperty(Property.EXDATE); - + if (event.getSummary() != null) summary = event.getSummary().getValue(); if (event.getLocation() != null) location = event.getLocation().getValue(); if (event.getDescription() != null) description = event.getDescription().getValue(); - + status = event.getStatus(); - opaque = event.getTransparency() != Transp.TRANSPARENT; - + opaque = event.getTransparency() != Transp.TRANSPARENT; + organizer = event.getOrganizer(); for (Object o : event.getProperties(Property.ATTENDEE)) attendees.add((Attendee)o); - + Clazz classification = event.getClassification(); if (classification != null) { if (classification == Clazz.PUBLIC) @@ -203,10 +230,12 @@ public class Event extends Resource { else if (classification == Clazz.CONFIDENTIAL || classification == Clazz.PRIVATE) forPublic = false; } - + this.alarms = event.getAlarms(); + } + @Override @SuppressWarnings("unchecked") public ByteArrayOutputStream toEntity() throws IOException { @@ -214,26 +243,38 @@ public class Event extends Resource { ical.getProperties().add(Version.VERSION_2_0); ical.getProperties().add(new ProdId("-//bitfire web engineering//DAVdroid " + Constants.APP_VERSION + " (ical4j 1.0.x)//EN")); - // "main event" (without exceptions) + // "master event" (without exceptions) ComponentList components = ical.getComponents(); - VEvent mainEvent = toVEvent(this); - components.add(mainEvent); + VEvent master = toVEvent(); + components.add(master); + + // remember used time zones + Set usedTimeZones = new HashSet<>(); + if (dtStart != null && dtStart.getTimeZone() != null) + usedTimeZones.add(dtStart.getTimeZone()); + if (dtEnd != null && dtEnd.getTimeZone() != null) + usedTimeZones.add(dtEnd.getTimeZone()); // recurrence exceptions for (Event exception : exceptions) { - VEvent vException = toVEvent(exception); - vException.getProperties().add(mainEvent.getProperty(Property.UID)); + // create VEVENT for exception + VEvent vException = exception.toVEvent(); + + // set UID to UID of master event + vException.getProperties().add(master.getProperty(Property.UID)); + components.add(vException); + + // remember used time zones + if (exception.dtStart != null && exception.dtStart.getTimeZone() != null) + usedTimeZones.add(exception.dtStart.getTimeZone()); + if (exception.dtEnd != null && exception.dtEnd.getTimeZone() != null) + usedTimeZones.add(exception.dtEnd.getTimeZone()); } // add VTIMEZONE components - net.fortuna.ical4j.model.TimeZone - tzStart = (dtStart == null ? null : dtStart.getTimeZone()), - tzEnd = (dtEnd == null ? null : dtEnd.getTimeZone()); - if (tzStart != null) - ical.getComponents().add(tzStart.getVTimeZone()); - if (tzEnd != null && tzEnd != tzStart) - ical.getComponents().add(tzEnd.getVTimeZone()); + for (net.fortuna.ical4j.model.TimeZone timeZone : usedTimeZones) + ical.getComponents().add(timeZone.getVTimeZone()); CalendarOutputter output = new CalendarOutputter(false); ByteArrayOutputStream os = new ByteArrayOutputStream(); @@ -245,50 +286,50 @@ public class Event extends Resource { return os; } - protected static VEvent toVEvent(Event e) { + protected VEvent toVEvent() { VEvent event = new VEvent(); PropertyList props = event.getProperties(); - if (e.uid != null) - props.add(new Uid(e.uid)); - if (e.recurrenceId != null) - props.add(e.recurrenceId); - - props.add(e.dtStart); - if (e.dtEnd != null) - props.add(e.dtEnd); - if (e.duration != null) - props.add(e.duration); - - if (e.rrule != null) - props.add(e.rrule); - if (e.rdate != null) - props.add(e.rdate); - if (e.exrule != null) - props.add(e.exrule); - if (e.exdate != null) - props.add(e.exdate); - - if (e.summary != null && !e.summary.isEmpty()) - props.add(new Summary(e.summary)); - if (e.location != null && !e.location.isEmpty()) - props.add(new Location(e.location)); - if (e.description != null && !e.description.isEmpty()) - props.add(new Description(e.description)); - - if (e.status != null) - props.add(e.status); - if (!e.opaque) + if (uid != null) + props.add(new Uid(uid)); + if (recurrenceId != null) + props.add(recurrenceId); + + props.add(dtStart); + if (dtEnd != null) + props.add(dtEnd); + if (duration != null) + props.add(duration); + + if (rrule != null) + props.add(rrule); + if (rdate != null) + props.add(rdate); + if (exrule != null) + props.add(exrule); + if (exdate != null) + props.add(exdate); + + if (summary != null && !summary.isEmpty()) + props.add(new Summary(summary)); + if (location != null && !location.isEmpty()) + props.add(new Location(location)); + if (description != null && !description.isEmpty()) + props.add(new Description(description)); + + if (status != null) + props.add(status); + if (!opaque) props.add(Transp.TRANSPARENT); - if (e.organizer != null) - props.add(e.organizer); - props.addAll(e.attendees); + if (organizer != null) + props.add(organizer); + props.addAll(attendees); - if (e.forPublic != null) - event.getProperties().add(e.forPublic ? Clazz.PUBLIC : Clazz.PRIVATE); + if (forPublic != null) + event.getProperties().add(forPublic ? Clazz.PUBLIC : Clazz.PRIVATE); - event.getAlarms().addAll(e.alarms); + event.getAlarms().addAll(alarms); props.add(new LastModified()); return event; diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java index b486e842..6d8001d1 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java @@ -35,6 +35,9 @@ import net.fortuna.ical4j.model.Dur; import net.fortuna.ical4j.model.Parameter; import net.fortuna.ical4j.model.ParameterList; import net.fortuna.ical4j.model.PropertyList; +import net.fortuna.ical4j.model.TimeZone; +import net.fortuna.ical4j.model.TimeZoneRegistry; +import net.fortuna.ical4j.model.TimeZoneRegistryFactory; import net.fortuna.ical4j.model.component.VAlarm; import net.fortuna.ical4j.model.parameter.Cn; import net.fortuna.ical4j.model.parameter.CuType; @@ -51,6 +54,7 @@ import net.fortuna.ical4j.model.property.RDate; import net.fortuna.ical4j.model.property.RRule; import net.fortuna.ical4j.model.property.RecurrenceId; import net.fortuna.ical4j.model.property.Status; +import net.fortuna.ical4j.util.TimeZones; import org.apache.commons.lang.StringUtils; @@ -211,11 +215,12 @@ public class LocalCalendar extends LocalCollection { // mark (recurring) events with changed/deleted exceptions as dirty String where = entryColumnID() + " IN (SELECT DISTINCT " + Events.ORIGINAL_ID + " FROM events WHERE " + Events.ORIGINAL_ID + " IS NOT NULL AND (" + Events.DIRTY + "=1 OR " + Events.DELETED + "=1))"; - Log.i(TAG, where); ContentValues dirty = new ContentValues(1); dirty.put(CalendarContract.Events.DIRTY, 1); try { - providerClient.update(entriesURI(), dirty, where, null); + int rows = providerClient.update(entriesURI(), dirty, where, null); + if (rows > 0) + Log.d(TAG, rows + " event(s) marked as dirty because of dirty/deleted exceptions"); } catch (RemoteException e) { Log.e(TAG, "Couldn't mark events with updated exceptions as dirty", e); } @@ -540,9 +545,6 @@ public class LocalCalendar extends LocalCollection { builder = builder .withValue(Events.CALENDAR_ID, id) - .withValue(entryColumnRemoteName(), event.getName()) - .withValue(entryColumnETag(), event.getETag()) - .withValue(entryColumnUID(), event.getUid()) .withValue(Events.ALL_DAY, event.isAllDay() ? 1 : 0) .withValue(Events.DTSTART, event.getDtStartInMillis()) .withValue(Events.EVENT_TIMEZONE, event.getDtStartTzID()) @@ -550,7 +552,26 @@ public class LocalCalendar extends LocalCollection { .withValue(Events.GUESTS_CAN_INVITE_OTHERS, 1) .withValue(Events.GUESTS_CAN_MODIFY, 1) .withValue(Events.GUESTS_CAN_SEE_GUESTS, 1); - + + RecurrenceId recurrenceId = event.getRecurrenceId(); + if (recurrenceId == null) { + // this event is a "master event" (not an exception) + builder = builder + .withValue(entryColumnRemoteName(), event.getName()) + .withValue(entryColumnETag(), event.getETag()) + .withValue(entryColumnUID(), event.getUid()); + } else { + // this event is an exception for a recurring event -> calculate + // 1. ORIGINAL_INSTANCE_TIME when the original instance would have occured (ms UTC) + // 2. ORIGINAL_ALL_DAY was the original instance an all-day event? + builder = builder.withValue(Events.ORIGINAL_SYNC_ID, event.getName()); + + // ORIGINAL_INSTANCE_TIME and ORIGINAL_ALL_DAY is set in buildExceptions. + // It's not possible to use only the RECURRENCE-ID to calculate + // ORIGINAL_INSTANCE_TIME and ORIGINAL_ALL_DAY because iCloud sends DATE-TIME + // RECURRENCE-IDs even if the original event is an all-day event. + } + boolean recurring = false; if (event.getRrule() != null) { recurring = true; @@ -612,8 +633,13 @@ public class LocalCalendar extends LocalCollection { @Override protected void addDataRows(Resource resource, long localID, int backrefIdx) { Event event = (Event)resource; + // add exceptions + for (Event exception : event.getExceptions()) + pendingOperations.add(buildException(newDataInsertBuilder(Events.CONTENT_URI, Events.ORIGINAL_ID, localID, backrefIdx), event, exception).build()); + // add attendees for (Attendee attendee : event.getAttendees()) pendingOperations.add(buildAttendee(newDataInsertBuilder(Attendees.CONTENT_URI, Attendees.EVENT_ID, localID, backrefIdx), attendee).build()); + // add reminders for (VAlarm alarm : event.getAlarms()) pendingOperations.add(buildReminder(newDataInsertBuilder(Reminders.CONTENT_URI, Reminders.EVENT_ID, localID, backrefIdx), alarm).build()); } @@ -621,14 +647,54 @@ public class LocalCalendar extends LocalCollection { @Override protected void removeDataRows(Resource resource) { Event event = (Event)resource; + // delete exceptions + pendingOperations.add(ContentProviderOperation.newDelete(entriesURI()) + .withSelection(Events.ORIGINAL_ID + "=?", + new String[] { String.valueOf(event.getLocalID()) }).build()); + // delete attendees pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Attendees.CONTENT_URI)) .withSelection(Attendees.EVENT_ID + "=?", new String[] { String.valueOf(event.getLocalID()) }).build()); + // delete reminders pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Reminders.CONTENT_URI)) .withSelection(Reminders.EVENT_ID + "=?", new String[] { String.valueOf(event.getLocalID()) }).build()); } + + protected Builder buildException(Builder builder, Event master, Event exception) { + buildEntry(builder, exception); + builder.withValue(Events.ORIGINAL_SYNC_ID, exception.getName()); + + // Some servers (iCloud, for instance) return RECURRENCE-ID with DATE-TIME even if + // the original event is an all-day event. Workaround: determine value of ORIGINAL_ALL_DAY + // by original event type (all-day or not) and not by whether RECURRENCE-ID is DATE or DATE-TIME. + + RecurrenceId recurrenceId = exception.getRecurrenceId(); + Date date = recurrenceId.getDate(); + + boolean originalAllDay = master.isAllDay(); + long originalInstanceTime; + + if (originalAllDay && date instanceof DateTime) { + String value = recurrenceId.getValue(); + if (value.matches("^\\d{8}T\\d{6}$")) + try { + // no "Z" at the end indicates "local" time + // so this is a "local" time, but it should be a ical4j Date without time + date = new Date(value.substring(0, 8)); + } catch (ParseException e) { + Log.e(TAG, "Couldn't parse DATE part of DATE-TIME RECURRENCE-ID", e); + } + } + originalInstanceTime = date.getTime(); + Log.i(TAG, "Original instance time: " + date.getTime()/1000); + + builder.withValue(Events.ORIGINAL_INSTANCE_TIME, originalInstanceTime); + builder.withValue(Events.ORIGINAL_ALL_DAY, originalAllDay ? 1 : 0); + + return builder; + } @SuppressLint("InlinedApi") protected Builder buildAttendee(Builder builder, Attendee attendee) { diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java index a9d07564..7710b269 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java @@ -350,7 +350,7 @@ public abstract class LocalCollection { .build(); } - protected Builder newDataInsertBuilder(Uri dataUri, String refFieldName, long raw_ref_id, Integer backrefIdx) { + protected Builder newDataInsertBuilder(Uri dataUri, String refFieldName, long raw_ref_id, int backrefIdx) { Builder builder = ContentProviderOperation.newInsert(syncAdapterURI(dataUri)); if (backrefIdx != -1) return builder.withValueBackReference(refFieldName, backrefIdx);