You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
etesync-android/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java

613 lines
22 KiB

/*******************************************************************************
* Copyright (c) 2014 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.davdroid.resource;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.ParseException;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.Cleanup;
import lombok.Getter;
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.component.VAlarm;
import net.fortuna.ical4j.model.parameter.Cn;
import net.fortuna.ical4j.model.parameter.CuType;
import net.fortuna.ical4j.model.parameter.PartStat;
import net.fortuna.ical4j.model.parameter.Role;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.ExDate;
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.Status;
import org.apache.commons.lang.StringUtils;
import android.accounts.Account;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.provider.ContactsContract;
import android.util.Log;
/**
* Represents a locally stored calendar, containing Events.
* Communicates with the Android Contacts Provider which uses an SQLite
* database to store the contacts.
*/
public class LocalCalendar extends LocalCollection<Event> {
private static final String TAG = "davdroid.LocalCalendar";
@Getter protected long id;
@Getter protected String url;
protected static String COLLECTION_COLUMN_CTAG = Calendars.CAL_SYNC1;
/* database fields */
@Override
protected Uri entriesURI() {
return syncAdapterURI(Events.CONTENT_URI);
}
protected String entryColumnAccountType() { return Events.ACCOUNT_TYPE; }
protected String entryColumnAccountName() { return Events.ACCOUNT_NAME; }
protected String entryColumnParentID() { return Events.CALENDAR_ID; }
protected String entryColumnID() { return Events._ID; }
protected String entryColumnRemoteName() { return Events._SYNC_ID; }
protected String entryColumnETag() { return Events.SYNC_DATA1; }
protected String entryColumnDirty() { return Events.DIRTY; }
protected String entryColumnDeleted() { return Events.DELETED; }
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
protected String entryColumnUID() {
return (android.os.Build.VERSION.SDK_INT >= 17) ?
Events.UID_2445 : Events.SYNC_DATA2;
}
/* class methods, constructor */
@SuppressLint("InlinedApi")
public static void create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws LocalStorageException {
ContentProviderClient client = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY);
if (client == null)
throw new LocalStorageException("No Calendar Provider found (Calendar app disabled?)");
int color = 0xFFC3EA6E; // fallback: "DAVdroid green"
if (info.getColor() != null) {
Pattern p = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?");
Matcher m = p.matcher(info.getColor());
if (m.find()) {
int color_rgb = Integer.parseInt(m.group(1), 16);
int color_alpha = m.group(2) != null ? (Integer.parseInt(m.group(2), 16) & 0xFF) : 0xFF;
color = (color_alpha << 24) | color_rgb;
}
}
ContentValues values = new ContentValues();
values.put(Calendars.ACCOUNT_NAME, account.name);
values.put(Calendars.ACCOUNT_TYPE, account.type);
values.put(Calendars.NAME, info.getURL());
values.put(Calendars.CALENDAR_DISPLAY_NAME, info.getTitle());
values.put(Calendars.CALENDAR_COLOR, color);
values.put(Calendars.OWNER_ACCOUNT, account.name);
values.put(Calendars.SYNC_EVENTS, 1);
values.put(Calendars.VISIBLE, 1);
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
if (info.isReadOnly())
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ);
else {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER);
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1);
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1);
}
if (android.os.Build.VERSION.SDK_INT >= 15) {
values.put(Calendars.ALLOWED_AVAILABILITY, Events.AVAILABILITY_BUSY + "," + Events.AVAILABILITY_FREE + "," + Events.AVAILABILITY_TENTATIVE);
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, Attendees.TYPE_NONE + "," + Attendees.TYPE_OPTIONAL + "," + Attendees.TYPE_REQUIRED + "," + Attendees.TYPE_RESOURCE);
}
if (info.getTimezone() != null)
values.put(Calendars.CALENDAR_TIME_ZONE, info.getTimezone());
Log.i(TAG, "Inserting calendar: " + values.toString() + " -> " + calendarsURI(account).toString());
try {
client.insert(calendarsURI(account), values);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
public static LocalCalendar[] findAll(Account account, ContentProviderClient providerClient) throws RemoteException {
@Cleanup Cursor cursor = providerClient.query(calendarsURI(account),
new String[] { Calendars._ID, Calendars.NAME },
Calendars.DELETED + "=0 AND " + Calendars.SYNC_EVENTS + "=1", null, null);
LinkedList<LocalCalendar> calendars = new LinkedList<LocalCalendar>();
while (cursor != null && cursor.moveToNext())
calendars.add(new LocalCalendar(account, providerClient, cursor.getInt(0), cursor.getString(1)));
return calendars.toArray(new LocalCalendar[0]);
}
public LocalCalendar(Account account, ContentProviderClient providerClient, long id, String url) throws RemoteException {
super(account, providerClient);
this.id = id;
this.url = url;
}
/* collection operations */
@Override
public String getCTag() throws LocalStorageException {
try {
@Cleanup Cursor c = providerClient.query(ContentUris.withAppendedId(calendarsURI(), id),
new String[] { COLLECTION_COLUMN_CTAG }, null, null, null);
if (c.moveToFirst()) {
return c.getString(0);
} else
throw new LocalStorageException("Couldn't query calendar CTag");
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public void setCTag(String cTag) throws LocalStorageException {
ContentValues values = new ContentValues(1);
values.put(COLLECTION_COLUMN_CTAG, cTag);
try {
providerClient.update(ContentUris.withAppendedId(calendarsURI(), id), values, null, null);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
/* create/update/delete */
public Event newResource(long localID, String resourceName, String eTag) {
return new Event(localID, resourceName, eTag);
}
public void deleteAllExceptRemoteNames(Resource[] remoteResources) {
String where;
if (remoteResources.length != 0) {
List<String> sqlFileNames = new LinkedList<String>();
for (Resource res : remoteResources)
sqlFileNames.add(DatabaseUtils.sqlEscapeString(res.getName()));
where = entryColumnRemoteName() + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ")";
} else
where = entryColumnRemoteName() + " IS NOT NULL";
Builder builder = ContentProviderOperation.newDelete(entriesURI())
.withSelection(entryColumnParentID() + "=? AND (" + where + ")", new String[] { String.valueOf(id) });
pendingOperations.add(builder
.withYieldAllowed(true)
.build());
}
/* methods for populating the data object from the content provider */
@Override
public void populate(Resource resource) throws LocalStorageException {
Event e = (Event)resource;
try {
@Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), e.getLocalID()),
new String[] {
/* 0 */ Events.TITLE, Events.EVENT_LOCATION, Events.DESCRIPTION,
/* 3 */ Events.DTSTART, Events.DTEND, Events.EVENT_TIMEZONE, Events.EVENT_END_TIMEZONE, Events.ALL_DAY,
/* 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
}, null, null, null);
if (cursor != null && cursor.moveToNext()) {
e.setUid(cursor.getString(17));
e.setSummary(cursor.getString(0));
e.setLocation(cursor.getString(1));
e.setDescription(cursor.getString(2));
boolean allDay = cursor.getInt(7) != 0;
long tsStart = cursor.getLong(3),
tsEnd = cursor.getLong(4);
String duration = cursor.getString(18);
String tzId = null;
if (allDay) {
e.setDtStart(tsStart, null);
// provide only DTEND and not DURATION for all-day events
if (tsEnd == 0) {
Dur dur = new Dur(duration);
java.util.Date dEnd = dur.getTime(new java.util.Date(tsStart));
tsEnd = dEnd.getTime();
}
e.setDtEnd(tsEnd, null);
} else {
// use the start time zone for the end time, too
// because apps like Samsung Planner allow the user to change "the" time zone but change the start time zone only
tzId = cursor.getString(5);
e.setDtStart(tsStart, tzId);
if (tsEnd != 0)
e.setDtEnd(tsEnd, tzId);
else if (!StringUtils.isEmpty(duration))
e.setDuration(new Duration(new Dur(duration)));
}
// recurrence
try {
String strRRule = cursor.getString(10);
if (!StringUtils.isEmpty(strRRule))
e.setRrule(new RRule(strRRule));
String strRDate = cursor.getString(11);
if (!StringUtils.isEmpty(strRDate)) {
RDate rDate = new RDate();
rDate.setValue(strRDate);
e.setRdate(rDate);
}
String strExRule = cursor.getString(12);
if (!StringUtils.isEmpty(strExRule)) {
ExRule exRule = new ExRule();
exRule.setValue(strExRule);
e.setExrule(exRule);
}
String strExDate = cursor.getString(13);
if (!StringUtils.isEmpty(strExDate)) {
// ignored, see https://code.google.com/p/android/issues/detail?id=21426
ExDate exDate = new ExDate();
exDate.setValue(strExDate);
e.setExdate(exDate);
}
} catch (ParseException ex) {
Log.w(TAG, "Couldn't parse recurrence rules, ignoring", ex);
} catch (IllegalArgumentException ex) {
Log.w(TAG, "Invalid recurrence rules, ignoring", ex);
}
// status
switch (cursor.getInt(8)) {
case Events.STATUS_CONFIRMED:
e.setStatus(Status.VEVENT_CONFIRMED);
break;
case Events.STATUS_TENTATIVE:
e.setStatus(Status.VEVENT_TENTATIVE);
break;
case Events.STATUS_CANCELED:
e.setStatus(Status.VEVENT_CANCELLED);
}
// availability
e.setOpaque(cursor.getInt(19) != Events.AVAILABILITY_FREE);
// attendees
if (cursor.getInt(14) != 0) { // has attendees
try {
e.setOrganizer(new Organizer(new URI("mailto", cursor.getString(15), null)));
} catch (URISyntaxException ex) {
Log.e(TAG, "Error when creating ORGANIZER URI, ignoring", ex);
}
populateAttendees(e);
}
// classification
switch (cursor.getInt(9)) {
case Events.ACCESS_CONFIDENTIAL:
case Events.ACCESS_PRIVATE:
e.setForPublic(false);
break;
case Events.ACCESS_PUBLIC:
e.setForPublic(true);
}
populateReminders(e);
} else
throw new RecordNotFoundException();
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
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[] {
/* 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);
while (c != null && c.moveToNext()) {
try {
Attendee attendee = new Attendee(new URI("mailto", c.getString(0), null));
ParameterList params = attendee.getParameters();
String cn = c.getString(1);
if (cn != null)
params.add(new Cn(cn));
// type
int type = c.getInt(2);
params.add((type == Attendees.TYPE_RESOURCE) ? CuType.RESOURCE : CuType.INDIVIDUAL);
// role
int relationship = c.getInt(3);
switch (relationship) {
case Attendees.RELATIONSHIP_ORGANIZER:
params.add(Role.CHAIR);
break;
case Attendees.RELATIONSHIP_ATTENDEE:
case Attendees.RELATIONSHIP_PERFORMER:
case Attendees.RELATIONSHIP_SPEAKER:
params.add((type == Attendees.TYPE_REQUIRED) ? Role.REQ_PARTICIPANT : Role.OPT_PARTICIPANT);
break;
case Attendees.RELATIONSHIP_NONE:
params.add(Role.NON_PARTICIPANT);
}
// status
switch (c.getInt(4)) {
case Attendees.ATTENDEE_STATUS_INVITED:
params.add(PartStat.NEEDS_ACTION);
break;
case Attendees.ATTENDEE_STATUS_ACCEPTED:
params.add(PartStat.ACCEPTED);
break;
case Attendees.ATTENDEE_STATUS_DECLINED:
params.add(PartStat.DECLINED);
break;
case Attendees.ATTENDEE_STATUS_TENTATIVE:
params.add(PartStat.TENTATIVE);
break;
}
e.addAttendee(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()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build();
@Cleanup Cursor c = providerClient.query(remindersUri, new String[] {
/* 0 */ Reminders.MINUTES, Reminders.METHOD
}, Reminders.EVENT_ID + "=?", new String[] { String.valueOf(e.getLocalID()) }, null);
while (c != null && c.moveToNext()) {
VAlarm alarm = new VAlarm(new Dur(0, 0, -c.getInt(0), 0));
PropertyList props = alarm.getProperties();
switch (c.getInt(1)) {
/*case Reminders.METHOD_EMAIL:
props.add(Action.EMAIL);
break;*/
default:
props.add(Action.DISPLAY);
props.add(new Description(e.getSummary()));
}
e.addAlarm(alarm);
}
}
/* content builder methods */
@Override
protected Builder buildEntry(Builder builder, Resource resource) {
Event event = (Event)resource;
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())
.withValue(Events.HAS_ATTENDEE_DATA, event.getAttendees().isEmpty() ? 0 : 1)
.withValue(Events.GUESTS_CAN_INVITE_OTHERS, 1)
.withValue(Events.GUESTS_CAN_MODIFY, 1)
.withValue(Events.GUESTS_CAN_SEE_GUESTS, 1);
boolean recurring = false;
if (event.getRrule() != null) {
recurring = true;
builder = builder.withValue(Events.RRULE, event.getRrule().getValue());
}
if (event.getRdate() != null) {
recurring = true;
builder = builder.withValue(Events.RDATE, event.getRdate().getValue());
}
if (event.getExrule() != null)
builder = builder.withValue(Events.EXRULE, event.getExrule().getValue());
if (event.getExdate() != null)
builder = builder.withValue(Events.EXDATE, event.getExdate().getValue());
// set either DTEND for single-time events or DURATION for recurring events
// because that's the way Android likes it (see docs)
if (recurring) {
// calculate DURATION from start and end date
Duration duration = new Duration(event.getDtStart().getDate(), event.getDtEnd().getDate());
builder = builder.withValue(Events.DURATION, duration.getValue());
} else {
builder = builder
.withValue(Events.DTEND, event.getDtEndInMillis())
.withValue(Events.EVENT_END_TIMEZONE, event.getDtEndTzID());
}
if (event.getSummary() != null)
builder = builder.withValue(Events.TITLE, event.getSummary());
if (event.getLocation() != null)
builder = builder.withValue(Events.EVENT_LOCATION, event.getLocation());
if (event.getDescription() != null)
builder = builder.withValue(Events.DESCRIPTION, event.getDescription());
if (event.getOrganizer() != null && event.getOrganizer().getCalAddress() != null) {
URI organizer = event.getOrganizer().getCalAddress();
if (organizer.getScheme() != null && organizer.getScheme().equalsIgnoreCase("mailto"))
builder = builder.withValue(Events.ORGANIZER, organizer.getSchemeSpecificPart());
}
Status status = event.getStatus();
if (status != null) {
int statusCode = Events.STATUS_TENTATIVE;
if (status == Status.VEVENT_CONFIRMED)
statusCode = Events.STATUS_CONFIRMED;
else if (status == Status.VEVENT_CANCELLED)
statusCode = Events.STATUS_CANCELED;
builder = builder.withValue(Events.STATUS, statusCode);
}
builder = builder.withValue(Events.AVAILABILITY, event.isOpaque() ? Events.AVAILABILITY_BUSY : Events.AVAILABILITY_FREE);
if (event.getForPublic() != null)
builder = builder.withValue(Events.ACCESS_LEVEL, event.getForPublic() ? Events.ACCESS_PUBLIC : Events.ACCESS_PRIVATE);
return builder;
}
@Override
protected void addDataRows(Resource resource, long localID, int backrefIdx) {
Event event = (Event)resource;
for (Attendee attendee : event.getAttendees())
pendingOperations.add(buildAttendee(newDataInsertBuilder(Attendees.CONTENT_URI, Attendees.EVENT_ID, localID, backrefIdx), attendee).build());
for (VAlarm alarm : event.getAlarms())
pendingOperations.add(buildReminder(newDataInsertBuilder(Reminders.CONTENT_URI, Reminders.EVENT_ID, localID, backrefIdx), alarm).build());
}
@Override
protected void removeDataRows(Resource resource) {
Event event = (Event)resource;
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Attendees.CONTENT_URI))
.withSelection(Attendees.EVENT_ID + "=?",
new String[] { String.valueOf(event.getLocalID()) }).build());
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Reminders.CONTENT_URI))
.withSelection(Reminders.EVENT_ID + "=?",
new String[] { String.valueOf(event.getLocalID()) }).build());
}
@SuppressLint("InlinedApi")
protected Builder buildAttendee(Builder builder, Attendee attendee) {
Uri member = Uri.parse(attendee.getValue());
String email = member.getSchemeSpecificPart();
Cn cn = (Cn)attendee.getParameter(Parameter.CN);
if (cn != null)
builder = builder.withValue(Attendees.ATTENDEE_NAME, cn.getValue());
int type = Attendees.TYPE_NONE;
CuType cutype = (CuType)attendee.getParameter(Parameter.CUTYPE);
if (cutype == CuType.RESOURCE)
type = Attendees.TYPE_RESOURCE;
else {
Role role = (Role)attendee.getParameter(Parameter.ROLE);
int relationship;
if (role == Role.CHAIR)
relationship = Attendees.RELATIONSHIP_ORGANIZER;
else {
relationship = Attendees.RELATIONSHIP_ATTENDEE;
if (role == Role.OPT_PARTICIPANT)
type = Attendees.TYPE_OPTIONAL;
else if (role == Role.REQ_PARTICIPANT)
type = Attendees.TYPE_REQUIRED;
}
builder = builder.withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship);
}
int status = Attendees.ATTENDEE_STATUS_NONE;
PartStat partStat = (PartStat)attendee.getParameter(Parameter.PARTSTAT);
if (partStat == null || partStat == PartStat.NEEDS_ACTION)
status = Attendees.ATTENDEE_STATUS_INVITED;
else if (partStat == PartStat.ACCEPTED)
status = Attendees.ATTENDEE_STATUS_ACCEPTED;
else if (partStat == PartStat.DECLINED)
status = Attendees.ATTENDEE_STATUS_DECLINED;
else if (partStat == PartStat.TENTATIVE)
status = Attendees.ATTENDEE_STATUS_TENTATIVE;
return builder
.withValue(Attendees.ATTENDEE_EMAIL, email)
.withValue(Attendees.ATTENDEE_TYPE, type)
.withValue(Attendees.ATTENDEE_STATUS, status);
}
protected Builder buildReminder(Builder builder, VAlarm alarm) {
int minutes = 0;
Dur duration;
if (alarm.getTrigger() != null && (duration = alarm.getTrigger().getDuration()) != null)
minutes = duration.getDays() * 24*60 + duration.getHours()*60 + duration.getMinutes();
Log.d(TAG, "Adding alarm " + minutes + " min before");
return builder
.withValue(Reminders.METHOD, Reminders.METHOD_ALERT)
.withValue(Reminders.MINUTES, minutes);
}
/* private helper methods */
protected static Uri calendarsURI(Account account) {
return Calendars.CONTENT_URI.buildUpon().appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true").build();
}
protected Uri calendarsURI() {
return calendarsURI(account);
}
}