org.exfio.csyncdroid.resource.LocalCalendar.java Source code

Java tutorial

Introduction

Here is the source code for org.exfio.csyncdroid.resource.LocalCalendar.java

Source

/*
 * Copyright (C) 2015 Gerry Healy <nickel_chrome@exfio.org> and contributors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * This program is derived from DavDroid, Copyright (C) 2014 Richard Hirner, bitfire web engineering
 * DavDroid is distributed under the terms of the GNU Public License v3.0, https://github.com/bitfireAT/davdroid
 */
package org.exfio.csyncdroid.resource;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import biweekly.ICalDataType;
import biweekly.ICalVersion;
import biweekly.component.VAlarm;
import biweekly.io.ParseContext;
import biweekly.io.TimezoneInfo;
import biweekly.io.WriteContext;
import biweekly.io.scribe.property.DurationPropertyScribe;
import biweekly.io.scribe.property.ExceptionDatesScribe;
import biweekly.io.scribe.property.ExceptionRuleScribe;
import biweekly.io.scribe.property.RecurrenceDatesScribe;
import biweekly.io.scribe.property.RecurrenceRuleScribe;
import biweekly.parameter.CalendarUserType;
import biweekly.parameter.ICalParameters;
import biweekly.parameter.ParticipationStatus;
import biweekly.parameter.Related;
import biweekly.parameter.Role;
import biweekly.property.Attendee;
import biweekly.property.DurationProperty;
import biweekly.property.ExceptionDates;
import biweekly.property.ExceptionRule;
import biweekly.property.Organizer;
import biweekly.property.RecurrenceDates;
import biweekly.property.RecurrenceRule;
import biweekly.property.Status;
import biweekly.property.Trigger;
import biweekly.util.Duration;
import biweekly.util.Recurrence;

import lombok.Cleanup;
import lombok.Getter;

import org.apache.commons.lang.ArrayUtils;
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;

import com.google.ical.values.DateValue;
import com.google.ical.values.RDateList;
import com.google.ical.values.RRule;
import com.google.ical.values.WeekdayNum;

import org.exfio.csyncdroid.syncadapter.AccountSettings;
import org.exfio.csyncdroid.syncadapter.ServerInfo;
import org.exfio.weave.util.SQLUtils;

public class LocalCalendar extends LocalCollection<Event> {
    private static final String TAG = "csyncdroid.LocalCal";

    //TODO - use CTAG or other content provider field for modified date?
    protected static String COLLECTION_COLUMN_CTAG = Calendars.CAL_SYNC1;

    protected String contentAuthority = "com.android.calendar";

    @Getter
    protected long id;
    @Getter
    protected String timeZone;
    protected AccountSettings accountSettings;

    /* 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 entryColumnRemoteId() {
        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;
    }

    public LocalCalendar(Account account, ContentProviderClient providerClient, AccountSettings accountSettings,
            int id, String timeZone) {
        super(account, providerClient);
        this.accountSettings = accountSettings;
        this.id = id;
        this.timeZone = timeZone;
    }

    /* class methods, constructor */

    @SuppressLint("InlinedApi")
    public static void create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info)
            throws RemoteException {
        ContentProviderClient client = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY);

        //FIXME - change default colour
        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.getCollection());
        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());
        client.insert(calendarsURI(account), values);
    }

    public static LocalCalendar[] findAll(Account account, ContentProviderClient providerClient,
            AccountSettings accountSettings) throws RemoteException {
        @Cleanup
        Cursor cursor = providerClient.query(calendarsURI(account),
                new String[] { Calendars._ID, Calendars.NAME, COLLECTION_COLUMN_CTAG,
                        Calendars.CALENDAR_TIME_ZONE },
                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, accountSettings, cursor.getInt(0),
                    cursor.getString(3)));
        return calendars.toArray(new LocalCalendar[0]);
    }

    /* collection operations */

    public void setCTag(String cTag) {
        pendingOperations.add(ContentProviderOperation.newUpdate(ContentUris.withAppendedId(calendarsURI(), id))
                .withValue(COLLECTION_COLUMN_CTAG, cTag).build());
    }

    /* create/update/delete */

    public Event newResource(long localID, String resourceName, String eTag) {
        return new Event(localID, resourceName, eTag);
    }

    public void deleteAllExceptRemoteIds(String[] preserveIds) {
        String where;

        if (preserveIds.length != 0) {
            where = entryColumnRemoteId() + " NOT IN (" + SQLUtils.quoteArray(preserveIds) + ")";
        } else
            where = entryColumnRemoteId() + " IS NOT NULL";

        Builder builder = ContentProviderOperation.newDelete(entriesURI()).withSelection(
                entryColumnParentID() + "=? AND (" + where + ")", new String[] { String.valueOf(id) });
        pendingOperations.add(builder.withYieldAllowed(true).build());
    }

    public void deleteAllExceptUIDs(String[] preserveUids) {
        String where;

        if (preserveUids.length != 0) {
            where = entryColumnUID() + " NOT IN (" + SQLUtils.quoteArray(preserveUids) + ")";
        } else
            where = entryColumnUID() + " 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 */

    protected String resourceToString(Resource resource) throws LocalStorageException {

        String output = "Event:";

        @Cleanup
        Cursor cursor = null;
        try {
            cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), resource.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, /* 20 */ entryColumnID(), entryColumnRemoteId() },
                    null, null, null);
        } catch (RemoteException e) {
            throw new LocalStorageException("Couldn't find event (" + resource.getLocalID() + ")" + e.getMessage());
        }

        if (cursor != null && cursor.moveToNext()) {

            output += "\nLocalId: " + cursor.getString(20);
            output += "\nRemoteId: " + cursor.getString(21);
            output += "\nUID: " + cursor.getString(17);
            output += "\nTITLE: " + cursor.getString(0);
            output += "\nEVENT_LOCATION: " + cursor.getString(1);
            output += "\nDESCRIPTION: " + cursor.getString(2);

            boolean allDay = cursor.getInt(7) != 0;
            long tsStart = cursor.getLong(3);
            long tsEnd = cursor.getLong(4);
            String duration = cursor.getString(18);
            String tzStart = cursor.getString(5);
            String tzEnd = cursor.getString(6);

            Date dtStart = new Date(tsStart);
            Date dtEnd = new Date(tsEnd);

            output += "\nALL_DAY: " + allDay;
            output += "\nDTSTART: " + dtStart.toString() + "(" + tsStart + ")";
            output += "\nEVENT_TIMEZONE: " + tzStart;
            output += "\nDTEND: " + dtEnd.toString() + "(" + tsEnd + ")";
            output += "\nEVENT_END_TIMEZONE: " + tzEnd;
            output += "\nDURATION: " + duration;
            output += "\nSTATUS: " + cursor.getString(8);
            output += "\nACCESS_LEVEL: " + cursor.getString(9);
            output += "\nRRULE: " + cursor.getString(10);
            output += "\nRDATE: " + cursor.getString(11);
            output += "\nEXRULE: " + cursor.getString(12);
            output += "\nEXDATE: " + cursor.getString(13);
            output += "\nHAS_ATTENDEE_DATA: " + cursor.getString(14);
            output += "\nORGANIZER: " + cursor.getString(15);
            output += "\nSELF_ATTENDEE_STATUS: " + cursor.getString(16);
            output += "\nAVAILABILITY: " + cursor.getString(19);
        } else {
            throw new LocalStorageException("Invalid cursor while fetching event (" + resource.getLocalID() + ")");
        }
        return output;
    }

    @Override
    public void populate(Resource resource) throws LocalStorageException {
        Log.d(TAG, "populate()");

        Log.d(TAG, resourceToString(resource));

        Event e = (Event) resource;

        ICalParameters icalParams = new ICalParameters();
        ParseContext parseContext = new ParseContext();

        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 = cursor.getString(5);
                TimeZone tz = TimeZone.getTimeZone(tzId);

                if (allDay) {
                    e.setDtStart(new Date(tsStart), false);

                    // provide only DTEND and not DURATION for all-day events
                    if (tsEnd == 0) {
                        Duration dur = Duration.parse(duration);
                        Date dEnd = dur.add(new Date(tsStart));
                        tsEnd = dEnd.getTime();
                    }
                    e.setDtEnd(new Date(tsEnd), false);

                } else {
                    //FIXME - Dates are stored as longs, i.e. epoch, hence not clear why timezone property is needed
                    // 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(new Date(tsStart), true);
                    Date tmpStart = new Date(tsStart);
                    e.setDtStart(tmpStart, true);
                    if (tsEnd != 0) {
                        //e.setDtEnd(new Date(tsEnd), true);
                        Date tmpEnd = new Date(tsEnd);
                        e.setDtEnd(tmpEnd, true);
                    } else if (!StringUtils.isEmpty(duration)) {
                        e.setDuration(Duration.parse(duration));
                    }
                }

                // recurrence
                try {
                    String strRRule = cursor.getString(10);
                    if (!StringUtils.isEmpty(strRRule)) {
                        //e.setRrule(parseRecurrenceRule(strRRule, tz));
                        RecurrenceRuleScribe rRuleReader = new RecurrenceRuleScribe();
                        e.setRrule(rRuleReader.parseText(strRRule, ICalDataType.RECUR, icalParams, parseContext));
                    }

                    String strRDate = cursor.getString(11);
                    if (!StringUtils.isEmpty(strRDate)) {
                        //e.setRdate(parseRecurrenceDates(strRDate, tz));
                        RecurrenceDatesScribe rDatesReader = new RecurrenceDatesScribe();
                        List<RecurrenceDates> listRDates = new ArrayList<RecurrenceDates>();
                        listRDates
                                .add(rDatesReader.parseText(strRDate, ICalDataType.DATE, icalParams, parseContext));
                        e.setRdate(listRDates);
                    }

                    String strExRule = cursor.getString(12);
                    if (!StringUtils.isEmpty(strExRule)) {
                        //e.setExrule(parseExceptionRule(strExRule, tz));
                        ExceptionRuleScribe exRuleReader = new ExceptionRuleScribe();
                        List<ExceptionRule> listExRule = new ArrayList<ExceptionRule>();
                        listExRule.add(
                                exRuleReader.parseText(strExRule, ICalDataType.RECUR, icalParams, parseContext));
                        e.setExrule(listExRule);
                    }

                    String strExDate = cursor.getString(13);
                    if (!StringUtils.isEmpty(strExDate)) {
                        // ignored, see https://code.google.com/p/android/issues/detail?id=21426
                        //e.setExdate(parseExceptionDates(strExDate, tz));
                        ExceptionDatesScribe exDatesReader = new ExceptionDatesScribe();
                        List<ExceptionDates> listExDates = new ArrayList<ExceptionDates>();
                        listExDates.add(
                                exDatesReader.parseText(strRDate, ICalDataType.DATE, icalParams, parseContext));
                        e.setExdate(listExDates);
                    }
                } catch (IllegalArgumentException ex) {
                    Log.w(TAG, "Invalid recurrence rules, ignoring", ex);
                }

                // status
                switch (cursor.getInt(8)) {
                case Events.STATUS_CONFIRMED:
                    e.setStatus(Status.confirmed());
                    break;
                case Events.STATUS_TENTATIVE:
                    e.setStatus(Status.tentative());
                    break;
                case Events.STATUS_CANCELED:
                    e.setStatus(Status.cancelled());
                }

                // availability
                e.setOpaque(cursor.getInt(19) != Events.AVAILABILITY_FREE);

                // attendees
                if (cursor.getInt(14) != 0) { // has attendees
                    //TODO - parse name from email assuming it is in rfc2822 format
                    e.setOrganizer(new Organizer(cursor.getString(15), cursor.getString(15)));
                    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()) {

            Attendee attendee = new Attendee(c.getString(1), c.getString(0));

            // type
            int type = c.getInt(2);
            attendee.setParameter("TYPE", (type == Attendees.TYPE_RESOURCE) ? "RESOURCE" : "NONE");

            // role
            int relationship = c.getInt(3);
            switch (relationship) {
            case Attendees.RELATIONSHIP_ORGANIZER:
                attendee.setRole(Role.CHAIR);
                break;
            case Attendees.RELATIONSHIP_ATTENDEE:
            case Attendees.RELATIONSHIP_PERFORMER:
            case Attendees.RELATIONSHIP_SPEAKER:
                attendee.setRole(Role.ATTENDEE);
                break;
            case Attendees.RELATIONSHIP_NONE:
                //No role
                break;
            default:
                //Ignore
            }

            // status
            switch (c.getInt(4)) {
            case Attendees.ATTENDEE_STATUS_INVITED:
                attendee.setParticipationStatus(ParticipationStatus.NEEDS_ACTION);
                break;
            case Attendees.ATTENDEE_STATUS_ACCEPTED:
                attendee.setParticipationStatus(ParticipationStatus.ACCEPTED);
                break;
            case Attendees.ATTENDEE_STATUS_DECLINED:
                attendee.setParticipationStatus(ParticipationStatus.DECLINED);
                break;
            case Attendees.ATTENDEE_STATUS_TENTATIVE:
                attendee.setParticipationStatus(ParticipationStatus.TENTATIVE);
                break;
            default:
                //Ignore
            }

            e.addAttendee(attendee);
        }
    }

    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()) {
            //Duration duration = new Duration.Builder().prior(true).minutes(c.getInt(0)).build();
            Duration duration = new Duration.Builder().minutes(c.getInt(0)).build();
            Trigger trigger = new Trigger(duration, Related.START);
            VAlarm alarm = VAlarm.display(trigger, e.getSummary());
            e.addAlarm(alarm);
        }
    }

    /* content builder methods */

    @Override
    protected Builder buildEntry(Builder builder, Resource resource) {
        Log.d(TAG, "buildEntry()");
        Event event = (Event) resource;

        Log.d(TAG, "dtstart:  " + event.getDtStart().getValue().toString());
        Log.d(TAG, "dtend:    " + (event.getDtEnd() == null ? null : event.getDtEnd().getValue().toString()));
        Log.d(TAG, "duration: " + (event.getDuration() == null ? null : event.getDuration().getValue().toString()));

        TimezoneInfo tzInfo = new TimezoneInfo();
        tzInfo.setDefaultTimeZone(TimeZone.getTimeZone(this.timeZone));
        WriteContext writeContext = new WriteContext(ICalVersion.V2_0, tzInfo);

        builder = builder.withValue(Events.CALENDAR_ID, id).withValue(entryColumnRemoteId(), event.getId())
                .withValue(entryColumnETag(), event.getETag()).withValue(entryColumnUID(), event.getUid())
                .withValue(Events.ALL_DAY, event.isAllDay() ? 1 : 0)
                .withValue(Events.DTSTART, event.getDtStart().getValue().getTime())
                .withValue(Events.EVENT_TIMEZONE, tzInfo.getTimeZoneToWriteIn(event.getDtStart()).getID())
                .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;

            RecurrenceRuleScribe rRuleWriter = new RecurrenceRuleScribe();

            builder = builder.withValue(Events.RRULE, rRuleWriter.writeText(event.getRrule(), writeContext));
        }
        if (event.getRdate() != null && event.getRdate().size() > 0) {
            recurring = true;

            RecurrenceDatesScribe rDateWriter = new RecurrenceDatesScribe();

            for (RecurrenceDates rDate : event.getRdate()) {
                builder = builder.withValue(Events.RDATE, rDateWriter.writeText(rDate, writeContext));
            }
        }
        if (event.getExrule() != null) {

            ExceptionRuleScribe exRuleWriter = new ExceptionRuleScribe();

            for (ExceptionRule exRule : event.getExrule()) {
                builder = builder.withValue(Events.EXRULE, exRuleWriter.writeText(exRule, writeContext));
            }
        }
        if (event.getExdate() != null && event.getExdate().size() > 0) {

            ExceptionDatesScribe exDateWriter = new ExceptionDatesScribe();

            for (ExceptionDates exDate : event.getExdate()) {
                builder = builder.withValue(Events.EXDATE, exDateWriter.writeText(exDate, writeContext));
            }
        }

        // 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
            DurationPropertyScribe durWriter = new DurationPropertyScribe();

            DurationProperty duration;
            if (event.getDuration() != null) {
                duration = event.getDuration();
            } else {
                Duration dur = new Duration.Builder().seconds(
                        (int) ((event.getDtEnd().getValue().getTime() - event.getDtStart().getValue().getTime())
                                / 1000))
                        .build();
                duration = new DurationProperty(dur);
            }

            builder = builder.withValue(Events.DURATION, durWriter.writeText(duration, writeContext));
        } else {
            builder = builder.withValue(Events.DTEND, event.getDtEnd().getValue().getTime())
                    .withValue(Events.EVENT_END_TIMEZONE, tzInfo.getTimeZoneToWriteIn(event.getDtEnd()).getID());
        }

        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().getEmail() != null) {
            builder = builder.withValue(Events.ORGANIZER, event.getOrganizer().getEmail());
        }

        Status status = event.getStatus();
        if (status != null) {
            int statusCode = Events.STATUS_TENTATIVE;
            if (status == Status.confirmed())
                statusCode = Events.STATUS_CONFIRMED;
            else if (status == Status.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) {
        String email = attendee.getEmail();

        String cn = attendee.getCommonName();
        if (cn != null)
            builder = builder.withValue(Attendees.ATTENDEE_NAME, cn);

        int type = Attendees.TYPE_NONE;
        if (attendee.getCalendarUserType() == CalendarUserType.RESOURCE)
            type = Attendees.TYPE_RESOURCE;
        else {
            int relationship;
            if (attendee.getRole() == Role.CHAIR)
                relationship = Attendees.RELATIONSHIP_ORGANIZER;
            else {
                relationship = Attendees.RELATIONSHIP_ATTENDEE;
            }
            builder = builder.withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship);
        }

        int status = Attendees.ATTENDEE_STATUS_NONE;
        ParticipationStatus partStat = attendee.getParticipationStatus();
        if (partStat == null || partStat == ParticipationStatus.NEEDS_ACTION)
            status = Attendees.ATTENDEE_STATUS_INVITED;
        else if (partStat == ParticipationStatus.ACCEPTED)
            status = Attendees.ATTENDEE_STATUS_ACCEPTED;
        else if (partStat == ParticipationStatus.DECLINED)
            status = Attendees.ATTENDEE_STATUS_DECLINED;
        else if (partStat == ParticipationStatus.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;

        if (alarm.getTrigger() != null && alarm.getTrigger().getDuration() != null)
            //minutes = duration.getDays() * 24*60 + duration.getHours()*60 + duration.getMinutes();
            minutes = (int) (alarm.getTrigger().getDuration().toMillis() / 60000);

        Log.d(TAG, "Adding alarm " + minutes + " min before");

        return builder.withValue(Reminders.METHOD, Reminders.METHOD_ALERT).withValue(Reminders.MINUTES, minutes);
    }

    protected RecurrenceRule parseRecurrenceRule(String property, TimeZone tz) throws ParseException {
        return new RecurrenceRule(parseRecurrence(property, tz));
    }

    protected List<ExceptionRule> parseExceptionRule(String property, TimeZone tz) throws ParseException {
        ExceptionRule exRule = new ExceptionRule(parseRecurrence(property, tz));

        List<ExceptionRule> listExRule = new ArrayList<ExceptionRule>();
        listExRule.add(exRule);
        return listExRule;
    }

    protected Recurrence parseRecurrence(String property, TimeZone tz) throws ParseException {
        RRule tmpRecRule = new RRule(property);

        Recurrence.Frequency freq = null;
        switch (tmpRecRule.getFreq()) {
        case DAILY:
            freq = Recurrence.Frequency.DAILY;
            break;
        case HOURLY:
            freq = Recurrence.Frequency.HOURLY;
            break;
        case MINUTELY:
            freq = Recurrence.Frequency.MINUTELY;
            break;
        case MONTHLY:
            freq = Recurrence.Frequency.MONTHLY;
            break;
        case SECONDLY:
            freq = Recurrence.Frequency.SECONDLY;
            break;
        case WEEKLY:
            freq = Recurrence.Frequency.WEEKLY;
            break;
        case YEARLY:
            freq = Recurrence.Frequency.YEARLY;
            break;
        default:
            //Fail quietly
        }

        List<Recurrence.DayOfWeek> dows = new ArrayList<Recurrence.DayOfWeek>();
        for (WeekdayNum wday : tmpRecRule.getByDay()) {
            switch (wday.wday) {
            case MO:
                dows.add(Recurrence.DayOfWeek.MONDAY);
                break;
            case TU:
                dows.add(Recurrence.DayOfWeek.TUESDAY);
                break;
            case WE:
                dows.add(Recurrence.DayOfWeek.WEDNESDAY);
                break;
            case TH:
                dows.add(Recurrence.DayOfWeek.THURSDAY);
                break;
            case FR:
                dows.add(Recurrence.DayOfWeek.FRIDAY);
                break;
            case SA:
                dows.add(Recurrence.DayOfWeek.SATURDAY);
                break;
            case SU:
                dows.add(Recurrence.DayOfWeek.SUNDAY);
                break;
            default:
                //Fail quietly
            }
        }

        Recurrence tmpRec = new Recurrence.Builder(freq).interval(tmpRecRule.getInterval())
                .count(tmpRecRule.getCount()).until(dateValueToDate(tmpRecRule.getUntil())).byDay(dows)
                .byHour(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getByHour()))))
                .byMinute(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getByMinute()))))
                .byMonth(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getByMonth()))))
                .byMonthDay(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getByMonthDay()))))
                .bySecond(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getBySecond()))))
                .byWeekNo(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getByWeekNo()))))
                .byYearDay(new ArrayList<Integer>(Arrays.asList(ArrayUtils.toObject(tmpRecRule.getByYearDay()))))
                .build();

        return tmpRec;
    }

    protected List<RecurrenceDates> parseRecurrenceDates(String property, TimeZone tz) throws ParseException {

        RDateList rDateList = new RDateList(property, tz);

        Calendar calUtc = Calendar.getInstance(TimeZone.getTimeZone("UTC"));

        RecurrenceDates recDates = new RecurrenceDates();

        for (DateValue rdate : rDateList.getDatesUtc()) {
            calUtc.clear();
            calUtc.set(rdate.year(), rdate.month(), rdate.day());
            recDates.addDate(calUtc.getTime());
        }

        List<RecurrenceDates> listRecDates = new ArrayList<RecurrenceDates>();
        listRecDates.add(recDates);

        return listRecDates;

    }

    protected List<ExceptionDates> parseExceptionDates(String property, TimeZone tz) throws ParseException {

        RDateList exDateList = new RDateList(property, tz);

        Calendar calUtc = Calendar.getInstance(TimeZone.getTimeZone("UTC"));

        ExceptionDates exDates = new ExceptionDates();

        for (DateValue exDate : exDateList.getDatesUtc()) {
            calUtc.clear();
            calUtc.set(exDate.year(), exDate.month(), exDate.day());
            exDates.addValue(calUtc.getTime());
        }

        List<ExceptionDates> listExDates = new ArrayList<ExceptionDates>();
        listExDates.add(exDates);

        return listExDates;
    }

    /* private helper methods */

    protected Date dateValueToDate(DateValue dateValue) {
        Calendar calUtc = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
        calUtc.clear();
        calUtc.set(dateValue.year(), dateValue.month(), dateValue.day());
        return calUtc.getTime();
    }

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

    @Override
    public Double getModifiedTime() {
        return accountSettings.getModifiedTime(contentAuthority);
    }

    @Override
    public void setModifiedTime(Double modified) {
        accountSettings.setModifiedTime(contentAuthority, modified);
    }
}