1
0
mirror of https://github.com/etesync/android synced 2024-12-23 15:18:14 +00:00

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)
This commit is contained in:
Ricki Hirner 2015-04-28 13:50:06 +02:00
parent f6eee6c910
commit 495cdf7c7e
3 changed files with 171 additions and 64 deletions

View File

@ -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<net.fortuna.ical4j.model.TimeZone> 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);
if (uid != null)
props.add(new Uid(uid));
if (recurrenceId != null)
props.add(recurrenceId);
props.add(e.dtStart);
if (e.dtEnd != null)
props.add(e.dtEnd);
if (e.duration != null)
props.add(e.duration);
props.add(dtStart);
if (dtEnd != null)
props.add(dtEnd);
if (duration != null)
props.add(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 (rrule != null)
props.add(rrule);
if (rdate != null)
props.add(rdate);
if (exrule != null)
props.add(exrule);
if (exdate != null)
props.add(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 (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 (e.status != null)
props.add(e.status);
if (!e.opaque)
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;

View File

@ -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<Event> {
// 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<Event> {
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<Event> {
.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<Event> {
@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<Event> {
@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) {

View File

@ -350,7 +350,7 @@ public abstract class LocalCollection<T extends Resource> {
.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);