blob: 396c1165d0c07d94029521a35e94a1a533661d23 [file] [log] [blame]
package com.android.exchange.service;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Entity;
import android.content.EntityIterator;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.EventsEntity;
import android.provider.CalendarContract.ExtendedProperties;
import android.provider.CalendarContract.Reminders;
import android.text.TextUtils;
import android.text.format.DateUtils;
import com.android.calendarcommon2.DateException;
import com.android.calendarcommon2.Duration;
import com.android.emailcommon.TrafficFlags;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.EmailContent.Message;
import com.android.emailcommon.provider.Mailbox;
import com.android.emailcommon.utility.Utility;
import com.android.exchange.Eas;
import com.android.exchange.R;
import com.android.exchange.adapter.AbstractSyncParser;
import com.android.exchange.adapter.CalendarSyncParser;
import com.android.exchange.adapter.Serializer;
import com.android.exchange.adapter.Tags;
import com.android.exchange.utility.CalendarUtilities;
import com.android.mail.utils.LogUtils;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.StringTokenizer;
import java.util.TimeZone;
import java.util.UUID;
/**
* Performs an Exchange Sync for a Calendar collection.
*/
public class EasCalendarSyncHandler extends EasSyncHandler {
private static final String TAG = Eas.LOG_TAG;
// TODO: Some constants are copied from CalendarSyncAdapter and are still used by the parser.
// These values need to stay in sync; when the parser is cleaned up, be sure to unify them.
/** Projection for getting a calendar id. */
private static final String[] CALENDAR_ID_PROJECTION = { Calendars._ID };
private static final int CALENDAR_ID_COLUMN = 0;
/** Content selection for getting a calendar id for an account. */
private static final String CALENDAR_SELECTION_ACCOUNT_AND_SYNC_ID =
Calendars.ACCOUNT_NAME + "=? AND " +
Calendars.ACCOUNT_TYPE + "=? AND " +
Calendars._SYNC_ID + "=?";
/** Content selection for getting a calendar id for an account. */
private static final String CALENDAR_SELECTION_ACCOUNT_AND_NO_SYNC =
Calendars.ACCOUNT_NAME + "=? AND " +
Calendars.ACCOUNT_TYPE + "=? AND " +
Calendars._SYNC_ID + " IS NULL";
/** The column used to track the timezone of the event. */
private static final String EVENT_SAVED_TIMEZONE_COLUMN = Events.SYNC_DATA1;
/** Used to keep track of exception vs. parent event dirtiness. */
private static final String EVENT_SYNC_MARK = Events.SYNC_DATA8;
/** The column used to track the Event version sequence number. */
private static final String EVENT_SYNC_VERSION = Events.SYNC_DATA4;
/** Projection for getting info about changed events. */
private static final String[] ORIGINAL_EVENT_PROJECTION = { Events.ORIGINAL_ID, Events._ID };
private static final int ORIGINAL_EVENT_ORIGINAL_ID_COLUMN = 0;
private static final int ORIGINAL_EVENT_ID_COLUMN = 1;
/** Content selection for dirty calendar events. */
private static final String DIRTY_EXCEPTION_IN_CALENDAR = Events.DIRTY + "=1 AND " +
Events.ORIGINAL_ID + " NOTNULL AND " + Events.CALENDAR_ID + "=?";
/** Where clause for updating dirty events. */
private static final String EVENT_ID_AND_CALENDAR_ID = Events._ID + "=? AND " +
Events.ORIGINAL_SYNC_ID + " ISNULL AND " + Events.CALENDAR_ID + "=?";
/** Content selection for dirty or marked top level events. */
private static final String DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR = "(" + Events.DIRTY +
"=1 OR " + EVENT_SYNC_MARK + "= 1) AND " + Events.ORIGINAL_ID + " ISNULL AND " +
Events.CALENDAR_ID + "=?";
/** Content selection for getting events when handling exceptions. */
private static final String ORIGINAL_EVENT_AND_CALENDAR = Events.ORIGINAL_SYNC_ID + "=? AND " +
Events.CALENDAR_ID + "=?";
private static final String CATEGORY_TOKENIZER_DELIMITER = "\\";
private static final String ATTENDEE_TOKENIZER_DELIMITER = CATEGORY_TOKENIZER_DELIMITER;
/** Used to indicate that upsyncs aren't allowed (we catch this in sendLocalChanges) */
private static final String EXTENDED_PROPERTY_UPSYNC_PROHIBITED = "upsyncProhibited";
private static final String EXTENDED_PROPERTY_USER_ATTENDEE_STATUS = "userAttendeeStatus";
private static final String EXTENDED_PROPERTY_ATTENDEES = "attendees";
private static final String EXTENDED_PROPERTY_CATEGORIES = "categories";
private final android.accounts.Account mAccountManagerAccount;
private final long mCalendarId;
// The following lists are populated as part of upsync, and handled during cleanup.
/** Ids of events that were deleted in this upsync. */
private final ArrayList<Long> mDeletedIdList = new ArrayList<Long>();
/** Ids of events that were changed in this upsync. */
private final ArrayList<Long> mUploadedIdList = new ArrayList<Long>();
/** Emails that need to be sent due to this upsync. */
private final ArrayList<Message> mOutgoingMailList = new ArrayList<Message>();
public EasCalendarSyncHandler(final Context context, final ContentResolver contentResolver,
final android.accounts.Account accountManagerAccount, final Account account,
final Mailbox mailbox, final Bundle syncExtras, final SyncResult syncResult) {
super(context, contentResolver, account, mailbox, syncExtras, syncResult);
mAccountManagerAccount = accountManagerAccount;
final Cursor c = mContentResolver.query(Calendars.CONTENT_URI, CALENDAR_ID_PROJECTION,
CALENDAR_SELECTION_ACCOUNT_AND_SYNC_ID,
new String[] {
mAccount.mEmailAddress,
Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE,
mailbox.mServerId,
}, null);
if (c == null) {
mCalendarId = -1;
} else {
try {
if (c.moveToFirst()) {
mCalendarId = c.getLong(CALENDAR_ID_COLUMN);
} else {
long id = -1;
// Check if we have a calendar for this account with no server Id. If so, it was
// synced with an older version of the sync adapter before serverId's were
// supported.
final Cursor c1 = mContentResolver.query(Calendars.CONTENT_URI,
CALENDAR_ID_PROJECTION,
CALENDAR_SELECTION_ACCOUNT_AND_NO_SYNC,
new String[] {
mAccount.mEmailAddress,
Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE,
}, null);
if (c1 != null) {
try {
if (c1.moveToFirst()) {
id = c1.getLong(CALENDAR_ID_COLUMN);
final ContentValues values = new ContentValues();
values.put(Calendars._SYNC_ID, mMailbox.mServerId);
mContentResolver.update(
ContentUris.withAppendedId(
asSyncAdapter(Calendars.CONTENT_URI), id),
values,
null, /* where */
null /* selectionArgs */);
}
} finally {
c1.close();
}
}
if (id >= 0) {
mCalendarId = id;
} else {
mCalendarId = CalendarUtilities.createCalendar(mContext, mContentResolver,
mAccount, mMailbox);
}
}
} finally {
c.close();
}
}
}
@Override
protected int getTrafficFlag() {
return TrafficFlags.DATA_CALENDAR;
}
/**
* Adds params to a {@link Uri} to indicate that the caller is a sync adapter, and to add the
* account info.
* @param uri The {@link Uri} to which to add params.
* @return The augmented {@link Uri}.
*/
private static Uri asSyncAdapter(final Uri uri, final String emailAddress) {
return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(Calendars.ACCOUNT_NAME, emailAddress)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)
.build();
}
/**
* Convenience wrapper to {@link #asSyncAdapter(android.net.Uri, String)}.
*/
private Uri asSyncAdapter(final Uri uri) {
return asSyncAdapter(uri, mAccount.mEmailAddress);
}
@Override
protected String getFolderClassName() {
return "Calendar";
}
@Override
protected AbstractSyncParser getParser(final InputStream is) throws IOException {
return new CalendarSyncParser(mContext, mContentResolver, is,
mMailbox, mAccount, mAccountManagerAccount, mCalendarId);
}
@Override
protected void setInitialSyncOptions(final Serializer s) throws IOException {
// Nothing to do for Calendar.
}
@Override
protected void setNonInitialSyncOptions(final Serializer s, int numWindows) throws IOException {
final int windowSize = numWindows * PIM_WINDOW_SIZE_CALENDAR;
if (windowSize > MAX_WINDOW_SIZE + PIM_WINDOW_SIZE_CALENDAR) {
throw new IOException("Max window size reached and still no data");
}
setPimSyncOptions(s, Eas.FILTER_2_WEEKS,
windowSize < MAX_WINDOW_SIZE ? windowSize : MAX_WINDOW_SIZE);
}
/**
* Find all dirty events for our calendar and mark their parents. Also delete any dirty events
* that have no parents.
* @param calendarIdString {@link #mCalendarId}, as a String.
* @param calendarIdArgument calendarIdString, in a String array.
*/
private void markParentsOfDirtyEvents(final String calendarIdString,
final String[] calendarIdArgument) {
// We've got to handle exceptions as part of the parent when changes occur, so we need
// to find new/changed exceptions and mark the parent dirty
final ArrayList<Long> orphanedExceptions = new ArrayList<Long>();
final Cursor c = mContentResolver.query(Events.CONTENT_URI,
ORIGINAL_EVENT_PROJECTION, DIRTY_EXCEPTION_IN_CALENDAR, calendarIdArgument, null);
if (c != null) {
try {
final ContentValues cv = new ContentValues(1);
// We use _sync_mark here to distinguish dirty parents from parents with dirty
// exceptions
cv.put(EVENT_SYNC_MARK, "1");
while (c.moveToNext()) {
// Mark the parents of dirty exceptions
final long parentId = c.getLong(ORIGINAL_EVENT_ORIGINAL_ID_COLUMN);
final int cnt = mContentResolver.update(asSyncAdapter(Events.CONTENT_URI), cv,
EVENT_ID_AND_CALENDAR_ID,
new String[] { Long.toString(parentId), calendarIdString });
// Keep track of any orphaned exceptions
if (cnt == 0) {
orphanedExceptions.add(c.getLong(ORIGINAL_EVENT_ID_COLUMN));
}
}
} finally {
c.close();
}
}
// Delete any orphaned exceptions
for (final long orphan : orphanedExceptions) {
LogUtils.i(TAG, "Deleted orphaned exception: %d", orphan);
mContentResolver.delete(asSyncAdapter(
ContentUris.withAppendedId(Events.CONTENT_URI, orphan)), null, null);
}
}
/**
* Get the version number of the current event, incrementing it if it's already there.
* @param entityValues The {@link ContentValues} for this event.
* @return The new version number for this event (i.e. 0 if it's a new event, or the old version
* number + 1).
*/
private static String getEntityVersion(final ContentValues entityValues) {
final String version = entityValues.getAsString(EVENT_SYNC_VERSION);
// This should never be null, but catch this error anyway
// Version should be "0" when we create the event, so use that
if (version != null) {
// Increment and save
try {
return Integer.toString((Integer.parseInt(version) + 1));
} catch (final NumberFormatException e) {
// Handle the case in which someone writes a non-integer here;
// shouldn't happen, but we don't want to kill the sync for his
}
}
return "0";
}
/**
* Convenience method for sending an email to the organizer declining the meeting.
* @param entity The {@link Entity} for this event.
* @param clientId The client id for this event.
*/
private void sendDeclinedEmail(final Entity entity, final String clientId) {
final Message msg =
CalendarUtilities.createMessageForEntity(mContext, entity,
Message.FLAG_OUTGOING_MEETING_DECLINE, clientId, mAccount);
if (msg != null) {
LogUtils.i(TAG, "Queueing declined response to %s", msg.mTo);
mOutgoingMailList.add(msg);
}
}
/**
* Get an integer value from a {@link ContentValues}, or 0 if the value isn't there.
* @param cv The {@link ContentValues} to find the value in.
* @param column The name of the column in cv to get.
* @return The appropriate value as an integer, or 0 if it's not there.
*/
private static int getInt(final ContentValues cv, final String column) {
final Integer i = cv.getAsInteger(column);
if (i == null) return 0;
return i;
}
/**
* Convert {@link Events} visibility values to EAS visibility values.
* @param visibility The {@link Events} visibility value.
* @return The corresponding EAS visibility value.
*/
private static String decodeVisibility(final int visibility) {
final int easVisibility;
switch(visibility) {
case Events.ACCESS_DEFAULT:
easVisibility = 0;
break;
case Events.ACCESS_PUBLIC:
easVisibility = 1;
break;
case Events.ACCESS_PRIVATE:
easVisibility = 2;
break;
case Events.ACCESS_CONFIDENTIAL:
easVisibility = 3;
break;
default:
easVisibility = 0;
break;
}
return Integer.toString(easVisibility);
}
/**
* Write an event to the {@link Serializer} for this upsync.
* @param entity The {@link Entity} for this event.
* @param clientId The client id for this event.
* @param s The {@link Serializer} for this Sync request.
* @throws IOException
* TODO: This can probably be refactored/cleaned up more.
*/
private void sendEvent(final Entity entity, final String clientId, final Serializer s)
throws IOException {
// Serialize for EAS here
// Set uid with the client id we created
// 1) Serialize the top-level event
// 2) Serialize attendees and reminders from subvalues
// 3) Look for exceptions and serialize with the top-level event
final ContentValues entityValues = entity.getEntityValues();
final boolean isException = (clientId == null);
boolean hasAttendees = false;
final boolean isChange = entityValues.containsKey(Events._SYNC_ID);
final boolean allDay =
CalendarUtilities.getIntegerValueAsBoolean(entityValues, Events.ALL_DAY);
final TimeZone localTimeZone = TimeZone.getDefault();
// NOTE: Exchange 2003 (EAS 2.5) seems to require the "exception deleted" and "exception
// start time" data before other data in exceptions. Failure to do so results in a
// status 6 error during sync
if (isException) {
// Send exception deleted flag if necessary
final Integer deleted = entityValues.getAsInteger(Events.DELETED);
final boolean isDeleted = deleted != null && deleted == 1;
final Integer eventStatus = entityValues.getAsInteger(Events.STATUS);
final boolean isCanceled =
eventStatus != null && eventStatus.equals(Events.STATUS_CANCELED);
if (isDeleted || isCanceled) {
s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "1");
// If we're deleted, the UI will continue to show this exception until we mark
// it canceled, so we'll do that here...
if (isDeleted && !isCanceled) {
final long eventId = entityValues.getAsLong(Events._ID);
final ContentValues cv = new ContentValues(1);
cv.put(Events.STATUS, Events.STATUS_CANCELED);
mContentResolver.update(
asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, eventId)),
cv, null, null);
}
} else {
s.data(Tags.CALENDAR_EXCEPTION_IS_DELETED, "0");
}
// TODO Add reminders to exceptions (allow them to be specified!)
Long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
if (originalTime != null) {
final boolean originalAllDay =
CalendarUtilities.getIntegerValueAsBoolean(entityValues,
Events.ORIGINAL_ALL_DAY);
if (originalAllDay) {
// For all day events, we need our local all-day time
originalTime =
CalendarUtilities.getLocalAllDayCalendarTime(originalTime, localTimeZone);
}
s.data(Tags.CALENDAR_EXCEPTION_START_TIME,
CalendarUtilities.millisToEasDateTime(originalTime));
} else {
// Illegal; what should we do?
}
}
if (!isException) {
// A time zone is required in all EAS events; we'll use the default if none is set
// Exchange 2003 seems to require this first... :-)
String timeZoneName = entityValues.getAsString(
allDay ? EVENT_SAVED_TIMEZONE_COLUMN : Events.EVENT_TIMEZONE);
if (timeZoneName == null) {
timeZoneName = localTimeZone.getID();
}
s.data(Tags.CALENDAR_TIME_ZONE,
CalendarUtilities.timeZoneToTziString(TimeZone.getTimeZone(timeZoneName)));
}
s.data(Tags.CALENDAR_ALL_DAY_EVENT, allDay ? "1" : "0");
// DTSTART is always supplied
long startTime = entityValues.getAsLong(Events.DTSTART);
// Determine endTime; it's either provided as DTEND or we calculate using DURATION
// If no DURATION is provided, we default to one hour
long endTime;
if (entityValues.containsKey(Events.DTEND)) {
endTime = entityValues.getAsLong(Events.DTEND);
} else {
long durationMillis = DateUtils.HOUR_IN_MILLIS;
if (entityValues.containsKey(Events.DURATION)) {
final Duration duration = new Duration();
try {
duration.parse(entityValues.getAsString(Events.DURATION));
durationMillis = duration.getMillis();
} catch (DateException e) {
// Can't do much about this; use the default (1 hour)
}
}
endTime = startTime + durationMillis;
}
if (allDay) {
startTime = CalendarUtilities.getLocalAllDayCalendarTime(startTime, localTimeZone);
endTime = CalendarUtilities.getLocalAllDayCalendarTime(endTime, localTimeZone);
}
s.data(Tags.CALENDAR_START_TIME, CalendarUtilities.millisToEasDateTime(startTime));
s.data(Tags.CALENDAR_END_TIME, CalendarUtilities.millisToEasDateTime(endTime));
s.data(Tags.CALENDAR_DTSTAMP,
CalendarUtilities.millisToEasDateTime(System.currentTimeMillis()));
String loc = entityValues.getAsString(Events.EVENT_LOCATION);
if (!TextUtils.isEmpty(loc)) {
if (getProtocolVersion() < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) {
// EAS 2.5 doesn't like bare line feeds
loc = Utility.replaceBareLfWithCrlf(loc);
}
s.data(Tags.CALENDAR_LOCATION, loc);
}
s.writeStringValue(entityValues, Events.TITLE, Tags.CALENDAR_SUBJECT);
if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) {
s.start(Tags.BASE_BODY);
s.data(Tags.BASE_TYPE, "1");
s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.BASE_DATA);
s.end();
} else {
// EAS 2.5 doesn't like bare line feeds
s.writeStringValue(entityValues, Events.DESCRIPTION, Tags.CALENDAR_BODY);
}
if (!isException) {
// For Exchange 2003, only upsync if the event is new
if ((getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) {
s.writeStringValue(entityValues, Events.ORGANIZER, Tags.CALENDAR_ORGANIZER_EMAIL);
}
final String rrule = entityValues.getAsString(Events.RRULE);
if (rrule != null) {
CalendarUtilities.recurrenceFromRrule(rrule, startTime, localTimeZone, s);
}
// Handle associated data EXCEPT for attendees, which have to be grouped
final ArrayList<Entity.NamedContentValues> subValues = entity.getSubValues();
// The earliest of the reminders for this Event; we can only send one reminder...
int earliestReminder = -1;
for (final Entity.NamedContentValues ncv: subValues) {
final Uri ncvUri = ncv.uri;
final ContentValues ncvValues = ncv.values;
if (ncvUri.equals(ExtendedProperties.CONTENT_URI)) {
final String propertyName = ncvValues.getAsString(ExtendedProperties.NAME);
final String propertyValue = ncvValues.getAsString(ExtendedProperties.VALUE);
if (TextUtils.isEmpty(propertyValue)) {
continue;
}
if (propertyName.equals(EXTENDED_PROPERTY_CATEGORIES)) {
// Send all the categories back to the server
// We've saved them as a String of delimited tokens
final StringTokenizer st =
new StringTokenizer(propertyValue, CATEGORY_TOKENIZER_DELIMITER);
if (st.countTokens() > 0) {
s.start(Tags.CALENDAR_CATEGORIES);
while (st.hasMoreTokens()) {
s.data(Tags.CALENDAR_CATEGORY, st.nextToken());
}
s.end();
}
}
} else if (ncvUri.equals(Reminders.CONTENT_URI)) {
Integer mins = ncvValues.getAsInteger(Reminders.MINUTES);
if (mins != null) {
// -1 means "default", which for Exchange, is 30
if (mins < 0) {
mins = 30;
}
// Save this away if it's the earliest reminder (greatest minutes)
if (mins > earliestReminder) {
earliestReminder = mins;
}
}
}
}
// If we have a reminder, send it to the server
if (earliestReminder >= 0) {
s.data(Tags.CALENDAR_REMINDER_MINS_BEFORE, Integer.toString(earliestReminder));
}
// We've got to send a UID, unless this is an exception. If the event is new, we've
// generated one; if not, we should have gotten one from extended properties.
if (clientId != null) {
s.data(Tags.CALENDAR_UID, clientId);
}
// Handle attendee data here; keep track of organizer and stream it afterward
String organizerName = null;
String organizerEmail = null;
for (final Entity.NamedContentValues ncv: subValues) {
final Uri ncvUri = ncv.uri;
final ContentValues ncvValues = ncv.values;
if (ncvUri.equals(Attendees.CONTENT_URI)) {
final Integer relationship =
ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP);
// If there's no relationship, we can't create this for EAS
// Similarly, we need an attendee email for each invitee
if (relationship != null && ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) {
// Organizer isn't among attendees in EAS
if (relationship == Attendees.RELATIONSHIP_ORGANIZER) {
organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME);
organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL);
continue;
}
if (!hasAttendees) {
s.start(Tags.CALENDAR_ATTENDEES);
hasAttendees = true;
}
s.start(Tags.CALENDAR_ATTENDEE);
final String attendeeEmail =
ncvValues.getAsString(Attendees.ATTENDEE_EMAIL);
String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME);
if (attendeeName == null) {
attendeeName = attendeeEmail;
}
s.data(Tags.CALENDAR_ATTENDEE_NAME, attendeeName);
s.data(Tags.CALENDAR_ATTENDEE_EMAIL, attendeeEmail);
if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) {
s.data(Tags.CALENDAR_ATTENDEE_TYPE, "1"); // Required
}
s.end(); // Attendee
}
}
}
if (hasAttendees) {
s.end(); // Attendees
}
// Get busy status from availability
final int availability = entityValues.getAsInteger(Events.AVAILABILITY);
final int busyStatus = CalendarUtilities.busyStatusFromAvailability(availability);
s.data(Tags.CALENDAR_BUSY_STATUS, Integer.toString(busyStatus));
// Meeting status, 0 = appointment, 1 = meeting, 3 = attendee
// In JB, organizer won't be an attendee
if (organizerEmail == null && entityValues.containsKey(Events.ORGANIZER)) {
organizerEmail = entityValues.getAsString(Events.ORGANIZER);
}
if (mAccount.mEmailAddress.equalsIgnoreCase(organizerEmail)) {
s.data(Tags.CALENDAR_MEETING_STATUS, hasAttendees ? "1" : "0");
} else {
s.data(Tags.CALENDAR_MEETING_STATUS, "3");
}
// For Exchange 2003, only upsync if the event is new
if (((getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) || !isChange) &&
organizerName != null) {
s.data(Tags.CALENDAR_ORGANIZER_NAME, organizerName);
}
// NOTE: Sensitivity must NOT be sent to the server for exceptions in Exchange 2003
// The result will be a status 6 failure during sync
final Integer visibility = entityValues.getAsInteger(Events.ACCESS_LEVEL);
if (visibility != null) {
s.data(Tags.CALENDAR_SENSITIVITY, decodeVisibility(visibility));
} else {
// Default to private if not set
s.data(Tags.CALENDAR_SENSITIVITY, "1");
}
}
}
/**
* Handle exceptions to an event's recurrance pattern.
* @param s The {@link Serializer} for this upsync.
* @param entity The {@link Entity} for this event.
* @param entityValues The {@link ContentValues} for entity.
* @param serverId The server side id for this event.
* @param clientId The client side id for this event.
* @param calendarIdString The calendar id, as a {@link String}.
* @param selfOrganizer Whether the user is the organizer of this event.
* @throws IOException
*/
private void handleExceptionsToRecurrenceRules(final Serializer s, final Entity entity,
final ContentValues entityValues, final String serverId, final String clientId,
final String calendarIdString, final boolean selfOrganizer) throws IOException {
final EntityIterator exIterator = EventsEntity.newEntityIterator(mContentResolver.query(
asSyncAdapter(Events.CONTENT_URI), null, ORIGINAL_EVENT_AND_CALENDAR,
new String[] { serverId, calendarIdString }, null), mContentResolver);
boolean exFirst = true;
while (exIterator.hasNext()) {
final Entity exEntity = exIterator.next();
if (exFirst) {
s.start(Tags.CALENDAR_EXCEPTIONS);
exFirst = false;
}
s.start(Tags.CALENDAR_EXCEPTION);
sendEvent(exEntity, null, s);
final ContentValues exValues = exEntity.getEntityValues();
if (getInt(exValues, Events.DIRTY) == 1) {
// This is a new/updated exception, so we've got to notify our
// attendees about it
final long exEventId = exValues.getAsLong(Events._ID);
// Copy subvalues into the exception; otherwise, we won't see the
// attendees when preparing the message
for (final Entity.NamedContentValues ncv: entity.getSubValues()) {
exEntity.addSubValue(ncv.uri, ncv.values);
}
final int flag;
if ((getInt(exValues, Events.DELETED) == 1) ||
(getInt(exValues, Events.STATUS) == Events.STATUS_CANCELED)) {
flag = Message.FLAG_OUTGOING_MEETING_CANCEL;
if (!selfOrganizer) {
// Send a cancellation notice to the organizer
// Since CalendarProvider2 sets the organizer of exceptions
// to the user, we have to reset it first to the original
// organizer
exValues.put(Events.ORGANIZER, entityValues.getAsString(Events.ORGANIZER));
sendDeclinedEmail(exEntity, clientId);
}
} else {
flag = Message.FLAG_OUTGOING_MEETING_INVITE;
}
// Add the eventId of the exception to the uploaded id list, so that
// the dirty/mark bits are cleared
mUploadedIdList.add(exEventId);
// Copy version so the ics attachment shows the proper sequence #
exValues.put(EVENT_SYNC_VERSION,
entityValues.getAsString(EVENT_SYNC_VERSION));
// Copy location so that it's included in the outgoing email
if (entityValues.containsKey(Events.EVENT_LOCATION)) {
exValues.put(Events.EVENT_LOCATION,
entityValues.getAsString(Events.EVENT_LOCATION));
}
if (selfOrganizer) {
final Message msg = CalendarUtilities.createMessageForEntity(mContext, exEntity,
flag, clientId, mAccount);
if (msg != null) {
LogUtils.i(TAG, "Queueing exception update to %s", msg.mTo);
mOutgoingMailList.add(msg);
}
}
}
s.end(); // EXCEPTION
}
if (!exFirst) {
s.end(); // EXCEPTIONS
}
}
/**
* Update the event properties with the attendee list, and send mail as appropriate.
* @param entity The {@link Entity} for this event.
* @param entityValues The {@link ContentValues} for entity.
* @param selfOrganizer Whether the user is the organizer of this event.
* @param eventId The id for this event.
* @param clientId The client side id for this event.
*/
private void updateAttendeesAndSendMail(final Entity entity, final ContentValues entityValues,
final boolean selfOrganizer, final long eventId, final String clientId) {
// Go through the extended properties of this Event and pull out our tokenized
// attendees list and the user attendee status; we will need them later
String attendeeString = null;
long attendeeStringId = -1;
String userAttendeeStatus = null;
long userAttendeeStatusId = -1;
for (final Entity.NamedContentValues ncv: entity.getSubValues()) {
if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) {
final ContentValues ncvValues = ncv.values;
final String propertyName = ncvValues.getAsString(ExtendedProperties.NAME);
if (propertyName.equals(EXTENDED_PROPERTY_ATTENDEES)) {
attendeeString = ncvValues.getAsString(ExtendedProperties.VALUE);
attendeeStringId = ncvValues.getAsLong(ExtendedProperties._ID);
} else if (propertyName.equals(EXTENDED_PROPERTY_USER_ATTENDEE_STATUS)) {
userAttendeeStatus = ncvValues.getAsString(ExtendedProperties.VALUE);
userAttendeeStatusId = ncvValues.getAsLong(ExtendedProperties._ID);
}
}
}
// Send the meeting invite if there are attendees and we're the organizer AND
// if the Event itself is dirty (we might be syncing only because an exception
// is dirty, in which case we DON'T send email about the Event)
if (selfOrganizer && (getInt(entityValues, Events.DIRTY) == 1)) {
final Message msg =
CalendarUtilities.createMessageForEventId(mContext, eventId,
Message.FLAG_OUTGOING_MEETING_INVITE, clientId, mAccount);
if (msg != null) {
LogUtils.i(TAG, "Queueing invitation to %s", msg.mTo);
mOutgoingMailList.add(msg);
}
// Make a list out of our tokenized attendees, if we have any
final ArrayList<String> originalAttendeeList = new ArrayList<String>();
if (attendeeString != null) {
final StringTokenizer st =
new StringTokenizer(attendeeString, ATTENDEE_TOKENIZER_DELIMITER);
while (st.hasMoreTokens()) {
originalAttendeeList.add(st.nextToken());
}
}
final StringBuilder newTokenizedAttendees = new StringBuilder();
// See if any attendees have been dropped and while we're at it, build
// an updated String with tokenized attendee addresses
for (final Entity.NamedContentValues ncv: entity.getSubValues()) {
if (ncv.uri.equals(Attendees.CONTENT_URI)) {
final String attendeeEmail = ncv.values.getAsString(Attendees.ATTENDEE_EMAIL);
// Remove all found attendees
originalAttendeeList.remove(attendeeEmail);
newTokenizedAttendees.append(attendeeEmail);
newTokenizedAttendees.append(ATTENDEE_TOKENIZER_DELIMITER);
}
}
// Update extended properties with the new attendee list, if we have one
// Otherwise, create one (this would be the case for Events created on
// device or "legacy" events (before this code was added)
final ContentValues cv = new ContentValues();
cv.put(ExtendedProperties.VALUE, newTokenizedAttendees.toString());
if (attendeeString != null) {
mContentResolver.update(asSyncAdapter(ContentUris.withAppendedId(
ExtendedProperties.CONTENT_URI, attendeeStringId)), cv, null, null);
} else {
// If there wasn't an "attendees" property, insert one
cv.put(ExtendedProperties.NAME, EXTENDED_PROPERTY_ATTENDEES);
cv.put(ExtendedProperties.EVENT_ID, eventId);
mContentResolver.insert(asSyncAdapter(ExtendedProperties.CONTENT_URI), cv);
}
// Whoever is left has been removed from the attendee list; send them
// a cancellation
for (final String removedAttendee: originalAttendeeList) {
// Send a cancellation message to each of them
final Message cancelMsg = CalendarUtilities.createMessageForEventId(mContext,
eventId, Message.FLAG_OUTGOING_MEETING_CANCEL, clientId, mAccount,
removedAttendee);
if (cancelMsg != null) {
// Just send it to the removed attendee
LogUtils.i(TAG, "Queueing cancellation to removed attendee %s", cancelMsg.mTo);
mOutgoingMailList.add(cancelMsg);
}
}
} else if (!selfOrganizer) {
// If we're not the organizer, see if we've changed our attendee status
// Our last synced attendee status is in ExtendedProperties, and we've
// retrieved it above as userAttendeeStatus
final int currentStatus = entityValues.getAsInteger(Events.SELF_ATTENDEE_STATUS);
int syncStatus = Attendees.ATTENDEE_STATUS_NONE;
if (userAttendeeStatus != null) {
try {
syncStatus = Integer.parseInt(userAttendeeStatus);
} catch (NumberFormatException e) {
// Just in case somebody else mucked with this and it's not Integer
}
}
if ((currentStatus != syncStatus) &&
(currentStatus != Attendees.ATTENDEE_STATUS_NONE)) {
// If so, send a meeting reply
final int messageFlag;
switch (currentStatus) {
case Attendees.ATTENDEE_STATUS_ACCEPTED:
messageFlag = Message.FLAG_OUTGOING_MEETING_ACCEPT;
break;
case Attendees.ATTENDEE_STATUS_DECLINED:
messageFlag = Message.FLAG_OUTGOING_MEETING_DECLINE;
break;
case Attendees.ATTENDEE_STATUS_TENTATIVE:
messageFlag = Message.FLAG_OUTGOING_MEETING_TENTATIVE;
break;
default:
messageFlag = 0;
break;
}
// Make sure we have a valid status (messageFlag should never be zero)
if (messageFlag != 0 && userAttendeeStatusId >= 0) {
// Save away the new status
final ContentValues cv = new ContentValues(1);
cv.put(ExtendedProperties.VALUE, Integer.toString(currentStatus));
mContentResolver.update(asSyncAdapter(ContentUris.withAppendedId(
ExtendedProperties.CONTENT_URI, userAttendeeStatusId)),
cv, null, null);
// Send mail to the organizer advising of the new status
final Message msg = CalendarUtilities.createMessageForEventId(mContext, eventId,
messageFlag, clientId, mAccount);
if (msg != null) {
LogUtils.i(TAG, "Queueing invitation reply to %s", msg.mTo);
mOutgoingMailList.add(msg);
}
}
}
}
}
/**
* Process a single event, adding to the {@link Serializer} as necessary.
* @param s The {@link Serializer} for this Sync request.
* @param entity The {@link Entity} for this event.
* @param calendarIdString The calendar's id, as a {@link String}.
* @param first Whether this would be the first event added to s.
* @return Whether this function added anything to s.
* @throws IOException
*/
private boolean handleEntity(final Serializer s, final Entity entity,
final String calendarIdString, final boolean first) throws IOException {
// For each of these entities, create the change commands
final ContentValues entityValues = entity.getEntityValues();
// We first need to check whether we can upsync this event; our test for this
// is currently the value of EXTENDED_PROPERTY_ATTENDEES_REDACTED
// If this is set to "1", we can't upsync the event
for (final Entity.NamedContentValues ncv: entity.getSubValues()) {
if (ncv.uri.equals(ExtendedProperties.CONTENT_URI)) {
final ContentValues ncvValues = ncv.values;
if (ncvValues.getAsString(ExtendedProperties.NAME).equals(
EXTENDED_PROPERTY_UPSYNC_PROHIBITED)) {
if ("1".equals(ncvValues.getAsString(ExtendedProperties.VALUE))) {
// Make sure we mark this to clear the dirty flag
mUploadedIdList.add(entityValues.getAsLong(Events._ID));
return false;
}
}
}
}
// EAS 2.5 needs: BusyStatus DtStamp EndTime Sensitivity StartTime TimeZone UID
// We can generate all but what we're testing for below
final String organizerEmail = entityValues.getAsString(Events.ORGANIZER);
if (organizerEmail == null || !entityValues.containsKey(Events.DTSTART) ||
(!entityValues.containsKey(Events.DURATION)
&& !entityValues.containsKey(Events.DTEND))) {
return false;
}
if (first) {
s.start(Tags.SYNC_COMMANDS);
LogUtils.i(TAG, "Sending Calendar changes to the server");
}
final boolean selfOrganizer = organizerEmail.equalsIgnoreCase(mAccount.mEmailAddress);
// Find our uid in the entity; otherwise create one
String clientId = entityValues.getAsString(Events.SYNC_DATA2);
if (clientId == null) {
clientId = UUID.randomUUID().toString();
}
final String serverId = entityValues.getAsString(Events._SYNC_ID);
final long eventId = entityValues.getAsLong(Events._ID);
if (serverId == null) {
// This is a new event; create a clientId
LogUtils.i(TAG, "Creating new event with clientId: %s", clientId);
s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId);
// And save it in the Event as the local id
final ContentValues cv = new ContentValues(2);
cv.put(Events.SYNC_DATA2, clientId);
cv.put(EVENT_SYNC_VERSION, "0");
mContentResolver.update(
asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, eventId)),
cv, null, null);
} else if (entityValues.getAsInteger(Events.DELETED) == 1) {
LogUtils.i(TAG, "Deleting event with serverId: %s", serverId);
s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end();
mDeletedIdList.add(eventId);
if (selfOrganizer) {
final Message msg = CalendarUtilities.createMessageForEventId(mContext,
eventId, Message.FLAG_OUTGOING_MEETING_CANCEL, null, mAccount);
if (msg != null) {
LogUtils.i(TAG, "Queueing cancellation to %s", msg.mTo);
mOutgoingMailList.add(msg);
}
} else {
sendDeclinedEmail(entity, clientId);
}
// For deletions, we don't need to add application data, so just bail here.
return true;
} else {
LogUtils.i(TAG, "Upsync change to event with serverId: %s", serverId);
s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId);
// Save to the ContentResolver.
final String version = getEntityVersion(entityValues);
final ContentValues cv = new ContentValues(1);
cv.put(EVENT_SYNC_VERSION, version);
mContentResolver.update(
asSyncAdapter( ContentUris.withAppendedId(Events.CONTENT_URI, eventId)),
cv, null, null);
// Also save in entityValues so that we send it this time around
entityValues.put(EVENT_SYNC_VERSION, version);
}
s.start(Tags.SYNC_APPLICATION_DATA);
sendEvent(entity, clientId, s);
// Now, the hard part; find exceptions for this event
if (serverId != null) {
handleExceptionsToRecurrenceRules(s, entity, entityValues, serverId, clientId,
calendarIdString, selfOrganizer);
}
s.end().end(); // ApplicationData & Add/Change
mUploadedIdList.add(eventId);
updateAttendeesAndSendMail(entity, entityValues, selfOrganizer, eventId, clientId);
return true;
}
@Override
protected void setUpsyncCommands(final Serializer s) throws IOException {
final String calendarIdString = Long.toString(mCalendarId);
final String[] calendarIdArgument = { calendarIdString };
markParentsOfDirtyEvents(calendarIdString, calendarIdArgument);
// Now go through dirty/marked top-level events and send them back to the server
final EntityIterator eventIterator = EventsEntity.newEntityIterator(
mContentResolver.query(asSyncAdapter(Events.CONTENT_URI), null,
DIRTY_OR_MARKED_TOP_LEVEL_IN_CALENDAR, calendarIdArgument, null), mContentResolver);
try {
boolean first = true;
while (eventIterator.hasNext()) {
final boolean addedCommand =
handleEntity(s, eventIterator.next(), calendarIdString, first);
if (addedCommand) {
first = false;
}
}
if (!first) {
s.end(); // Commands
}
} finally {
eventIterator.close();
}
}
@Override
protected void cleanup(final int syncResult) {
if (syncResult != SYNC_RESULT_FAILED) {
// Clear dirty and mark flags for updates sent to server
if (!mUploadedIdList.isEmpty()) {
final ContentValues cv = new ContentValues(2);
cv.put(Events.DIRTY, 0);
cv.put(EVENT_SYNC_MARK, "0");
for (final long eventId : mUploadedIdList) {
mContentResolver.update(asSyncAdapter(ContentUris.withAppendedId(
Events.CONTENT_URI, eventId)), cv, null, null);
}
}
// Delete events marked for deletion
if (!mDeletedIdList.isEmpty()) {
for (final long eventId : mDeletedIdList) {
mContentResolver.delete(asSyncAdapter(ContentUris.withAppendedId(
Events.CONTENT_URI, eventId)), null, null);
}
}
// Send all messages that were created during this sync.
for (final Message msg : mOutgoingMailList) {
sendMessage(mAccount, msg);
}
}
// Clear our lists for the next Sync request, if necessary.
if (syncResult != SYNC_RESULT_MORE_AVAILABLE) {
mDeletedIdList.clear();
mUploadedIdList.clear();
mOutgoingMailList.clear();
}
}
/**
* Delete an account from the Calendar provider.
* @param context Our {@link Context}
* @param emailAddress The email address of the account we wish to delete
*/
public static void wipeAccountFromContentProvider(final Context context,
final String emailAddress) {
context.getContentResolver().delete(asSyncAdapter(Calendars.CONTENT_URI, emailAddress),
Calendars.ACCOUNT_NAME + "=" + DatabaseUtils.sqlEscapeString(emailAddress)
+ " AND " + Calendars.ACCOUNT_TYPE + "="+ DatabaseUtils.sqlEscapeString(
context.getString(R.string.account_manager_type_exchange)), null);
}
}