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 a5913b92..deb53d44 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/Event.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/Event.java @@ -41,6 +41,7 @@ import net.fortuna.ical4j.model.property.Organizer; import net.fortuna.ical4j.model.property.ProdId; 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.model.property.Summary; import net.fortuna.ical4j.model.property.Transp; @@ -75,32 +76,29 @@ public class Event extends Resource { public final static String MIME_TYPE = "text/calendar"; private final static TimeZoneRegistry tzRegistry = new DefaultTimeZoneRegistryFactory().createRegistry(); + + @Getter @Setter protected RecurrenceId recurrenceId; + + @Getter @Setter protected String summary, location, description; - @Getter @Setter private String summary, location, description; + @Getter protected DtStart dtStart; + @Getter protected DtEnd dtEnd; + @Getter @Setter protected Duration duration; + @Getter @Setter protected RDate rdate; + @Getter @Setter protected RRule rrule; + @Getter @Setter protected ExDate exdate; + @Getter @Setter protected ExRule exrule; + @Getter protected List exceptions = new LinkedList<>(); + + @Getter @Setter protected Boolean forPublic; + @Getter @Setter protected Status status; - @Getter private DtStart dtStart; - @Getter private DtEnd dtEnd; - @Getter @Setter private Duration duration; - @Getter @Setter private RDate rdate; - @Getter @Setter private RRule rrule; - @Getter @Setter private ExDate exdate; - @Getter @Setter private ExRule exrule; + @Getter @Setter protected boolean opaque; - @Getter @Setter private Boolean forPublic; - @Getter @Setter private Status status; - - @Getter @Setter private boolean opaque; - - @Getter @Setter private Organizer organizer; - @Getter private List attendees = new LinkedList(); - public void addAttendee(Attendee attendee) { - attendees.add(attendee); - } - - @Getter private List alarms = new LinkedList(); - public void addAlarm(VAlarm alarm) { - alarms.add(alarm); - } + @Getter @Setter protected Organizer organizer; + @Getter protected List attendees = new LinkedList(); + + @Getter protected List alarms = new LinkedList(); static { CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_UNFOLDING, true); @@ -215,51 +213,18 @@ public class Event extends Resource { net.fortuna.ical4j.model.Calendar ical = new net.fortuna.ical4j.model.Calendar(); ical.getProperties().add(Version.VERSION_2_0); ical.getProperties().add(new ProdId("-//bitfire web engineering//DAVdroid " + Constants.APP_VERSION + " (ical4j 1.0.x)//EN")); - - VEvent event = new VEvent(); - PropertyList props = event.getProperties(); - - if (uid != null) - props.add(new Uid(uid)); - - 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 (organizer != null) - props.add(organizer); - props.addAll(attendees); - - if (forPublic != null) - event.getProperties().add(forPublic ? Clazz.PUBLIC : Clazz.PRIVATE); - - event.getAlarms().addAll(alarms); - - props.add(new LastModified()); - ical.getComponents().add(event); + + // "main event" (without exceptions) + ComponentList components = ical.getComponents(); + VEvent mainEvent = toVEvent(this); + components.add(mainEvent); + + // recurrence exceptions + for (Event exception : exceptions) { + VEvent vException = toVEvent(exception); + vException.getProperties().add(mainEvent.getProperty(Property.UID)); + components.add(vException); + } // add VTIMEZONE components net.fortuna.ical4j.model.TimeZone @@ -280,6 +245,55 @@ public class Event extends Resource { return os; } + protected static VEvent toVEvent(Event e) { + 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) + props.add(Transp.TRANSPARENT); + + if (e.organizer != null) + props.add(e.organizer); + props.addAll(e.attendees); + + if (e.forPublic != null) + event.getProperties().add(e.forPublic ? Clazz.PUBLIC : Clazz.PRIVATE); + + event.getAlarms().addAll(e.alarms); + + props.add(new LastModified()); + return event; + } + public long getDtStartInMillis() { return dtStart.getDate().getTime(); 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 8edfdf8d..e7d8ec4d 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java @@ -29,6 +29,8 @@ import android.provider.CalendarContract.Reminders; import android.provider.ContactsContract; import android.util.Log; +import net.fortuna.ical4j.model.Date; +import net.fortuna.ical4j.model.DateTime; import net.fortuna.ical4j.model.Dur; import net.fortuna.ical4j.model.Parameter; import net.fortuna.ical4j.model.ParameterList; @@ -47,6 +49,7 @@ import net.fortuna.ical4j.model.property.ExRule; import net.fortuna.ical4j.model.property.Organizer; 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 org.apache.commons.lang.StringUtils; @@ -172,6 +175,7 @@ public class LocalCalendar extends LocalCollection { super(account, providerClient); this.id = id; this.url = url; + sqlFilter = "ORIGINAL_ID IS NULL"; } @@ -210,18 +214,20 @@ public class LocalCalendar extends LocalCollection { } public void deleteAllExceptRemoteNames(Resource[] remoteResources) { - String where; + String where = entryColumnParentID() + "=?"; if (remoteResources.length != 0) { List sqlFileNames = new LinkedList(); for (Resource res : remoteResources) sqlFileNames.add(DatabaseUtils.sqlEscapeString(res.getName())); - where = entryColumnRemoteName() + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ")"; + where += " AND " + entryColumnRemoteName() + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ")"; } else - where = entryColumnRemoteName() + " IS NOT NULL"; - + where += " AND " + entryColumnRemoteName() + " IS NOT NULL"; + if (sqlFilter != null) + where += " AND (" + sqlFilter + ")"; + Builder builder = ContentProviderOperation.newDelete(entriesURI()) - .withSelection(entryColumnParentID() + "=? AND (" + where + ")", new String[] { String.valueOf(id) }); + .withSelection(where, new String[] { String.valueOf(id) }); pendingOperations.add(builder .withYieldAllowed(true) .build()); @@ -229,7 +235,6 @@ public class LocalCalendar extends LocalCollection { /* methods for populating the data object from the content provider */ - @Override public void populate(Resource resource) throws LocalStorageException { @@ -243,7 +248,8 @@ public class LocalCalendar extends LocalCollection { /* 8 */ Events.STATUS, Events.ACCESS_LEVEL, /* 10 */ Events.RRULE, Events.RDATE, Events.EXRULE, Events.EXDATE, /* 14 */ Events.HAS_ATTENDEE_DATA, Events.ORGANIZER, Events.SELF_ATTENDEE_STATUS, - /* 17 */ entryColumnUID(), Events.DURATION, Events.AVAILABILITY + /* 17 */ entryColumnUID(), Events.DURATION, Events.AVAILABILITY, + /* 20 */ Events.ORIGINAL_ALL_DAY, Events.ORIGINAL_INSTANCE_TIME }, null, null, null); if (cursor != null && cursor.moveToNext()) { e.setUid(cursor.getString(17)); @@ -311,7 +317,21 @@ public class LocalCalendar extends LocalCollection { } catch (IllegalArgumentException ex) { Log.w(TAG, "Invalid recurrence rules, ignoring", ex); } - + + // recurrence exceptions + if (!cursor.isNull(21)) { + long originalInstanceTime = cursor.getLong(21); + boolean originalAllDay = cursor.getInt(20) != 0; + Date originalDate = originalAllDay ? + new Date(originalInstanceTime) : + new DateTime(originalInstanceTime); + if (originalDate instanceof DateTime) + ((DateTime)originalDate).setUtc(true); + e.setRecurrenceId(new RecurrenceId(originalDate)); + } else + // this event may have exceptions + populateExceptions(e); + // status switch (cursor.getInt(8)) { case Events.STATUS_CONFIRMED: @@ -355,15 +375,35 @@ public class LocalCalendar extends LocalCollection { } } - + void populateExceptions(Event e) throws RemoteException { + Uri exceptionsUri = Events.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + @Cleanup Cursor c = providerClient.query(exceptionsUri, new String[] { + /* 0 */ Events._ID, entryColumnRemoteName() + }, Events.ORIGINAL_ID + "=?", new String[] { String.valueOf(e.getLocalID()) }, null); + while (c != null && c.moveToNext()) { + long exceptionId = c.getLong(0); + String exceptionRemoteName = c.getString(1); + Log.i(TAG, "Found exception ID " + exceptionId + " of original ID " + e.getLocalID()); + try { + Event exception = new Event(exceptionId, exceptionRemoteName, null); + populate(exception); + e.getExceptions().add(exception); + } catch (LocalStorageException ex) { + Log.e(TAG, "Couldn't find exception details, ignoring"); + } + } + } + void populateAttendees(Event e) throws RemoteException { Uri attendeesUri = Attendees.CONTENT_URI.buildUpon() .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") .build(); - @Cleanup Cursor c = providerClient.query(attendeesUri, new String[] { + @Cleanup Cursor c = providerClient.query(attendeesUri, new String[]{ /* 0 */ Attendees.ATTENDEE_EMAIL, Attendees.ATTENDEE_NAME, Attendees.ATTENDEE_TYPE, /* 3 */ Attendees.ATTENDEE_RELATIONSHIP, Attendees.STATUS - }, Attendees.EVENT_ID + "=?", new String[] { String.valueOf(e.getLocalID()) }, null); + }, Attendees.EVENT_ID + "=?", new String[]{String.valueOf(e.getLocalID())}, null); while (c != null && c.moveToNext()) { try { Attendee attendee = new Attendee(new URI("mailto", c.getString(0), null)); @@ -408,13 +448,13 @@ public class LocalCalendar extends LocalCollection { break; } - e.addAttendee(attendee); + e.getAttendees().add(attendee); } catch (URISyntaxException ex) { Log.e(TAG, "Couldn't parse attendee information, ignoring", ex); } } } - + void populateReminders(Event e) throws RemoteException { // reminders Uri remindersUri = Reminders.CONTENT_URI.buildUpon() @@ -435,7 +475,7 @@ public class LocalCalendar extends LocalCollection { props.add(Action.DISPLAY); props.add(new Description(e.getSummary())); } - e.addAlarm(alarm); + e.getAlarms().add(alarm); } } 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 9f2e28d0..a3c0b4a1 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java @@ -32,7 +32,7 @@ import lombok.Cleanup; * @param Subtype of Resource that can be stored in the collection */ public abstract class LocalCollection { - private static final String TAG = "davdroid.LocalCollection"; + private static final String TAG = "davdroid.Collection"; protected Account account; protected ContentProviderClient providerClient; @@ -66,6 +66,9 @@ public abstract class LocalCollection { /** column name of an entry's UID */ abstract protected String entryColumnUID(); + + /** SQL filter expression */ + String sqlFilter; LocalCollection(Account account, ContentProviderClient providerClient) { @@ -97,6 +100,8 @@ public abstract class LocalCollection { String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NULL"; if (entryColumnParentID() != null) where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId()); + if (sqlFilter != null) + where += " AND (" + sqlFilter + ")"; try { @Cleanup Cursor cursor = providerClient.query(entriesURI(), new String[] { entryColumnID() }, @@ -136,6 +141,8 @@ public abstract class LocalCollection { String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NOT NULL"; if (entryColumnParentID() != null) where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId()); + if (sqlFilter != null) + where += " AND (" + sqlFilter + ")"; try { @Cleanup Cursor cursor = providerClient.query(entriesURI(), new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() }, @@ -163,6 +170,8 @@ public abstract class LocalCollection { String where = entryColumnDeleted() + "=1"; if (entryColumnParentID() != null) where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId()); + if (sqlFilter != null) + where += " AND (" + sqlFilter + ")"; try { @Cleanup Cursor cursor = providerClient.query(entriesURI(), new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() }, @@ -191,7 +200,7 @@ public abstract class LocalCollection { public T findById(long localID, boolean populate) throws LocalStorageException { try { @Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), localID), - new String[] { entryColumnRemoteName(), entryColumnETag() }, null, null, null); + new String[] { entryColumnRemoteName(), entryColumnETag() }, sqlFilter, null, null); if (cursor != null && cursor.moveToNext()) { T resource = newResource(localID, cursor.getString(0), cursor.getString(1)); if (populate) @@ -214,10 +223,13 @@ public abstract class LocalCollection { * @throws LocalStorageException when the content provider couldn't be queried */ public T findByRemoteName(String remoteName, boolean populate) throws LocalStorageException { + String where = entryColumnRemoteName() + "=?"; + if (sqlFilter != null) + where += " AND (" + sqlFilter + ")"; try { @Cleanup Cursor cursor = providerClient.query(entriesURI(), new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() }, - entryColumnRemoteName() + "=?", new String[] { remoteName }, null); + where, new String[] { remoteName }, null); if (cursor != null && cursor.moveToNext()) { T resource = newResource(cursor.getLong(0), cursor.getString(1), cursor.getString(2)); if (populate)