blob: da1d895d0537765309e12525695eea66cf6640da [file] [log] [blame]
/*
**
** Copyright 2006, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** See the License for the specific language governing permissions and
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** limitations under the License.
*/
package com.android.providers.calendar;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncContext;
import android.content.SyncResult;
import android.content.SyncableContentProvider;
import android.database.Cursor;
import android.database.CursorJoiner;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.os.SystemProperties;
import android.pim.ICalendar;
import android.pim.RecurrenceSet;
import android.provider.Calendar;
import android.provider.Calendar.Calendars;
import android.provider.Calendar.Events;
import android.provider.SubscribedFeeds;
import android.provider.SyncConstValue;
import android.provider.Settings;
import android.text.TextUtils;
import android.text.format.Time;
import android.util.Config;
import android.util.Log;
import com.google.android.gdata.client.AndroidGDataClient;
import com.google.android.gdata.client.AndroidXmlParserFactory;
import com.google.android.googlelogin.GoogleLoginServiceConstants;
import com.google.android.providers.AbstractGDataSyncAdapter;
import com.google.wireless.gdata.calendar.client.CalendarClient;
import com.google.wireless.gdata.calendar.data.CalendarEntry;
import com.google.wireless.gdata.calendar.data.CalendarsFeed;
import com.google.wireless.gdata.calendar.data.EventEntry;
import com.google.wireless.gdata.calendar.data.EventsFeed;
import com.google.wireless.gdata.calendar.data.Reminder;
import com.google.wireless.gdata.calendar.data.When;
import com.google.wireless.gdata.calendar.data.Who;
import com.google.wireless.gdata.calendar.parser.xml.XmlCalendarGDataParserFactory;
import com.google.wireless.gdata.client.GDataServiceClient;
import com.google.wireless.gdata.client.HttpException;
import com.google.wireless.gdata.client.QueryParams;
import com.google.wireless.gdata.data.Entry;
import com.google.wireless.gdata.data.Feed;
import com.google.wireless.gdata.data.StringUtils;
import com.google.wireless.gdata.parser.GDataParser;
import com.google.wireless.gdata.parser.ParseException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
import java.util.Vector;
import java.net.URLDecoder;
/**
* SyncAdapter for Google Calendar. Fetches the list of the user's calendars,
* and for each calendar that is marked as "selected" in the web
* interface, syncs that calendar.
*/
public final class CalendarSyncAdapter extends AbstractGDataSyncAdapter {
/* package */ static final String USER_AGENT_APP_VERSION = "Android-GData-Calendar/1.2";
private static final String SELECT_BY_ACCOUNT =
Calendars._SYNC_ACCOUNT + "=? AND " + Calendars._SYNC_ACCOUNT_TYPE + "=?";
private static final String SELECT_BY_ACCOUNT_AND_FEED =
SELECT_BY_ACCOUNT + " AND " + Calendars.URL + "=?";
private static final String[] CALENDAR_KEY_COLUMNS =
new String[]{Calendars._SYNC_ACCOUNT, Calendars._SYNC_ACCOUNT_TYPE, Calendars.URL};
private static final String CALENDAR_KEY_SORT_ORDER =
Calendars._SYNC_ACCOUNT + "," + Calendars._SYNC_ACCOUNT_TYPE + "," + Calendars.URL;
private static final String[] FEEDS_KEY_COLUMNS =
new String[]{SubscribedFeeds.Feeds._SYNC_ACCOUNT,
SubscribedFeeds.Feeds._SYNC_ACCOUNT_TYPE, SubscribedFeeds.Feeds.FEED};
private static final String FEEDS_KEY_SORT_ORDER =
SubscribedFeeds.Feeds._SYNC_ACCOUNT + ", " + SubscribedFeeds.Feeds._SYNC_ACCOUNT_TYPE
+ ", " + SubscribedFeeds.Feeds.FEED;
private static final String PRIVATE_FULL = "/private/full";
private static final String FEEDS_SUBSTRING = "/feeds/";
private static final String PRIVATE_FULL_SELFATTENDANCE = "/private/full-selfattendance";
/** System property to enable sliding window sync **/
private static final String USE_SLIDING_WINDOW = "sync.slidingwindows";
private static final String HIDDEN_ATTENDEES_PROP =
"com.android.providers.calendar.CalendarSyncAdapter#guests";
public static class SyncInfo {
// public String feedUrl;
public long calendarId;
public String calendarTimezone;
}
private static final String TAG = "Sync";
private static final Integer sTentativeStatus = Events.STATUS_TENTATIVE;
private static final Integer sConfirmedStatus = Events.STATUS_CONFIRMED;
private static final Integer sCanceledStatus = Events.STATUS_CANCELED;
private final CalendarClient mCalendarClient;
private ContentResolver mContentResolver;
private static final String[] CALENDARS_PROJECTION = new String[] {
Calendars._ID, // 0
Calendars.SELECTED, // 1
Calendars._SYNC_TIME, // 2
Calendars.URL, // 3
Calendars.DISPLAY_NAME, // 4
Calendars.TIMEZONE, // 5
Calendars.SYNC_EVENTS, // 6
Calendars.OWNER_ACCOUNT // 7
};
// Counters for sync event logging
private static int mServerDiffs;
private static int mRefresh;
/** These are temporary until a real policy is implemented. **/
private static final long DAY_IN_MS = 86400000;
private static final long MONTH_IN_MS = 2592000000L; // 30 days
private static final long YEAR_IN_MS = 31600000000L; // approximately
protected CalendarSyncAdapter(Context context, SyncableContentProvider provider) {
super(context, provider);
mCalendarClient = new CalendarClient(
new AndroidGDataClient(context, USER_AGENT_APP_VERSION),
new XmlCalendarGDataParserFactory(new AndroidXmlParserFactory()));
}
@Override
protected Object createSyncInfo() {
return new SyncInfo();
}
@Override
protected Entry newEntry() {
return new EventEntry();
}
@Override
protected Cursor getCursorForTable(ContentProvider cp, Class entryClass) {
if (entryClass != EventEntry.class) {
throw new IllegalArgumentException("unexpected entry class, " + entryClass.getName());
}
return cp.query(Calendar.Events.CONTENT_URI, null, null, null, null);
}
@Override
protected Cursor getCursorForDeletedTable(ContentProvider cp, Class entryClass) {
if (entryClass != EventEntry.class) {
throw new IllegalArgumentException("unexpected entry class, " + entryClass.getName());
}
return cp.query(Calendar.Events.DELETED_CONTENT_URI, null, null, null, null);
}
@Override
protected String cursorToEntry(SyncContext context, Cursor c, Entry entry,
Object info) throws ParseException {
EventEntry event = (EventEntry) entry;
SyncInfo syncInfo = (SyncInfo) info;
String feedUrl = c.getString(c.getColumnIndex(Calendars.URL));
// update the sync info. this will be used later when we update the
// provider with the results of sending this entry to the calendar
// server.
syncInfo.calendarId = c.getLong(c.getColumnIndex(Events.CALENDAR_ID));
syncInfo.calendarTimezone =
c.getString(c.getColumnIndex(Events.EVENT_TIMEZONE));
if (TextUtils.isEmpty(syncInfo.calendarTimezone)) {
// if the event timezone is not set -- e.g., when we're creating an
// event on the device -- we will use the timezone for the calendar.
syncInfo.calendarTimezone =
c.getString(c.getColumnIndex(Events.TIMEZONE));
}
// has attendees data. this is set to false if the proxy hid all of
// the guests (see #entryToContentValues). in that case, we switch
// to the self attendance feed for updates.
boolean hasAttendees = c.getInt(c.getColumnIndex(Events.HAS_ATTENDEE_DATA)) != 0;
// id, edit uri.
// these may need to get rewritten to a self attendance projection,
// if our proxy server has removed guests (if there were to many)
String id = c.getString(c.getColumnIndex(Events._SYNC_ID));
String editUri = c.getString(c.getColumnIndex(Events._SYNC_VERSION));
if (!hasAttendees) {
if (id != null) id = convertProjectionToSelfAttendance(id);
if (editUri != null) editUri = convertProjectionToSelfAttendance(editUri);
}
event.setId(id);
event.setEditUri(editUri);
// status
byte status;
int localStatus = c.getInt(c.getColumnIndex(Events.STATUS));
switch (localStatus) {
case Events.STATUS_CANCELED:
status = EventEntry.STATUS_CANCELED;
break;
case Events.STATUS_CONFIRMED:
status = EventEntry.STATUS_CONFIRMED;
break;
case Events.STATUS_TENTATIVE:
status = EventEntry.STATUS_TENTATIVE;
break;
default:
// should not happen
status = EventEntry.STATUS_TENTATIVE;
break;
}
event.setStatus(status);
// visibility
byte visibility;
int localVisibility = c.getInt(c.getColumnIndex(Events.VISIBILITY));
switch (localVisibility) {
case Events.VISIBILITY_DEFAULT:
visibility = EventEntry.VISIBILITY_DEFAULT;
break;
case Events.VISIBILITY_CONFIDENTIAL:
visibility = EventEntry.VISIBILITY_CONFIDENTIAL;
break;
case Events.VISIBILITY_PRIVATE:
visibility = EventEntry.VISIBILITY_PRIVATE;
break;
case Events.VISIBILITY_PUBLIC:
visibility = EventEntry.VISIBILITY_PUBLIC;
break;
default:
// should not happen
Log.e(TAG, "Unexpected value for visibility: " + localVisibility
+ "; using default visibility.");
visibility = EventEntry.VISIBILITY_DEFAULT;
break;
}
event.setVisibility(visibility);
byte transparency;
int localTransparency = c.getInt(c.getColumnIndex(Events.TRANSPARENCY));
switch (localTransparency) {
case Events.TRANSPARENCY_OPAQUE:
transparency = EventEntry.TRANSPARENCY_OPAQUE;
break;
case Events.TRANSPARENCY_TRANSPARENT:
transparency = EventEntry.TRANSPARENCY_TRANSPARENT;
break;
default:
// should not happen
Log.e(TAG, "Unexpected value for transparency: " + localTransparency
+ "; using opaque transparency.");
transparency = EventEntry.TRANSPARENCY_OPAQUE;
break;
}
event.setTransparency(transparency);
// could set the html uri, but there's no need to, since it should not be edited.
// title
event.setTitle(c.getString(c.getColumnIndex(Events.TITLE)));
// description
event.setContent(c.getString(c.getColumnIndex(Events.DESCRIPTION)));
// where
event.setWhere(c.getString(c.getColumnIndex(Events.EVENT_LOCATION)));
// attendees
long eventId = c.getInt(c.getColumnIndex(Events._SYNC_LOCAL_ID));
addAttendeesToEntry(eventId, event);
// comment uri
event.setCommentsUri(c.getString(c.getColumnIndexOrThrow(Events.COMMENTS_URI)));
Time utc = new Time(Time.TIMEZONE_UTC);
boolean allDay = c.getInt(c.getColumnIndex(Events.ALL_DAY)) != 0;
String startTime = null;
String endTime = null;
// start time
int dtstartColumn = c.getColumnIndex(Events.DTSTART);
if (!c.isNull(dtstartColumn)) {
long dtstart = c.getLong(dtstartColumn);
utc.set(dtstart);
startTime = utc.format3339(allDay);
}
// end time
int dtendColumn = c.getColumnIndex(Events.DTEND);
if (!c.isNull(dtendColumn)) {
long dtend = c.getLong(dtendColumn);
utc.set(dtend);
endTime = utc.format3339(allDay);
}
When when = new When(startTime, endTime);
event.addWhen(when);
// reminders
Integer hasReminder = c.getInt(c.getColumnIndex(Events.HAS_ALARM));
if (hasReminder != null && hasReminder.intValue() != 0) {
addRemindersToEntry(eventId, event);
}
// extendedProperties
Integer hasExtendedProperties = c.getInt(c.getColumnIndex(Events.HAS_EXTENDED_PROPERTIES));
if (hasExtendedProperties != null && hasExtendedProperties.intValue() != 0) {
addExtendedPropertiesToEntry(eventId, event);
}
long originalStartTime = -1;
String originalId = c.getString(c.getColumnIndex(Events.ORIGINAL_EVENT));
int originalStartTimeIndex = c.getColumnIndex(Events.ORIGINAL_INSTANCE_TIME);
if (!c.isNull(originalStartTimeIndex)) {
originalStartTime = c.getLong(originalStartTimeIndex);
}
if ((originalStartTime != -1) && !TextUtils.isEmpty(originalId)) {
// We need to use the "originalAllDay" field for the original event
// in order to format the "originalStartTime" correctly.
boolean originalAllDay = c.getInt(c.getColumnIndex(Events.ORIGINAL_ALL_DAY)) != 0;
String timezone = c.getString(c.getColumnIndex(Events.EVENT_TIMEZONE));
if (TextUtils.isEmpty(timezone)) {
timezone = TimeZone.getDefault().getID();
}
Time originalTime = new Time(timezone);
originalTime.set(originalStartTime);
utc.set(originalStartTime);
event.setOriginalEventStartTime(utc.format3339(originalAllDay));
event.setOriginalEventId(originalId);
}
// recurrences.
ICalendar.Component component = new ICalendar.Component("DUMMY",
null /* parent */);
if (RecurrenceSet.populateComponent(c, component)) {
addRecurrenceToEntry(component, event);
}
// For now, always want to send event notifications
event.setSendEventNotifications(true);
event.setGuestsCanInviteOthers(
c.getInt(c.getColumnIndex(Events.GUESTS_CAN_INVITE_OTHERS)) != 0);
event.setGuestsCanModify(
c.getInt(c.getColumnIndex(Events.GUESTS_CAN_MODIFY)) != 0);
event.setGuestsCanSeeGuests(
c.getInt(c.getColumnIndex(Events.GUESTS_CAN_SEE_GUESTS)) != 0);
event.setOrganizer(c.getString(c.getColumnIndex(Events.ORGANIZER)));
// if this is a new entry, return the feed url. otherwise, return null; the edit url is
// already in the entry.
if (event.getEditUri() == null) {
// we won't ever rewrite this to self attendance because this is a new event
// (so if there are attendees, we need to use the full projection).
return feedUrl;
} else {
return null;
}
}
private String convertProjectionToSelfAttendance(String uri) {
return uri.replace(PRIVATE_FULL, PRIVATE_FULL_SELFATTENDANCE);
}
private void addAttendeesToEntry(long eventId, EventEntry event)
throws ParseException {
Cursor c = getContext().getContentResolver().query(
Calendar.Attendees.CONTENT_URI, null, "event_id=" + eventId, null, null);
try {
int nameIndex = c.getColumnIndexOrThrow(Calendar.Attendees.ATTENDEE_NAME);
int emailIndex = c.getColumnIndexOrThrow(Calendar.Attendees.ATTENDEE_EMAIL);
int statusIndex = c.getColumnIndexOrThrow(Calendar.Attendees.ATTENDEE_STATUS);
int typeIndex = c.getColumnIndexOrThrow(Calendar.Attendees.ATTENDEE_TYPE);
int relIndex = c.getColumnIndexOrThrow(Calendar.Attendees.ATTENDEE_RELATIONSHIP);
while (c.moveToNext()) {
Who who = new Who();
who.setValue(c.getString(nameIndex));
who.setEmail(c.getString(emailIndex));
int status = c.getInt(statusIndex);
switch (status) {
case Calendar.Attendees.ATTENDEE_STATUS_NONE:
who.setStatus(Who.STATUS_NONE);
break;
case Calendar.Attendees.ATTENDEE_STATUS_ACCEPTED:
who.setStatus(Who.STATUS_ACCEPTED);
break;
case Calendar.Attendees.ATTENDEE_STATUS_DECLINED:
who.setStatus(Who.STATUS_DECLINED);
break;
case Calendar.Attendees.ATTENDEE_STATUS_INVITED:
who.setStatus(Who.STATUS_INVITED);
break;
case Calendar.Attendees.ATTENDEE_STATUS_TENTATIVE:
who.setStatus(Who.STATUS_TENTATIVE);
break;
default:
Log.e(TAG, "Unknown attendee status: " + status);
who.setStatus(Who.STATUS_NONE);
break;
}
int type = c.getInt(typeIndex);
switch (type) {
case Calendar.Attendees.TYPE_NONE:
who.setType(Who.TYPE_NONE);
break;
case Calendar.Attendees.TYPE_REQUIRED:
who.setType(Who.TYPE_REQUIRED);
break;
case Calendar.Attendees.TYPE_OPTIONAL:
who.setType(Who.TYPE_OPTIONAL);
break;
default:
Log.e(TAG, "Unknown attendee type: " + type);
who.setType(Who.TYPE_NONE);
break;
}
int rel = c.getInt(relIndex);
switch (rel) {
case Calendar.Attendees.RELATIONSHIP_NONE:
who.setRelationship(Who.RELATIONSHIP_NONE);
break;
case Calendar.Attendees.RELATIONSHIP_ATTENDEE:
who.setRelationship(Who.RELATIONSHIP_ATTENDEE);
break;
case Calendar.Attendees.RELATIONSHIP_ORGANIZER:
who.setRelationship(Who.RELATIONSHIP_ORGANIZER);
break;
case Calendar.Attendees.RELATIONSHIP_SPEAKER:
who.setRelationship(Who.RELATIONSHIP_SPEAKER);
break;
case Calendar.Attendees.RELATIONSHIP_PERFORMER:
who.setRelationship(Who.RELATIONSHIP_PERFORMER);
break;
default:
Log.e(TAG, "Unknown attendee relationship: " + rel);
who.setRelationship(Who.RELATIONSHIP_NONE);
break;
}
event.addAttendee(who);
}
} finally {
c.close();
}
}
private void addRemindersToEntry(long eventId, EventEntry event)
throws ParseException {
Cursor c = getContext().getContentResolver().query(
Calendar.Reminders.CONTENT_URI, null,
"event_id=" + eventId, null, null);
try {
int methodIndex = c.getColumnIndex(Calendar.Reminders.METHOD);
int minutesIndex = c.getColumnIndex(Calendar.Reminders.MINUTES);
while (c.moveToNext()) {
Reminder reminder = new Reminder();
reminder.setMinutes(c.getInt(minutesIndex));
int method = c.getInt(methodIndex);
switch(method) {
case Calendar.Reminders.METHOD_DEFAULT:
reminder.setMethod(Reminder.METHOD_DEFAULT);
break;
case Calendar.Reminders.METHOD_ALERT:
reminder.setMethod(Reminder.METHOD_ALERT);
break;
case Calendar.Reminders.METHOD_EMAIL:
reminder.setMethod(Reminder.METHOD_EMAIL);
break;
case Calendar.Reminders.METHOD_SMS:
reminder.setMethod(Reminder.METHOD_SMS);
break;
default:
throw new ParseException("illegal method, " + method);
}
event.addReminder(reminder);
}
} finally {
c.close();
}
}
private void addExtendedPropertiesToEntry(long eventId, EventEntry event)
throws ParseException {
Cursor c = getContext().getContentResolver().query(
Calendar.ExtendedProperties.CONTENT_URI, null,
"event_id=" + eventId, null, null);
try {
int nameIndex = c.getColumnIndex(Calendar.ExtendedProperties.NAME);
int valueIndex = c.getColumnIndex(Calendar.ExtendedProperties.VALUE);
while (c.moveToNext()) {
String name = c.getString(nameIndex);
String value = c.getString(valueIndex);
event.addExtendedProperty(name, value);
}
} finally {
c.close();
}
}
private void addRecurrenceToEntry(ICalendar.Component component,
EventEntry event) {
// serialize the component into a Google Calendar recurrence string
// we don't serialize the entire component, since we have a dummy
// wrapper (BEGIN:DUMMY, END:DUMMY).
StringBuilder sb = new StringBuilder();
// append the properties
boolean first = true;
for (String propertyName : component.getPropertyNames()) {
for (ICalendar.Property property :
component.getProperties(propertyName)) {
if (first) {
first = false;
} else {
sb.append("\n");
}
property.toString(sb);
}
}
// append the sub-components
List<ICalendar.Component> children = component.getComponents();
if (children != null) {
for (ICalendar.Component child : children) {
if (first) {
first = false;
} else {
sb.append("\n");
}
child.toString(sb);
}
}
event.setRecurrence(sb.toString());
}
@Override
protected void deletedCursorToEntry(SyncContext context, Cursor c, Entry entry) {
EventEntry event = (EventEntry) entry;
event.setId(c.getString(c.getColumnIndex(Events._SYNC_ID)));
event.setEditUri(c.getString(c.getColumnIndex(Events._SYNC_VERSION)));
event.setStatus(EventEntry.STATUS_CANCELED);
}
protected boolean handleAllDeletedUnavailable(GDataSyncData syncData, String feed) {
syncData.feedData.remove(feed);
final Account account = getAccount();
getContext().getContentResolver().delete(Calendar.Calendars.CONTENT_URI,
Calendar.Calendars._SYNC_ACCOUNT + "=? AND "
+ Calendar.Calendars._SYNC_ACCOUNT_TYPE + "=? AND "
+ Calendar.Calendars.URL + "=?",
new String[]{account.name, account.type, feed});
return true;
}
@Override
public void onSyncStarting(SyncContext context, Account account, boolean manualSync,
SyncResult result) {
mContentResolver = getContext().getContentResolver();
mServerDiffs = 0;
mRefresh = 0;
super.onSyncStarting(context, account, manualSync, result);
}
public boolean getIsSyncable(Account account)
throws IOException, AuthenticatorException, OperationCanceledException {
Account[] accounts = AccountManager.get(getContext()).getAccountsByTypeAndFeatures(
"com.google", new String[]{"legacy_hosted_or_google"}, null, null).getResult();
return accounts.length > 0 && accounts[0].equals(account) && super.getIsSyncable(account);
}
private void deletedEntryToContentValues(Long syncLocalId, EventEntry event,
ContentValues values) {
// see #deletedCursorToEntry. this deletion cannot be an exception to a recurrence (e.g.,
// deleting an instance of a repeating event) -- new recurrence exceptions would be
// insertions.
values.clear();
// Base sync info
values.put(Events._SYNC_LOCAL_ID, syncLocalId);
values.put(Events._SYNC_ID, event.getId());
values.put(Events._SYNC_VERSION, event.getEditUri());
}
/**
* Clear out the map and stuff an Entry into it in a format that can
* be inserted into a content provider.
*
* If a date is before 1970 or past 2038, ENTRY_INVALID is returned, and DTSTART
* is set to -1. This is due to the current 32-bit time restriction and
* will be fixed in a future release.
*
* @return ENTRY_OK, ENTRY_DELETED, or ENTRY_INVALID
*/
private int entryToContentValues(EventEntry event, Long syncLocalId,
ContentValues map, Object info) {
SyncInfo syncInfo = (SyncInfo) info;
// There are 3 cases for parsing a date-time string:
//
// 1. The date-time string specifies a date and time with a time offset.
// (The "normal" format.)
// 2. The date-time string is just a date, used for all-day events,
// with no time or time offset fields. (The "all-day" format.)
// 3. The date-time string specifies a date and time, but no time
// offset. (The "floating" format, not supported yet.)
//
// Case 1: Time.parse3339() converts the date-time string to UTC and
// sets the Time.timezone to UTC. It does not matter what the initial
// Time.timezone field was set to. The initial timezone is ignored.
//
// Case 2: The date-time string doesn't specify the time.
// Time.parse3339() just sets the date but not the time (hour, minute,
// second) fields. (The time fields should be zero, meaning midnight.)
// This code then sets the timezone to UTC (because this is an all-day
// event). It does not matter in this case either what the initial
// Time.timezone field was set to.
//
// Case 3: This is a "floating time" (which we do not support yet).
// In this case, it will matter what the initial Time.timezone is set
// to. It should use UTC. If I specify a floating time of 1pm then I
// want that event displayed at 1pm in every timezone. The easiest way
// to support this would be store it as 1pm in UTC and mark the event
// as "isFloating" (with a new database column). Then when displaying
// the event, the code checks "isFloating" and just leaves the time at
// 1pm without doing any conversion to the local timezone.
//
// So in all cases, it is correct to set the Time.timezone to UTC.
Time time = new Time(Time.TIMEZONE_UTC);
map.clear();
// Base sync info
map.put(Events._SYNC_ID, event.getId());
String version = event.getEditUri();
final Account account = getAccount();
if (!StringUtils.isEmpty(version)) {
// Always rewrite the edit URL to https for dasher account to avoid
// redirection.
map.put(Events._SYNC_VERSION, rewriteUrlforAccount(account, version));
}
// see if this is an exception to an existing event/recurrence.
String originalId = event.getOriginalEventId();
String originalStartTime = event.getOriginalEventStartTime();
boolean isRecurrenceException = false;
if (!StringUtils.isEmpty(originalId) && !StringUtils.isEmpty(originalStartTime)) {
isRecurrenceException = true;
time.parse3339(originalStartTime);
map.put(Events.ORIGINAL_EVENT, originalId);
map.put(Events.ORIGINAL_INSTANCE_TIME, time.toMillis(false /* use isDst */));
map.put(Events.ORIGINAL_ALL_DAY, time.allDay ? 1 : 0);
}
// Event status
byte status = event.getStatus();
switch (status) {
case EventEntry.STATUS_CANCELED:
if (!isRecurrenceException) {
return ENTRY_DELETED;
}
map.put(Events.STATUS, sCanceledStatus);
break;
case EventEntry.STATUS_TENTATIVE:
map.put(Events.STATUS, sTentativeStatus);
break;
case EventEntry.STATUS_CONFIRMED:
map.put(Events.STATUS, sConfirmedStatus);
break;
default:
// should not happen
return ENTRY_INVALID;
}
map.put(Events._SYNC_LOCAL_ID, syncLocalId);
// Updated time, only needed for non-deleted items
String updated = event.getUpdateDate();
map.put(Events._SYNC_TIME, updated);
map.put(Events._SYNC_DIRTY, 0);
// visibility
switch (event.getVisibility()) {
case EventEntry.VISIBILITY_DEFAULT:
map.put(Events.VISIBILITY, Events.VISIBILITY_DEFAULT);
break;
case EventEntry.VISIBILITY_CONFIDENTIAL:
map.put(Events.VISIBILITY, Events.VISIBILITY_CONFIDENTIAL);
break;
case EventEntry.VISIBILITY_PRIVATE:
map.put(Events.VISIBILITY, Events.VISIBILITY_PRIVATE);
break;
case EventEntry.VISIBILITY_PUBLIC:
map.put(Events.VISIBILITY, Events.VISIBILITY_PUBLIC);
break;
default:
// should not happen
Log.e(TAG, "Unexpected visibility " + event.getVisibility());
return ENTRY_INVALID;
}
// transparency
switch (event.getTransparency()) {
case EventEntry.TRANSPARENCY_OPAQUE:
map.put(Events.TRANSPARENCY, Events.TRANSPARENCY_OPAQUE);
break;
case EventEntry.TRANSPARENCY_TRANSPARENT:
map.put(Events.TRANSPARENCY, Events.TRANSPARENCY_TRANSPARENT);
break;
default:
// should not happen
Log.e(TAG, "Unexpected transparency " + event.getTransparency());
return ENTRY_INVALID;
}
// html uri
String htmlUri = event.getHtmlUri();
if (!StringUtils.isEmpty(htmlUri)) {
// TODO: convert this desktop url into a mobile one?
// htmlUri = htmlUri.replace("/event?", "/mevent?"); // but a little more robust
map.put(Events.HTML_URI, htmlUri);
}
// title
String title = event.getTitle();
if (!StringUtils.isEmpty(title)) {
map.put(Events.TITLE, title);
}
// content
String content = event.getContent();
if (!StringUtils.isEmpty(content)) {
map.put(Events.DESCRIPTION, content);
}
// where
String where = event.getWhere();
if (!StringUtils.isEmpty(where)) {
map.put(Events.EVENT_LOCATION, where);
}
// Calendar ID
map.put(Events.CALENDAR_ID, syncInfo.calendarId);
// comments uri
String commentsUri = event.getCommentsUri();
if (commentsUri != null) {
map.put(Events.COMMENTS_URI, commentsUri);
}
boolean timesSet = false;
// see if there are any reminders for this event
if (event.getReminders() != null) {
// just store that we have reminders. the caller will have
// to update the reminders table separately.
map.put(Events.HAS_ALARM, 1);
}
boolean hasAttendeeData = true;
// see if there are any extended properties for this event
if (event.getExtendedProperties() != null) {
// first, intercept the proxy's hint that it has stripped attendees
Hashtable props = event.getExtendedProperties();
if (props.containsKey(HIDDEN_ATTENDEES_PROP) &&
"hidden".equals(props.get(HIDDEN_ATTENDEES_PROP))) {
props.remove(HIDDEN_ATTENDEES_PROP);
hasAttendeeData = false;
}
// just store that we have extended properties. the caller will have
// to update the extendedproperties table separately.
map.put(Events.HAS_EXTENDED_PROPERTIES, ((props.size() > 0) ? 1 : 0));
}
map.put(Events.HAS_ATTENDEE_DATA, hasAttendeeData ? 1 : 0);
// dtstart & dtend
When when = event.getFirstWhen();
if (when != null) {
String startTime = when.getStartTime();
if (!StringUtils.isEmpty(startTime)) {
time.parse3339(startTime);
// we also stash away the event's timezone.
// this timezone might get overwritten below, if this event is
// a recurrence (recurrences are defined in terms of the
// timezone of the creator of the event).
// note that we treat all day events as occurring in the UTC timezone, so
// an event on 05/08/2007 occurs on 05/08/2007, no matter what timezone the device
// is in.
// TODO: handle the "floating" timezone.
if (time.allDay) {
map.put(Events.ALL_DAY, 1);
map.put(Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC);
} else {
map.put(Events.EVENT_TIMEZONE, syncInfo.calendarTimezone);
}
long dtstart = time.toMillis(false /* use isDst */);
if (dtstart < 0) {
if (Config.LOGD) {
Log.d(TAG, "dtstart out of range: " + startTime);
}
map.put(Events.DTSTART, -1); // Flag to caller that date is out of range
return ENTRY_INVALID;
}
map.put(Events.DTSTART, dtstart);
timesSet = true;
}
String endTime = when.getEndTime();
if (!StringUtils.isEmpty(endTime)) {
time.parse3339(endTime);
long dtend = time.toMillis(false /* use isDst */);
if (dtend < 0) {
if (Config.LOGD) {
Log.d(TAG, "dtend out of range: " + endTime);
}
map.put(Events.DTSTART, -1); // Flag to caller that date is out of range
return ENTRY_INVALID;
}
map.put(Events.DTEND, dtend);
}
}
// rrule
String recurrence = event.getRecurrence();
if (!TextUtils.isEmpty(recurrence)) {
ICalendar.Component recurrenceComponent =
new ICalendar.Component("DUMMY", null /* parent */);
ICalendar ical = null;
try {
ICalendar.parseComponent(recurrenceComponent, recurrence);
} catch (ICalendar.FormatException fe) {
if (Config.LOGD) {
Log.d(TAG, "Unable to parse recurrence: " + recurrence);
}
return ENTRY_INVALID;
}
if (!RecurrenceSet.populateContentValues(recurrenceComponent, map)) {
return ENTRY_INVALID;
}
timesSet = true;
}
if (!timesSet) {
return ENTRY_INVALID;
}
map.put(SyncConstValue._SYNC_ACCOUNT, account.name);
map.put(SyncConstValue._SYNC_ACCOUNT_TYPE, account.type);
map.put(Events.GUESTS_CAN_INVITE_OTHERS, event.getGuestsCanInviteOthers() ? 1 : 0);
map.put(Events.GUESTS_CAN_MODIFY, event.getGuestsCanModify() ? 1 : 0);
map.put(Events.GUESTS_CAN_SEE_GUESTS, event.getGuestsCanSeeGuests() ? 1 : 0);
// Find the organizer for this event
String organizer = null;
Vector attendees = event.getAttendees();
Enumeration attendeesEnum = attendees.elements();
while (attendeesEnum.hasMoreElements()) {
Who who = (Who) attendeesEnum.nextElement();
if (who.getRelationship() == Who.RELATIONSHIP_ORGANIZER) {
organizer = who.getEmail();
break;
}
}
if (organizer != null) {
map.put(Events.ORGANIZER, organizer);
}
return ENTRY_OK;
}
public void updateProvider(Feed feed,
Long syncLocalId, Entry entry,
ContentProvider provider, Object info,
GDataSyncData.FeedData feedSyncData) throws ParseException {
SyncInfo syncInfo = (SyncInfo) info;
EventEntry event = (EventEntry) entry;
ContentValues map = new ContentValues();
// use the calendar's timezone, if provided in the feed.
// this overwrites whatever was in the db.
if ((feed != null) && (feed instanceof EventsFeed)) {
EventsFeed eventsFeed = (EventsFeed) feed;
syncInfo.calendarTimezone = eventsFeed.getTimezone();
}
if (entry.isDeleted()) {
deletedEntryToContentValues(syncLocalId, event, map);
if (Config.LOGV) {
Log.v(TAG, "Deleting entry: " + map);
}
provider.insert(Events.DELETED_CONTENT_URI, map);
return;
}
int entryState = entryToContentValues(event, syncLocalId, map, syncInfo);
// See if event is inside the window
// feedSyncData will be null if the phone is creating the event
if (entryState == ENTRY_OK && (feedSyncData == null || feedSyncData.newWindowEnd == 0)) {
// A regular sync. Accept the event if it is inside the sync window or
// it is a recurrence exception for something inside the sync window.
Long dtstart = map.getAsLong(Events.DTSTART);
if (dtstart != null && (feedSyncData == null || dtstart < feedSyncData.windowEnd)) {
// dstart inside window, keeping event
} else {
Long originalInstanceTime = map.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
if (originalInstanceTime != null &&
(feedSyncData == null || originalInstanceTime <= feedSyncData.windowEnd)) {
// originalInstanceTime inside the window, keeping event
} else {
// Rejecting event as outside window
return;
}
}
}
if (entryState == ENTRY_DELETED) {
if (Config.LOGV) {
Log.v(TAG, "Got deleted entry from server: "
+ map);
}
provider.insert(Events.DELETED_CONTENT_URI, map);
} else if (entryState == ENTRY_OK) {
if (Config.LOGV) {
Log.v(TAG, "Got entry from server: " + map);
}
Uri result = provider.insert(Events.CONTENT_URI, map);
long rowId = ContentUris.parseId(result);
// handle the reminders for the event
Integer hasAlarm = map.getAsInteger(Events.HAS_ALARM);
if (hasAlarm != null && hasAlarm == 1) {
// reminders should not be null
Vector alarms = event.getReminders();
if (alarms == null) {
Log.e(TAG, "Have an alarm but do not have any reminders "
+ "-- should not happen.");
throw new IllegalStateException("Have an alarm but do not have any reminders");
}
Enumeration reminders = alarms.elements();
while (reminders.hasMoreElements()) {
ContentValues reminderValues = new ContentValues();
reminderValues.put(Calendar.Reminders.EVENT_ID, rowId);
Reminder reminder = (Reminder) reminders.nextElement();
byte method = reminder.getMethod();
switch (method) {
case Reminder.METHOD_DEFAULT:
reminderValues.put(Calendar.Reminders.METHOD,
Calendar.Reminders.METHOD_DEFAULT);
break;
case Reminder.METHOD_ALERT:
reminderValues.put(Calendar.Reminders.METHOD,
Calendar.Reminders.METHOD_ALERT);
break;
case Reminder.METHOD_EMAIL:
reminderValues.put(Calendar.Reminders.METHOD,
Calendar.Reminders.METHOD_EMAIL);
break;
case Reminder.METHOD_SMS:
reminderValues.put(Calendar.Reminders.METHOD,
Calendar.Reminders.METHOD_SMS);
break;
default:
// should not happen. return false? we'd have to
// roll back the event.
Log.e(TAG, "Unknown reminder method: " + method
+ " should not happen!");
}
int minutes = reminder.getMinutes();
reminderValues.put(Calendar.Reminders.MINUTES,
minutes == Reminder.MINUTES_DEFAULT ?
Calendar.Reminders.MINUTES_DEFAULT :
minutes);
if (provider.insert(Calendar.Reminders.CONTENT_URI,
reminderValues) == null) {
throw new ParseException("Unable to insert reminders.");
}
}
}
// handle attendees for the event
Vector attendees = event.getAttendees();
Enumeration attendeesEnum = attendees.elements();
while (attendeesEnum.hasMoreElements()) {
Who who = (Who) attendeesEnum.nextElement();
ContentValues attendeesValues = new ContentValues();
attendeesValues.put(Calendar.Attendees.EVENT_ID, rowId);
attendeesValues.put(Calendar.Attendees.ATTENDEE_NAME, who.getValue());
attendeesValues.put(Calendar.Attendees.ATTENDEE_EMAIL, who.getEmail());
byte status;
switch (who.getStatus()) {
case Who.STATUS_NONE:
status = Calendar.Attendees.ATTENDEE_STATUS_NONE;
break;
case Who.STATUS_INVITED:
status = Calendar.Attendees.ATTENDEE_STATUS_INVITED;
break;
case Who.STATUS_ACCEPTED:
status = Calendar.Attendees.ATTENDEE_STATUS_ACCEPTED;
break;
case Who.STATUS_TENTATIVE:
status = Calendar.Attendees.ATTENDEE_STATUS_TENTATIVE;
break;
case Who.STATUS_DECLINED:
status = Calendar.Attendees.ATTENDEE_STATUS_DECLINED;
break;
default:
Log.w(TAG, "Unknown attendee status " + who.getStatus());
status = Calendar.Attendees.ATTENDEE_STATUS_NONE;
}
attendeesValues.put(Calendar.Attendees.ATTENDEE_STATUS, status);
byte rel;
switch (who.getRelationship()) {
case Who.RELATIONSHIP_NONE:
rel = Calendar.Attendees.RELATIONSHIP_NONE;
break;
case Who.RELATIONSHIP_ORGANIZER:
rel = Calendar.Attendees.RELATIONSHIP_ORGANIZER;
break;
case Who.RELATIONSHIP_ATTENDEE:
rel = Calendar.Attendees.RELATIONSHIP_ATTENDEE;
break;
case Who.RELATIONSHIP_PERFORMER:
rel = Calendar.Attendees.RELATIONSHIP_PERFORMER;
break;
case Who.RELATIONSHIP_SPEAKER:
rel = Calendar.Attendees.RELATIONSHIP_SPEAKER;
break;
default:
Log.w(TAG, "Unknown attendee relationship " + who.getRelationship());
rel = Calendar.Attendees.RELATIONSHIP_NONE;
}
attendeesValues.put(Calendar.Attendees.ATTENDEE_RELATIONSHIP, rel);
byte type;
switch (who.getType()) {
case Who.TYPE_NONE:
type = Calendar.Attendees.TYPE_NONE;
break;
case Who.TYPE_REQUIRED:
type = Calendar.Attendees.TYPE_REQUIRED;
break;
case Who.TYPE_OPTIONAL:
type = Calendar.Attendees.TYPE_OPTIONAL;
break;
default:
Log.w(TAG, "Unknown attendee type " + who.getType());
type = Calendar.Attendees.TYPE_NONE;
}
attendeesValues.put(Calendar.Attendees.ATTENDEE_TYPE, type);
if (provider.insert(Calendar.Attendees.CONTENT_URI, attendeesValues) == null) {
throw new ParseException("Unable to insert attendees.");
}
}
// handle the extended properties for the event
Integer hasExtendedProperties = map.getAsInteger(Events.HAS_EXTENDED_PROPERTIES);
if (hasExtendedProperties != null && hasExtendedProperties.intValue() != 0) {
// extended properties should not be null
// TODO: make the extended properties a bit more OO?
Hashtable extendedProperties = event.getExtendedProperties();
if (extendedProperties == null) {
Log.e(TAG, "Have extendedProperties but do not have any properties"
+ "-- should not happen.");
throw new IllegalStateException(
"Have extendedProperties but do not have any properties");
}
Enumeration propertyNames = extendedProperties.keys();
while (propertyNames.hasMoreElements()) {
String propertyName = (String) propertyNames.nextElement();
String propertyValue = (String) extendedProperties.get(propertyName);
ContentValues extendedPropertyValues = new ContentValues();
extendedPropertyValues.put(Calendar.ExtendedProperties.EVENT_ID, rowId);
extendedPropertyValues.put(Calendar.ExtendedProperties.NAME,
propertyName);
extendedPropertyValues.put(Calendar.ExtendedProperties.VALUE,
propertyValue);
if (provider.insert(Calendar.ExtendedProperties.CONTENT_URI,
extendedPropertyValues) == null) {
throw new ParseException("Unable to insert extended properties.");
}
}
}
} else {
// If the DTSTART == -1, then the date was out of range. We don't
// need to throw a ParseException because the user can create
// dates on the web that we can't handle on the phone. For
// example, events with dates before Dec 13, 1901 can be created
// on the web but cannot be handled on the phone.
Long dtstart = map.getAsLong(Events.DTSTART);
if (dtstart != null && dtstart == -1) {
return;
}
if (Config.LOGV) {
Log.v(TAG, "Got invalid entry from server: " + map);
}
throw new ParseException("Got invalid entry from server: " + map);
}
}
/**
* Converts an old non-sliding-windows database to sliding windows
* @param feedSyncData State of the sync.
*/
private void upgradeToSlidingWindows(GDataSyncData.FeedData feedSyncData) {
feedSyncData.windowEnd = getSyncWindowEnd();
// TODO: Should prune old events
}
@Override
public void getServerDiffs(SyncContext context,
SyncData baseSyncData, SyncableContentProvider tempProvider,
Bundle extras, Object baseSyncInfo, SyncResult syncResult) {
final ContentResolver cr = getContext().getContentResolver();
mServerDiffs++;
final boolean syncingSingleFeed = (extras != null) && extras.containsKey("feed");
final boolean syncingMetafeedOnly = (extras != null) && extras.containsKey("metafeedonly");
if (syncingSingleFeed) {
if (syncingMetafeedOnly) {
Log.d(TAG, "metafeedonly and feed both set.");
return;
}
StringBuilder sb = new StringBuilder();
extrasToStringBuilder(extras, sb);
String feedUrl = extras.getString("feed");
GDataSyncData.FeedData feedSyncData = getFeedData(feedUrl, baseSyncData);
if (feedSyncData != null && feedSyncData.windowEnd == 0) {
upgradeToSlidingWindows(feedSyncData);
} else if (feedSyncData == null) {
feedSyncData = new GDataSyncData.FeedData(0, 0, false, "", 0);
feedSyncData.windowEnd = getSyncWindowEnd();
((GDataSyncData) baseSyncData).feedData.put(feedUrl, feedSyncData);
}
if (extras.getBoolean("moveWindow", false)) {
// This is a move window sync. Set the new end.
// Setting newWindowEnd makes this a sliding window expansion sync.
if (feedSyncData.newWindowEnd == 0) {
feedSyncData.newWindowEnd = getSyncWindowEnd();
}
} else {
if (getSyncWindowEnd() > feedSyncData.windowEnd) {
// Schedule a move-the-window sync
Bundle syncExtras = new Bundle();
syncExtras.clear();
syncExtras.putBoolean("moveWindow", true);
syncExtras.putString("feed", feedUrl);
ContentResolver.requestSync(null /* account */, Calendar.AUTHORITY, syncExtras);
}
}
getServerDiffsForFeed(context, baseSyncData, tempProvider, feedUrl,
baseSyncInfo, syncResult);
return;
}
// At this point, either metafeed sync or poll.
// For the poll (or metafeed sync), refresh the list of calendars.
// we can move away from this when we move to the new allcalendars feed, which is
// syncable. until then, we'll rely on the daily poll to keep the list of calendars
// up to date.
mRefresh++;
context.setStatusText("Fetching list of calendars");
fetchCalendarsFromServer();
if (syncingMetafeedOnly) {
// If not polling, nothing more to do.
return;
}
// select the set of calendars for this account.
final Account account = getAccount();
final String[] accountSelectionArgs = new String[]{account.name, account.type};
Cursor cursor = cr.query(Calendar.Calendars.CONTENT_URI,
CALENDARS_PROJECTION, SELECT_BY_ACCOUNT,
accountSelectionArgs, null /* sort order */);
Bundle syncExtras = new Bundle();
try {
while (cursor.moveToNext()) {
boolean syncEvents = (cursor.getInt(6) == 1);
String feedUrl = cursor.getString(3);
if (!syncEvents) {
continue;
}
// schedule syncs for each of these feeds.
syncExtras.clear();
syncExtras.putAll(extras);
syncExtras.putString("feed", feedUrl);
ContentResolver.requestSync(account,
Calendar.Calendars.CONTENT_URI.getAuthority(), syncExtras);
}
} finally {
cursor.close();
}
}
/**
* Gets end of the sliding sync window.
*
* @return end of window in ms
*/
private long getSyncWindowEnd() {
// How many days in the future the window extends (e.g. 1 year). 0 for no sliding window.
long window = Settings.Gservices.getLong(getContext().getContentResolver(),
Settings.Gservices.GOOGLE_CALENDAR_SYNC_WINDOW_DAYS, 0);
if (window > 0) {
// How often to advance the window (e.g. 30 days)
long advanceInterval = Settings.Gservices.getLong(getContext().getContentResolver(),
Settings.Gservices.GOOGLE_CALENDAR_SYNC_WINDOW_UPDATE_DAYS, 30) * DAY_IN_MS;
if (advanceInterval > 0) {
// endOfWindow is the proposed end of the sliding window (e.g. 1 year out)
long endOfWindow = System.currentTimeMillis() + window * DAY_IN_MS;
// We don't want the end of the window to advance smoothly or else we would
// be constantly doing syncs to update the window. We "snap" the window to
// a multiple of advanceInterval so the end of the window will only advance
// every e.g. 30 days. By dividing and multiplying by advanceInterval, the
// window is truncated down to a multiple of advanceInterval. This provides
// the "snap" action.
return (endOfWindow / advanceInterval) * advanceInterval;
}
}
return Long.MAX_VALUE;
}
private void getServerDiffsForFeed(SyncContext context, SyncData baseSyncData,
SyncableContentProvider tempProvider,
String feed, Object baseSyncInfo, SyncResult syncResult) {
final SyncInfo syncInfo = (SyncInfo) baseSyncInfo;
final GDataSyncData syncData = (GDataSyncData) baseSyncData;
final Account account = getAccount();
Cursor cursor = getContext().getContentResolver().query(Calendar.Calendars.CONTENT_URI,
CALENDARS_PROJECTION, SELECT_BY_ACCOUNT_AND_FEED,
new String[] { account.name, account.type, feed }, null /* sort order */);
ContentValues map = new ContentValues();
int maxResults = getMaxEntriesPerSync();
try {
if (!cursor.moveToFirst()) {
return;
}
// TODO: refactor all of this, so we don't have to rely on
// member variables getting updated here in order for the
// base class hooks to work.
syncInfo.calendarId = cursor.getLong(0);
boolean syncEvents = (cursor.getInt(6) == 1);
long syncTime = cursor.getLong(2);
String feedUrl = cursor.getString(3);
String name = cursor.getString(4);
String origCalendarTimezone =
syncInfo.calendarTimezone = cursor.getString(5);
if (!syncEvents) {
// should not happen. non-syncable feeds should not be scheduled for syncs nor
// should they get tickled.
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Ignoring sync request for non-syncable feed.");
}
return;
}
context.setStatusText("Syncing " + name);
// call the superclass implementation to sync the current
// calendar from the server.
getServerDiffsImpl(context, tempProvider, getFeedEntryClass(), feedUrl, syncInfo,
maxResults, syncData, syncResult);
if (mSyncCanceled || syncResult.hasError()) {
return;
}
// update the timezone for this calendar if it changed
if (!TextUtils.equals(syncInfo.calendarTimezone,
origCalendarTimezone)) {
map.clear();
map.put(Calendars.TIMEZONE, syncInfo.calendarTimezone);
mContentResolver.update(
ContentUris.withAppendedId(Calendars.CONTENT_URI, syncInfo.calendarId),
map, null, null);
}
} finally {
cursor.close();
}
}
@Override
protected void initTempProvider(SyncableContentProvider cp) {
// TODO: don't use the real db's calendar id's. create new ones locally and translate
// during CalendarProvider's merge.
// populate temp provider with calendar ids, so joins work.
ContentValues map = new ContentValues();
final Account account = getAccount();
Cursor c = getContext().getContentResolver().query(
Calendar.Calendars.CONTENT_URI,
CALENDARS_PROJECTION,
SELECT_BY_ACCOUNT, new String[]{account.name, account.type},
null /* sort order */);
final int idIndex = c.getColumnIndexOrThrow(Calendars._ID);
final int urlIndex = c.getColumnIndexOrThrow(Calendars.URL);
final int timezoneIndex = c.getColumnIndexOrThrow(Calendars.TIMEZONE);
final int ownerAccountIndex = c.getColumnIndexOrThrow(Calendars.OWNER_ACCOUNT);
while (c.moveToNext()) {
map.clear();
map.put(Calendars._ID, c.getLong(idIndex));
map.put(Calendars.URL, c.getString(urlIndex));
map.put(Calendars.TIMEZONE, c.getString(timezoneIndex));
map.put(Calendars.OWNER_ACCOUNT, c.getString(ownerAccountIndex));
cp.insert(Calendar.Calendars.CONTENT_URI, map);
}
c.close();
}
public void onAccountsChanged(Account[] accountsArray) {
if (!"yes".equals(SystemProperties.get("ro.config.sync"))) {
return;
}
// - Get a cursor (A) over all sync'd calendars over all accounts
// - Get a cursor (B) over all subscribed feeds for calendar
// - If an item is in A but not B then add a subscription
// - If an item is in B but not A then remove the subscription
ContentResolver cr = getContext().getContentResolver();
Cursor cursorA = null;
Cursor cursorB = null;
try {
cursorA = Calendar.Calendars.query(cr, null /* projection */,
Calendar.Calendars.SYNC_EVENTS + "=1", CALENDAR_KEY_SORT_ORDER);
int urlIndexA = cursorA.getColumnIndexOrThrow(Calendar.Calendars.URL);
int accountNameIndexA = cursorA.getColumnIndexOrThrow(Calendar.Calendars._SYNC_ACCOUNT);
int accountTypeIndexA =
cursorA.getColumnIndexOrThrow(Calendar.Calendars._SYNC_ACCOUNT_TYPE);
cursorB = SubscribedFeeds.Feeds.query(cr, FEEDS_KEY_COLUMNS,
SubscribedFeeds.Feeds.AUTHORITY + "=?", new String[]{Calendar.AUTHORITY},
FEEDS_KEY_SORT_ORDER);
int urlIndexB = cursorB.getColumnIndexOrThrow(SubscribedFeeds.Feeds.FEED);
int accountNameIndexB =
cursorB.getColumnIndexOrThrow(SubscribedFeeds.Feeds._SYNC_ACCOUNT);
int accountTypeIndexB =
cursorB.getColumnIndexOrThrow(SubscribedFeeds.Feeds._SYNC_ACCOUNT_TYPE);
for (CursorJoiner.Result joinerResult :
new CursorJoiner(cursorA, CALENDAR_KEY_COLUMNS, cursorB, FEEDS_KEY_COLUMNS)) {
switch (joinerResult) {
case LEFT:
SubscribedFeeds.addFeed(
cr,
cursorA.getString(urlIndexA),
new Account(cursorA.getString(accountNameIndexA),
cursorA.getString(accountTypeIndexA)),
Calendar.AUTHORITY,
CalendarClient.SERVICE);
break;
case RIGHT:
SubscribedFeeds.deleteFeed(
cr,
cursorB.getString(urlIndexB),
new Account(cursorB.getString(accountNameIndexB),
cursorB.getString(accountTypeIndexB)),
Calendar.AUTHORITY);
break;
case BOTH:
// do nothing, since the subscription already exists
break;
}
}
} finally {
// check for null in case an exception occurred before the cursors got created
if (cursorA != null) cursorA.close();
if (cursorB != null) cursorB.close();
}
}
/**
* Should not get called. The feed url changes depending on which calendar is being sync'd
* to/from the device, and thus is determined and passed around as a local variable, where
* appropriate.
*/
protected String getFeedUrl(Account account) {
throw new UnsupportedOperationException("getFeedUrl() should not get called.");
}
protected Class getFeedEntryClass() {
return EventEntry.class;
}
// XXX temporary debugging
private static void extrasToStringBuilder(Bundle bundle, StringBuilder sb) {
sb.append("[");
for (String key : bundle.keySet()) {
sb.append(key).append("=").append(bundle.get(key)).append(" ");
}
sb.append("]");
}
@Override
protected void updateQueryParameters(QueryParams params, GDataSyncData.FeedData feedSyncData) {
if (feedSyncData != null && feedSyncData.newWindowEnd > 0) {
// Advancing the sliding window: set the parameters to the new part of the window
params.setUpdatedMin(null);
params.setParamValue("requirealldeleted", "false");
Time startMinTime = new Time(Time.TIMEZONE_UTC);
Time startMaxTime = new Time(Time.TIMEZONE_UTC);
startMinTime.set(feedSyncData.windowEnd);
startMaxTime.set(feedSyncData.newWindowEnd);
String startMin = startMinTime.format("%Y-%m-%dT%H:%M:%S.000Z");
String startMax = startMaxTime.format("%Y-%m-%dT%H:%M:%S.000Z");
params.setParamValue("start-min", startMin);
params.setParamValue("start-max", startMax);
} else if (params.getUpdatedMin() == null) {
// if this is the first sync, only bother syncing starting from
// one month ago.
// TODO: remove this restriction -- we may want all of
// historical calendar events.
Time lastMonth = new Time(Time.TIMEZONE_UTC);
lastMonth.setToNow();
--lastMonth.month;
lastMonth.normalize(true /* ignore isDst */);
// TODO: move start-min to CalendarClient?
// or create CalendarQueryParams subclass (extra class)?
String startMin = lastMonth.format("%Y-%m-%dT%H:%M:%S.000Z");
params.setParamValue("start-min", startMin);
// Note: start-max is not set for regular syncs. The sync needs to pick up events
// outside the window in case an event inside the window got moved outside.
// The event will be discarded later.
}
// HACK: specify that we want to expand recurrences in the past,
// so the server does not expand any recurrences. we do this to
// avoid a large number of gd:when elements that we do not need,
// since we process gd:recurrence elements instead.
params.setParamValue("recurrence-expansion-start", "1970-01-01");
params.setParamValue("recurrence-expansion-end", "1970-01-01");
// we want to get the events ordered by last modified, so we can
// recover in case we cannot process the entire feed.
params.setParamValue("orderby", "lastmodified");
params.setParamValue("sortorder", "ascending");
}
@Override
protected GDataServiceClient getGDataServiceClient() {
return mCalendarClient;
}
protected void getStatsString(StringBuffer sb, SyncResult result) {
super.getStatsString(sb, result);
if (mRefresh > 0) {
sb.append("F").append(mRefresh);
}
if (mServerDiffs > 0) {
sb.append("s").append(mServerDiffs);
}
}
private void fetchCalendarsFromServer() {
if (mCalendarClient == null) {
Log.w(TAG, "Cannot fetch calendars -- calendar url defined.");
return;
}
Account account = null;
String authToken = null;
try {
// TODO: allow caller to specify which account's feeds should be updated
String[] features = new String[]{
GoogleLoginServiceConstants.FEATURE_LEGACY_HOSTED_OR_GOOGLE};
Account[] accounts = AccountManager.get(getContext()).getAccountsByTypeAndFeatures(
GoogleLoginServiceConstants.ACCOUNT_TYPE, features, null, null).getResult();
if (accounts.length == 0) {
Log.w(TAG, "Unable to update calendars from server -- no users configured.");
return;
}
account = accounts[0];
Bundle bundle = AccountManager.get(getContext()).getAuthToken(
account, mCalendarClient.getServiceName(),
true /* notifyAuthFailure */, null /* callback */, null /* handler */)
.getResult();
authToken = bundle.getString(AccountManager.KEY_AUTHTOKEN);
if (authToken == null) {
Log.w(TAG, "Unable to update calendars from server -- could not "
+ "authenticate user " + account);
return;
}
} catch (IOException e) {
Log.w(TAG, "Unable to update calendars from server -- could not "
+ "authenticate user " + account, e);
return;
} catch (AuthenticatorException e) {
Log.w(TAG, "Unable to update calendars from server -- could not "
+ "authenticate user " + account, e);
return;
} catch (OperationCanceledException e) {
Log.w(TAG, "Unable to update calendars from server -- could not "
+ "authenticate user " + account, e);
return;
}
// get the current set of calendars. we'll need to pay attention to
// which calendars we get back from the server, so we can delete
// calendars that have been deleted from the server.
Set<Long> existingCalendarIds = new HashSet<Long>();
getCurrentCalendars(existingCalendarIds);
// get and process the calendars meta feed
GDataParser parser = null;
try {
String feedUrl = mCalendarClient.getUserCalendarsUrl(account.name);
feedUrl = CalendarSyncAdapter.rewriteUrlforAccount(account, feedUrl);
parser = mCalendarClient.getParserForUserCalendars(feedUrl, authToken);
// process the calendars
processCalendars(account, parser, existingCalendarIds);
} catch (ParseException pe) {
Log.w(TAG, "Unable to process calendars from server -- could not "
+ "parse calendar feed.", pe);
return;
} catch (IOException ioe) {
Log.w(TAG, "Unable to process calendars from server -- encountered "
+ "i/o error", ioe);
return;
} catch (HttpException e) {
switch (e.getStatusCode()) {
case HttpException.SC_UNAUTHORIZED:
Log.w(TAG, "Unable to process calendars from server -- could not "
+ "authenticate user.", e);
return;
case HttpException.SC_GONE:
Log.w(TAG, "Unable to process calendars from server -- encountered "
+ "an AllDeletedUnavailableException, this should never happen", e);
return;
default:
Log.w(TAG, "Unable to process calendars from server -- error", e);
return;
}
} finally {
if (parser != null) {
parser.close();
}
}
// delete calendars that are no longer sent from the server.
final Uri calendarContentUri = Calendars.CONTENT_URI;
final ContentResolver cr = getContext().getContentResolver();
for (long calId : existingCalendarIds) {
// NOTE: triggers delete all events, instances for this calendar.
cr.delete(ContentUris.withAppendedId(calendarContentUri, calId),
null /* where */, null /* selectionArgs */);
}
}
private void getCurrentCalendars(Set<Long> calendarIds) {
final ContentResolver cr = getContext().getContentResolver();
Cursor cursor = cr.query(Calendars.CONTENT_URI,
new String[] { Calendars._ID },
null /* selection */,
null /* selectionArgs */,
null /* sort */);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
calendarIds.add(cursor.getLong(0));
}
} finally {
cursor.close();
}
}
}
private void processCalendars(Account account,
GDataParser parser,
Set<Long> existingCalendarIds)
throws ParseException, IOException {
final ContentResolver cr = getContext().getContentResolver();
CalendarsFeed feed = (CalendarsFeed) parser.init();
Entry entry = null;
final Uri calendarContentUri = Calendars.CONTENT_URI;
ArrayList<ContentValues> inserts = new ArrayList<ContentValues>();
while (parser.hasMoreData()) {
entry = parser.readNextEntry(entry);
if (Config.LOGV) Log.v(TAG, "Read entry: " + entry.toString());
CalendarEntry calendarEntry = (CalendarEntry) entry;
ContentValues map = new ContentValues();
String feedUrl = calendarEntryToContentValues(account, feed, calendarEntry, map);
if (TextUtils.isEmpty(feedUrl)) {
continue;
}
long calId = -1;
Cursor c = cr.query(calendarContentUri,
new String[] { Calendars._ID },
Calendars.URL + "='"
+ feedUrl + '\'' /* selection */,
null /* selectionArgs */,
null /* sort */);
if (c != null) {
try {
if (c.moveToFirst()) {
calId = c.getLong(0);
existingCalendarIds.remove(calId);
}
} finally {
c.close();
}
}
if (calId != -1) {
if (Config.LOGV) Log.v(TAG, "Updating calendar " + map);
// don't override the existing "selected" or "hidden" settings.
map.remove(Calendars.SELECTED);
map.remove(Calendars.HIDDEN);
cr.update(ContentUris.withAppendedId(calendarContentUri, calId), map,
null /* where */, null /* selectionArgs */);
} else {
// Select this calendar for syncing and display if it is
// selected and not hidden.
int syncAndDisplay = 0;
if (calendarEntry.isSelected() && !calendarEntry.isHidden()) {
syncAndDisplay = 1;
}
map.put(Calendars.SYNC_EVENTS, syncAndDisplay);
map.put(Calendars.SELECTED, syncAndDisplay);
map.put(Calendars.HIDDEN, 0);
map.put(Calendars._SYNC_ACCOUNT, account.name);
map.put(Calendars._SYNC_ACCOUNT_TYPE, account.type);
if (Config.LOGV) Log.v(TAG, "Adding calendar " + map);
inserts.add(map);
}
}
if (!inserts.isEmpty()) {
if (Config.LOGV) Log.v(TAG, "Bulk updating calendar list.");
cr.bulkInsert(calendarContentUri, inserts.toArray(new ContentValues[inserts.size()]));
}
}
/**
* Convert the CalenderEntry to a Bundle that can be inserted/updated into the
* Calendars table.
*/
private String calendarEntryToContentValues(Account account, CalendarsFeed feed,
CalendarEntry entry,
ContentValues map) {
map.clear();
String url = entry.getAlternateLink();
if (TextUtils.isEmpty(url)) {
// yuck. the alternate link was not available. we should
// reconstruct from the id.
url = entry.getId();
if (!TextUtils.isEmpty(url)) {
url = convertCalendarIdToFeedUrl(url);
} else {
if (Config.LOGV) {
Log.v(TAG, "Cannot generate url for calendar feed.");
}
return null;
}
}
url = rewriteUrlforAccount(account, url);
map.put(Calendars.URL, url);
map.put(Calendars.OWNER_ACCOUNT, calendarEmailAddressFromFeedUrl(url));
map.put(Calendars.NAME, entry.getTitle());
// TODO:
map.put(Calendars.DISPLAY_NAME, entry.getTitle());
map.put(Calendars.TIMEZONE, entry.getTimezone());
String colorStr = entry.getColor();
if (!TextUtils.isEmpty(colorStr)) {
int color = Color.parseColor(colorStr);
// Ensure the alpha is set to max
color |= 0xff000000;
map.put(Calendars.COLOR, color);
}
map.put(Calendars.SELECTED, entry.isSelected() ? 1 : 0);
map.put(Calendars.HIDDEN, entry.isHidden() ? 1 : 0);
int accesslevel;
switch (entry.getAccessLevel()) {
case CalendarEntry.ACCESS_NONE:
accesslevel = Calendars.NO_ACCESS;
break;
case CalendarEntry.ACCESS_READ:
accesslevel = Calendars.READ_ACCESS;
break;
case CalendarEntry.ACCESS_FREEBUSY:
accesslevel = Calendars.FREEBUSY_ACCESS;
break;
case CalendarEntry.ACCESS_EDITOR:
accesslevel = Calendars.EDITOR_ACCESS;
break;
case CalendarEntry.ACCESS_OWNER:
accesslevel = Calendars.OWNER_ACCESS;
break;
case CalendarEntry.ACCESS_ROOT:
accesslevel = Calendars.ROOT_ACCESS;
break;
default:
accesslevel = Calendars.NO_ACCESS;
}
map.put(Calendars.ACCESS_LEVEL, accesslevel);
// TODO: use the update time, when calendar actually supports this.
// right now, calendar modifies the update time frequently.
map.put(Calendars._SYNC_TIME, System.currentTimeMillis());
return url;
}
// TODO: unit test.
protected static final String convertCalendarIdToFeedUrl(String url) {
// id: http://www.google.com/calendar/feeds/<username>/<cal id>
// desired feed:
// http://www.google.com/calendar/feeds/<cal id>/<projection>
int start = url.indexOf(FEEDS_SUBSTRING);
if (start != -1) {
// strip out the */ in /feeds/*/
start += FEEDS_SUBSTRING.length();
int end = url.indexOf('/', start);
if (end != -1) {
url = url.replace(url.substring(start, end + 1), "");
}
url = url + PRIVATE_FULL;
}
return url;
}
/**
* Extracts the calendar email from a calendar feed url.
* @param feed the calendar feed url
* @return the calendar email that is in the feed url or null if it can't
* find the email address.
*/
public static String calendarEmailAddressFromFeedUrl(String feed) {
// Example feed url:
// https://www.google.com/calendar/feeds/foo%40gmail.com/private/full-noattendees
String[] pathComponents = feed.split("/");
if (pathComponents.length > 5 && "feeds".equals(pathComponents[4])) {
try {
return URLDecoder.decode(pathComponents[5], "UTF-8");
} catch (UnsupportedEncodingException e) {
Log.e(TAG, "unable to url decode the email address in calendar " + feed);
return null;
}
}
Log.e(TAG, "unable to find the email address in calendar " + feed);
return null;
}
}