/* ** ** 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.OnAccountsUpdateListener; import android.app.AlarmManager; import android.app.AppOpsManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.UriMatcher; import android.content.pm.PackageManager; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.Binder; import android.os.Process; import android.os.SystemClock; import android.provider.BaseColumns; import android.provider.CalendarContract; import android.provider.CalendarContract.Attendees; import android.provider.CalendarContract.CalendarAlerts; import android.provider.CalendarContract.Calendars; import android.provider.CalendarContract.Colors; import android.provider.CalendarContract.Events; import android.provider.CalendarContract.Instances; import android.provider.CalendarContract.Reminders; import android.provider.CalendarContract.SyncState; import android.text.TextUtils; import android.text.format.DateUtils; import android.text.format.Time; import android.util.Log; import android.util.TimeFormatException; import android.util.TimeUtils; import com.android.calendarcommon2.DateException; import com.android.calendarcommon2.Duration; import com.android.calendarcommon2.EventRecurrence; import com.android.calendarcommon2.RecurrenceProcessor; import com.android.calendarcommon2.RecurrenceSet; import com.android.providers.calendar.CalendarDatabaseHelper.Tables; import com.android.providers.calendar.CalendarDatabaseHelper.Views; import com.google.android.collect.Sets; import com.google.common.annotations.VisibleForTesting; import java.io.File; import java.lang.reflect.Array; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.TimeZone; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Calendar content provider. The contract between this provider and applications * is defined in {@link android.provider.CalendarContract}. */ public class CalendarProvider2 extends SQLiteContentProvider implements OnAccountsUpdateListener { protected static final String TAG = "CalendarProvider2"; // Turn on for b/22449592 static final boolean DEBUG_INSTANCES = Log.isLoggable(TAG, Log.DEBUG); private static final String TIMEZONE_GMT = "GMT"; private static final String ACCOUNT_SELECTION_PREFIX = Calendars.ACCOUNT_NAME + "=? AND " + Calendars.ACCOUNT_TYPE + "=?"; protected static final boolean PROFILE = false; private static final boolean MULTIPLE_ATTENDEES_PER_EVENT = true; private static final String[] ID_ONLY_PROJECTION = new String[] {Events._ID}; private static final String[] EVENTS_PROJECTION = new String[] { Events._SYNC_ID, Events.RRULE, Events.RDATE, Events.ORIGINAL_ID, Events.ORIGINAL_SYNC_ID, }; private static final int EVENTS_SYNC_ID_INDEX = 0; private static final int EVENTS_RRULE_INDEX = 1; private static final int EVENTS_RDATE_INDEX = 2; private static final int EVENTS_ORIGINAL_ID_INDEX = 3; private static final int EVENTS_ORIGINAL_SYNC_ID_INDEX = 4; private static final String[] COLORS_PROJECTION = new String[] { Colors.ACCOUNT_NAME, Colors.ACCOUNT_TYPE, Colors.COLOR_TYPE, Colors.COLOR_KEY, Colors.COLOR, }; private static final int COLORS_ACCOUNT_NAME_INDEX = 0; private static final int COLORS_ACCOUNT_TYPE_INDEX = 1; private static final int COLORS_COLOR_TYPE_INDEX = 2; private static final int COLORS_COLOR_INDEX_INDEX = 3; private static final int COLORS_COLOR_INDEX = 4; private static final String COLOR_FULL_SELECTION = Colors.ACCOUNT_NAME + "=? AND " + Colors.ACCOUNT_TYPE + "=? AND " + Colors.COLOR_TYPE + "=? AND " + Colors.COLOR_KEY + "=?"; private static final String GENERIC_ACCOUNT_NAME = Calendars.ACCOUNT_NAME; private static final String GENERIC_ACCOUNT_TYPE = Calendars.ACCOUNT_TYPE; private static final String[] ACCOUNT_PROJECTION = new String[] { GENERIC_ACCOUNT_NAME, GENERIC_ACCOUNT_TYPE, }; private static final int ACCOUNT_NAME_INDEX = 0; private static final int ACCOUNT_TYPE_INDEX = 1; // many tables have _id and event_id; pick a representative version to use as our generic private static final String GENERIC_ID = Attendees._ID; private static final String GENERIC_EVENT_ID = Attendees.EVENT_ID; private static final String[] ID_PROJECTION = new String[] { GENERIC_ID, GENERIC_EVENT_ID, }; private static final int ID_INDEX = 0; private static final int EVENT_ID_INDEX = 1; /** * Projection to query for correcting times in allDay events. */ private static final String[] ALLDAY_TIME_PROJECTION = new String[] { Events._ID, Events.DTSTART, Events.DTEND, Events.DURATION }; private static final int ALLDAY_ID_INDEX = 0; private static final int ALLDAY_DTSTART_INDEX = 1; private static final int ALLDAY_DTEND_INDEX = 2; private static final int ALLDAY_DURATION_INDEX = 3; private static final int DAY_IN_SECONDS = 24 * 60 * 60; /** * The cached copy of the CalendarMetaData database table. * Make this "package private" instead of "private" so that test code * can access it. */ MetaData mMetaData; CalendarCache mCalendarCache; private CalendarDatabaseHelper mDbHelper; private CalendarInstancesHelper mInstancesHelper; private static final String SQL_SELECT_EVENTSRAWTIMES = "SELECT " + CalendarContract.EventsRawTimes.EVENT_ID + ", " + CalendarContract.EventsRawTimes.DTSTART_2445 + ", " + CalendarContract.EventsRawTimes.DTEND_2445 + ", " + Events.EVENT_TIMEZONE + " FROM " + Tables.EVENTS_RAW_TIMES + ", " + Tables.EVENTS + " WHERE " + CalendarContract.EventsRawTimes.EVENT_ID + " = " + Tables.EVENTS + "." + Events._ID; private static final String SQL_UPDATE_EVENT_SET_DIRTY_AND_MUTATORS = "UPDATE " + Tables.EVENTS + " SET " + Events.DIRTY + "=1," + Events.MUTATORS + "=? " + " WHERE " + Events._ID + "=?"; private static final String SQL_QUERY_EVENT_MUTATORS = "SELECT " + Events.MUTATORS + " FROM " + Tables.EVENTS + " WHERE " + Events._ID + "=?"; private static final String SQL_WHERE_CALENDAR_COLOR = Calendars.ACCOUNT_NAME + "=? AND " + Calendars.ACCOUNT_TYPE + "=? AND " + Calendars.CALENDAR_COLOR_KEY + "=?"; private static final String SQL_WHERE_EVENT_COLOR = "calendar_id in (SELECT _id from " + Tables.CALENDARS + " WHERE " + Events.ACCOUNT_NAME + "=? AND " + Events.ACCOUNT_TYPE + "=?) AND " + Events.EVENT_COLOR_KEY + "=?"; protected static final String SQL_WHERE_ID = GENERIC_ID + "=?"; private static final String SQL_WHERE_EVENT_ID = GENERIC_EVENT_ID + "=?"; private static final String SQL_WHERE_ORIGINAL_ID = Events.ORIGINAL_ID + "=?"; private static final String SQL_WHERE_ORIGINAL_ID_NO_SYNC_ID = Events.ORIGINAL_ID + "=? AND " + Events._SYNC_ID + " IS NULL"; private static final String SQL_WHERE_ATTENDEE_BASE = Tables.EVENTS + "." + Events._ID + "=" + Tables.ATTENDEES + "." + Attendees.EVENT_ID + " AND " + Tables.EVENTS + "." + Events.CALENDAR_ID + "=" + Tables.CALENDARS + "." + Calendars._ID; private static final String SQL_WHERE_ATTENDEES_ID = Tables.ATTENDEES + "." + Attendees._ID + "=? AND " + SQL_WHERE_ATTENDEE_BASE; private static final String SQL_WHERE_REMINDERS_ID = Tables.REMINDERS + "." + Reminders._ID + "=? AND " + Tables.EVENTS + "." + Events._ID + "=" + Tables.REMINDERS + "." + Reminders.EVENT_ID + " AND " + Tables.EVENTS + "." + Events.CALENDAR_ID + "=" + Tables.CALENDARS + "." + Calendars._ID; private static final String SQL_WHERE_CALENDAR_ALERT = Views.EVENTS + "." + Events._ID + "=" + Tables.CALENDAR_ALERTS + "." + CalendarAlerts.EVENT_ID; private static final String SQL_WHERE_CALENDAR_ALERT_ID = Views.EVENTS + "." + Events._ID + "=" + Tables.CALENDAR_ALERTS + "." + CalendarAlerts.EVENT_ID + " AND " + Tables.CALENDAR_ALERTS + "." + CalendarAlerts._ID + "=?"; private static final String SQL_WHERE_EXTENDED_PROPERTIES_ID = Tables.EXTENDED_PROPERTIES + "." + CalendarContract.ExtendedProperties._ID + "=?"; private static final String SQL_DELETE_FROM_CALENDARS = "DELETE FROM " + Tables.CALENDARS + " WHERE " + Calendars.ACCOUNT_NAME + "=? AND " + Calendars.ACCOUNT_TYPE + "=?"; private static final String SQL_DELETE_FROM_COLORS = "DELETE FROM " + Tables.COLORS + " WHERE " + Calendars.ACCOUNT_NAME + "=? AND " + Calendars.ACCOUNT_TYPE + "=?"; private static final String SQL_SELECT_COUNT_FOR_SYNC_ID = "SELECT COUNT(*) FROM " + Tables.EVENTS + " WHERE " + Events._SYNC_ID + "=?"; // Make sure we load at least two months worth of data. // Client apps can load more data in a background thread. private static final long MINIMUM_EXPANSION_SPAN = 2L * 31 * 24 * 60 * 60 * 1000; private static final String[] sCalendarsIdProjection = new String[] { Calendars._ID }; private static final int CALENDARS_INDEX_ID = 0; private static final String INSTANCE_QUERY_TABLES = CalendarDatabaseHelper.Tables.INSTANCES + " INNER JOIN " + CalendarDatabaseHelper.Views.EVENTS + " AS " + CalendarDatabaseHelper.Tables.EVENTS + " ON (" + CalendarDatabaseHelper.Tables.INSTANCES + "." + CalendarContract.Instances.EVENT_ID + "=" + CalendarDatabaseHelper.Tables.EVENTS + "." + CalendarContract.Events._ID + ")"; private static final String INSTANCE_SEARCH_QUERY_TABLES = "(" + CalendarDatabaseHelper.Tables.INSTANCES + " INNER JOIN " + CalendarDatabaseHelper.Views.EVENTS + " AS " + CalendarDatabaseHelper.Tables.EVENTS + " ON (" + CalendarDatabaseHelper.Tables.INSTANCES + "." + CalendarContract.Instances.EVENT_ID + "=" + CalendarDatabaseHelper.Tables.EVENTS + "." + CalendarContract.Events._ID + ")" + ") LEFT OUTER JOIN " + CalendarDatabaseHelper.Tables.ATTENDEES + " ON (" + CalendarDatabaseHelper.Tables.ATTENDEES + "." + CalendarContract.Attendees.EVENT_ID + "=" + CalendarDatabaseHelper.Tables.EVENTS + "." + CalendarContract.Events._ID + ")"; private static final String SQL_WHERE_INSTANCES_BETWEEN_DAY = CalendarContract.Instances.START_DAY + "<=? AND " + CalendarContract.Instances.END_DAY + ">=?"; private static final String SQL_WHERE_INSTANCES_BETWEEN = CalendarContract.Instances.BEGIN + "<=? AND " + CalendarContract.Instances.END + ">=?"; private static final int INSTANCES_INDEX_START_DAY = 0; private static final int INSTANCES_INDEX_END_DAY = 1; private static final int INSTANCES_INDEX_START_MINUTE = 2; private static final int INSTANCES_INDEX_END_MINUTE = 3; private static final int INSTANCES_INDEX_ALL_DAY = 4; /** * The sort order is: events with an earlier start time occur first and if * the start times are the same, then events with a later end time occur * first. The later end time is ordered first so that long-running events in * the calendar views appear first. If the start and end times of two events * are the same then we sort alphabetically on the title. This isn't * required for correctness, it just adds a nice touch. */ public static final String SORT_CALENDAR_VIEW = "begin ASC, end DESC, title ASC"; /** * A regex for describing how we split search queries into tokens. Keeps * quoted phrases as one token. "one \"two three\"" ==> ["one" "two three"] */ private static final Pattern SEARCH_TOKEN_PATTERN = Pattern.compile("[^\\s\"'.?!,]+|" // first part matches unquoted words + "\"([^\"]*)\""); // second part matches quoted phrases /** * A special character that was use to escape potentially problematic * characters in search queries. * * Note: do not use backslash for this, as it interferes with the regex * escaping mechanism. */ private static final String SEARCH_ESCAPE_CHAR = "#"; /** * A regex for matching any characters in an incoming search query that we * need to escape with {@link #SEARCH_ESCAPE_CHAR}, including the escape * character itself. */ private static final Pattern SEARCH_ESCAPE_PATTERN = Pattern.compile("([%_" + SEARCH_ESCAPE_CHAR + "])"); /** * Alias used for aggregate concatenation of attendee e-mails when grouping * attendees by instance. */ private static final String ATTENDEES_EMAIL_CONCAT = "group_concat(" + CalendarContract.Attendees.ATTENDEE_EMAIL + ")"; /** * Alias used for aggregate concatenation of attendee names when grouping * attendees by instance. */ private static final String ATTENDEES_NAME_CONCAT = "group_concat(" + CalendarContract.Attendees.ATTENDEE_NAME + ")"; private static final String[] SEARCH_COLUMNS = new String[] { CalendarContract.Events.TITLE, CalendarContract.Events.DESCRIPTION, CalendarContract.Events.EVENT_LOCATION, ATTENDEES_EMAIL_CONCAT, ATTENDEES_NAME_CONCAT }; /** * Arbitrary integer that we assign to the messages that we send to this * thread's handler, indicating that these are requests to send an update * notification intent. */ private static final int UPDATE_BROADCAST_MSG = 1; /** * Any requests to send a PROVIDER_CHANGED intent will be collapsed over * this window, to prevent spamming too many intents at once. */ private static final long UPDATE_BROADCAST_TIMEOUT_MILLIS = DateUtils.SECOND_IN_MILLIS; private static final long SYNC_UPDATE_BROADCAST_TIMEOUT_MILLIS = 30 * DateUtils.SECOND_IN_MILLIS; private static final HashSet ALLOWED_URI_PARAMETERS = Sets.newHashSet( CalendarContract.CALLER_IS_SYNCADAPTER, CalendarContract.EventsEntity.ACCOUNT_NAME, CalendarContract.EventsEntity.ACCOUNT_TYPE); /** Set of columns allowed to be altered when creating an exception to a recurring event. */ private static final HashSet ALLOWED_IN_EXCEPTION = new HashSet(); static { // _id, _sync_account, _sync_account_type, dirty, _sync_mark, calendar_id ALLOWED_IN_EXCEPTION.add(Events._SYNC_ID); ALLOWED_IN_EXCEPTION.add(Events.SYNC_DATA1); ALLOWED_IN_EXCEPTION.add(Events.SYNC_DATA7); ALLOWED_IN_EXCEPTION.add(Events.SYNC_DATA3); ALLOWED_IN_EXCEPTION.add(Events.TITLE); ALLOWED_IN_EXCEPTION.add(Events.EVENT_LOCATION); ALLOWED_IN_EXCEPTION.add(Events.DESCRIPTION); ALLOWED_IN_EXCEPTION.add(Events.EVENT_COLOR); ALLOWED_IN_EXCEPTION.add(Events.EVENT_COLOR_KEY); ALLOWED_IN_EXCEPTION.add(Events.STATUS); ALLOWED_IN_EXCEPTION.add(Events.SELF_ATTENDEE_STATUS); ALLOWED_IN_EXCEPTION.add(Events.SYNC_DATA6); ALLOWED_IN_EXCEPTION.add(Events.DTSTART); // dtend -- set from duration as part of creating the exception ALLOWED_IN_EXCEPTION.add(Events.EVENT_TIMEZONE); ALLOWED_IN_EXCEPTION.add(Events.EVENT_END_TIMEZONE); ALLOWED_IN_EXCEPTION.add(Events.DURATION); ALLOWED_IN_EXCEPTION.add(Events.ALL_DAY); ALLOWED_IN_EXCEPTION.add(Events.ACCESS_LEVEL); ALLOWED_IN_EXCEPTION.add(Events.AVAILABILITY); ALLOWED_IN_EXCEPTION.add(Events.HAS_ALARM); ALLOWED_IN_EXCEPTION.add(Events.HAS_EXTENDED_PROPERTIES); ALLOWED_IN_EXCEPTION.add(Events.RRULE); ALLOWED_IN_EXCEPTION.add(Events.RDATE); ALLOWED_IN_EXCEPTION.add(Events.EXRULE); ALLOWED_IN_EXCEPTION.add(Events.EXDATE); ALLOWED_IN_EXCEPTION.add(Events.ORIGINAL_SYNC_ID); ALLOWED_IN_EXCEPTION.add(Events.ORIGINAL_INSTANCE_TIME); // originalAllDay, lastDate ALLOWED_IN_EXCEPTION.add(Events.HAS_ATTENDEE_DATA); ALLOWED_IN_EXCEPTION.add(Events.GUESTS_CAN_MODIFY); ALLOWED_IN_EXCEPTION.add(Events.GUESTS_CAN_INVITE_OTHERS); ALLOWED_IN_EXCEPTION.add(Events.GUESTS_CAN_SEE_GUESTS); ALLOWED_IN_EXCEPTION.add(Events.ORGANIZER); ALLOWED_IN_EXCEPTION.add(Events.CUSTOM_APP_PACKAGE); ALLOWED_IN_EXCEPTION.add(Events.CUSTOM_APP_URI); ALLOWED_IN_EXCEPTION.add(Events.UID_2445); // deleted, original_id, alerts } /** Don't clone these from the base event into the exception event. */ private static final String[] DONT_CLONE_INTO_EXCEPTION = { Events._SYNC_ID, Events.SYNC_DATA1, Events.SYNC_DATA2, Events.SYNC_DATA3, Events.SYNC_DATA4, Events.SYNC_DATA5, Events.SYNC_DATA6, Events.SYNC_DATA7, Events.SYNC_DATA8, Events.SYNC_DATA9, Events.SYNC_DATA10, }; /** set to 'true' to enable debug logging for recurrence exception code */ private static final boolean DEBUG_EXCEPTION = false; private final ThreadLocal mCallingPackageErrorLogged = new ThreadLocal(); private Context mContext; private ContentResolver mContentResolver; @VisibleForTesting protected CalendarAlarmManager mCalendarAlarm; /** * Listens for timezone changes and disk-no-longer-full events */ private BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "onReceive() " + action); } if (Intent.ACTION_TIMEZONE_CHANGED.equals(action)) { updateTimezoneDependentFields(); mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); } else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(action)) { // Try to clean up if things were screwy due to a full disk updateTimezoneDependentFields(); mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); } else if (Intent.ACTION_TIME_CHANGED.equals(action)) { mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); } } }; /* Visible for testing */ @Override protected CalendarDatabaseHelper getDatabaseHelper(final Context context) { return CalendarDatabaseHelper.getInstance(context); } @Override public void shutdown() { if (mDbHelper != null) { mDbHelper.close(); mDbHelper = null; mDb = null; } } @Override public boolean onCreate() { super.onCreate(); setAppOps(AppOpsManager.OP_READ_CALENDAR, AppOpsManager.OP_WRITE_CALENDAR); try { return initialize(); } catch (RuntimeException e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Cannot start provider", e); } return false; } } private boolean initialize() { mContext = getContext(); mContentResolver = mContext.getContentResolver(); mDbHelper = (CalendarDatabaseHelper)getDatabaseHelper(); mDb = mDbHelper.getWritableDatabase(); mMetaData = new MetaData(mDbHelper); mInstancesHelper = new CalendarInstancesHelper(mDbHelper, mMetaData); // Register for Intent broadcasts IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); filter.addAction(Intent.ACTION_DEVICE_STORAGE_OK); filter.addAction(Intent.ACTION_TIME_CHANGED); // We don't ever unregister this because this thread always wants // to receive notifications, even in the background. And if this // thread is killed then the whole process will be killed and the // memory resources will be reclaimed. mContext.registerReceiver(mIntentReceiver, filter); mCalendarCache = new CalendarCache(mDbHelper); // This is pulled out for testing initCalendarAlarm(); postInitialize(); return true; } protected void initCalendarAlarm() { mCalendarAlarm = getOrCreateCalendarAlarmManager(); } synchronized CalendarAlarmManager getOrCreateCalendarAlarmManager() { if (mCalendarAlarm == null) { mCalendarAlarm = new CalendarAlarmManager(mContext); Log.i(TAG, "Created " + mCalendarAlarm + "(" + this + ")"); } return mCalendarAlarm; } protected void postInitialize() { Thread thread = new PostInitializeThread(); thread.start(); } private class PostInitializeThread extends Thread { @Override public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); verifyAccounts(); try { doUpdateTimezoneDependentFields(); } catch (IllegalStateException e) { // Added this because tests would fail if the provider is // closed by the time this is executed // Nothing actionable here anyways. } } } private void verifyAccounts() { AccountManager.get(getContext()).addOnAccountsUpdatedListener(this, null, false); removeStaleAccounts(AccountManager.get(getContext()).getAccounts()); } /** * This creates a background thread to check the timezone and update * the timezone dependent fields in the Instances table if the timezone * has changed. */ protected void updateTimezoneDependentFields() { Thread thread = new TimezoneCheckerThread(); thread.start(); } private class TimezoneCheckerThread extends Thread { @Override public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); doUpdateTimezoneDependentFields(); } } /** * Check if we are in the same time zone */ private boolean isLocalSameAsInstancesTimezone() { String localTimezone = TimeZone.getDefault().getID(); return TextUtils.equals(mCalendarCache.readTimezoneInstances(), localTimezone); } /** * This method runs in a background thread. If the timezone has changed * then the Instances table will be regenerated. */ protected void doUpdateTimezoneDependentFields() { try { String timezoneType = mCalendarCache.readTimezoneType(); // Nothing to do if we have the "home" timezone type (timezone is sticky) if (timezoneType != null && timezoneType.equals(CalendarCache.TIMEZONE_TYPE_HOME)) { return; } // We are here in "auto" mode, the timezone is coming from the device if (! isSameTimezoneDatabaseVersion()) { String localTimezone = TimeZone.getDefault().getID(); doProcessEventRawTimes(localTimezone, TimeUtils.getTimeZoneDatabaseVersion()); } if (isLocalSameAsInstancesTimezone()) { // Even if the timezone hasn't changed, check for missed alarms. // This code executes when the CalendarProvider2 is created and // helps to catch missed alarms when the Calendar process is // killed (because of low-memory conditions) and then restarted. mCalendarAlarm.rescheduleMissedAlarms(); } } catch (SQLException e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "doUpdateTimezoneDependentFields() failed", e); } try { // Clear at least the in-memory data (and if possible the // database fields) to force a re-computation of Instances. mMetaData.clearInstanceRange(); } catch (SQLException e2) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "clearInstanceRange() also failed: " + e2); } } } } protected void doProcessEventRawTimes(String localTimezone, String timeZoneDatabaseVersion) { mDb.beginTransaction(); try { updateEventsStartEndFromEventRawTimesLocked(); updateTimezoneDatabaseVersion(timeZoneDatabaseVersion); mCalendarCache.writeTimezoneInstances(localTimezone); regenerateInstancesTable(); mDb.setTransactionSuccessful(); } finally { mDb.endTransaction(); } } private void updateEventsStartEndFromEventRawTimesLocked() { Cursor cursor = mDb.rawQuery(SQL_SELECT_EVENTSRAWTIMES, null /* selection args */); try { while (cursor.moveToNext()) { long eventId = cursor.getLong(0); String dtStart2445 = cursor.getString(1); String dtEnd2445 = cursor.getString(2); String eventTimezone = cursor.getString(3); if (dtStart2445 == null && dtEnd2445 == null) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Event " + eventId + " has dtStart2445 and dtEnd2445 null " + "at the same time in EventsRawTimes!"); } continue; } updateEventsStartEndLocked(eventId, eventTimezone, dtStart2445, dtEnd2445); } } finally { cursor.close(); cursor = null; } } private long get2445ToMillis(String timezone, String dt2445) { if (null == dt2445) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Cannot parse null RFC2445 date"); } return 0; } Time time = (timezone != null) ? new Time(timezone) : new Time(); try { time.parse(dt2445); } catch (TimeFormatException e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Cannot parse RFC2445 date " + dt2445); } return 0; } return time.toMillis(true /* ignore DST */); } private void updateEventsStartEndLocked(long eventId, String timezone, String dtStart2445, String dtEnd2445) { ContentValues values = new ContentValues(); values.put(Events.DTSTART, get2445ToMillis(timezone, dtStart2445)); values.put(Events.DTEND, get2445ToMillis(timezone, dtEnd2445)); int result = mDb.update(Tables.EVENTS, values, SQL_WHERE_ID, new String[] {String.valueOf(eventId)}); if (0 == result) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Could not update Events table with values " + values); } } } private void updateTimezoneDatabaseVersion(String timeZoneDatabaseVersion) { try { mCalendarCache.writeTimezoneDatabaseVersion(timeZoneDatabaseVersion); } catch (CalendarCache.CacheException e) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Could not write timezone database version in the cache"); } } } /** * Check if the time zone database version is the same as the cached one */ protected boolean isSameTimezoneDatabaseVersion() { String timezoneDatabaseVersion = mCalendarCache.readTimezoneDatabaseVersion(); if (timezoneDatabaseVersion == null) { return false; } return TextUtils.equals(timezoneDatabaseVersion, TimeUtils.getTimeZoneDatabaseVersion()); } @VisibleForTesting protected String getTimezoneDatabaseVersion() { String timezoneDatabaseVersion = mCalendarCache.readTimezoneDatabaseVersion(); if (timezoneDatabaseVersion == null) { return ""; } if (Log.isLoggable(TAG, Log.INFO)) { Log.i(TAG, "timezoneDatabaseVersion = " + timezoneDatabaseVersion); } return timezoneDatabaseVersion; } private boolean isHomeTimezone() { final String type = mCalendarCache.readTimezoneType(); return CalendarCache.TIMEZONE_TYPE_HOME.equals(type); } private void regenerateInstancesTable() { // The database timezone is different from the current timezone. // Regenerate the Instances table for this month. Include events // starting at the beginning of this month. long now = System.currentTimeMillis(); String instancesTimezone = mCalendarCache.readTimezoneInstances(); Time time = new Time(instancesTimezone); time.set(now); time.monthDay = 1; time.hour = 0; time.minute = 0; time.second = 0; long begin = time.normalize(true); long end = begin + MINIMUM_EXPANSION_SPAN; Cursor cursor = null; try { cursor = handleInstanceQuery(new SQLiteQueryBuilder(), begin, end, new String[] { Instances._ID }, null /* selection */, null, null /* sort */, false /* searchByDayInsteadOfMillis */, true /* force Instances deletion and expansion */, instancesTimezone, isHomeTimezone()); } finally { if (cursor != null) { cursor.close(); } } mCalendarAlarm.rescheduleMissedAlarms(); } @Override protected void notifyChange(boolean syncToNetwork) { // Note that semantics are changed: notification is for CONTENT_URI, not the specific // Uri that was modified. mContentResolver.notifyChange(CalendarContract.CONTENT_URI, null, syncToNetwork); } /** * ALERT table is maintained locally so don't request a sync for changes in it */ @Override protected boolean shouldSyncFor(Uri uri) { final int match = sUriMatcher.match(uri); return !(match == CALENDAR_ALERTS || match == CALENDAR_ALERTS_ID || match == CALENDAR_ALERTS_BY_INSTANCE); } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { final long identity = clearCallingIdentityInternal(); try { return queryInternal(uri, projection, selection, selectionArgs, sortOrder); } finally { restoreCallingIdentityInternal(identity); } } private Cursor queryInternal(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "query uri - " + uri); } validateUriParameters(uri.getQueryParameterNames()); final SQLiteDatabase db = mDbHelper.getReadableDatabase(); SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); String groupBy = null; String limit = null; // Not currently implemented String instancesTimezone; final int match = sUriMatcher.match(uri); switch (match) { case SYNCSTATE: return mDbHelper.getSyncState().query(db, projection, selection, selectionArgs, sortOrder); case SYNCSTATE_ID: String selectionWithId = (SyncState._ID + "=?") + (selection == null ? "" : " AND (" + selection + ")"); // Prepend id to selectionArgs selectionArgs = insertSelectionArg(selectionArgs, String.valueOf(ContentUris.parseId(uri))); return mDbHelper.getSyncState().query(db, projection, selectionWithId, selectionArgs, sortOrder); case EVENTS: qb.setTables(CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sEventsProjectionMap); selection = appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE); selection = appendLastSyncedColumnToSelection(selection, uri); break; case EVENTS_ID: qb.setTables(CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sEventsProjectionMap); selectionArgs = insertSelectionArg(selectionArgs, uri.getPathSegments().get(1)); qb.appendWhere(SQL_WHERE_ID); break; case EVENT_ENTITIES: qb.setTables(CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sEventEntitiesProjectionMap); selection = appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE); selection = appendLastSyncedColumnToSelection(selection, uri); break; case EVENT_ENTITIES_ID: qb.setTables(CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sEventEntitiesProjectionMap); selectionArgs = insertSelectionArg(selectionArgs, uri.getPathSegments().get(1)); qb.appendWhere(SQL_WHERE_ID); break; case COLORS: qb.setTables(Tables.COLORS); qb.setProjectionMap(sColorsProjectionMap); selection = appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE); break; case CALENDARS: case CALENDAR_ENTITIES: qb.setTables(Tables.CALENDARS); qb.setProjectionMap(sCalendarsProjectionMap); selection = appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE); break; case CALENDARS_ID: case CALENDAR_ENTITIES_ID: qb.setTables(Tables.CALENDARS); qb.setProjectionMap(sCalendarsProjectionMap); selectionArgs = insertSelectionArg(selectionArgs, uri.getPathSegments().get(1)); qb.appendWhere(SQL_WHERE_ID); break; case INSTANCES: case INSTANCES_BY_DAY: long begin; long end; try { begin = Long.valueOf(uri.getPathSegments().get(2)); } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Cannot parse begin " + uri.getPathSegments().get(2)); } try { end = Long.valueOf(uri.getPathSegments().get(3)); } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Cannot parse end " + uri.getPathSegments().get(3)); } instancesTimezone = mCalendarCache.readTimezoneInstances(); return handleInstanceQuery(qb, begin, end, projection, selection, selectionArgs, sortOrder, match == INSTANCES_BY_DAY, false /* don't force an expansion */, instancesTimezone, isHomeTimezone()); case INSTANCES_SEARCH: case INSTANCES_SEARCH_BY_DAY: try { begin = Long.valueOf(uri.getPathSegments().get(2)); } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Cannot parse begin " + uri.getPathSegments().get(2)); } try { end = Long.valueOf(uri.getPathSegments().get(3)); } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Cannot parse end " + uri.getPathSegments().get(3)); } instancesTimezone = mCalendarCache.readTimezoneInstances(); // this is already decoded String query = uri.getPathSegments().get(4); return handleInstanceSearchQuery(qb, begin, end, query, projection, selection, selectionArgs, sortOrder, match == INSTANCES_SEARCH_BY_DAY, instancesTimezone, isHomeTimezone()); case EVENT_DAYS: int startDay; int endDay; try { startDay = Integer.parseInt(uri.getPathSegments().get(2)); } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Cannot parse start day " + uri.getPathSegments().get(2)); } try { endDay = Integer.parseInt(uri.getPathSegments().get(3)); } catch (NumberFormatException nfe) { throw new IllegalArgumentException("Cannot parse end day " + uri.getPathSegments().get(3)); } instancesTimezone = mCalendarCache.readTimezoneInstances(); return handleEventDayQuery(qb, startDay, endDay, projection, selection, instancesTimezone, isHomeTimezone()); case ATTENDEES: qb.setTables(Tables.ATTENDEES + ", " + Tables.EVENTS + ", " + Tables.CALENDARS); qb.setProjectionMap(sAttendeesProjectionMap); qb.appendWhere(SQL_WHERE_ATTENDEE_BASE); break; case ATTENDEES_ID: qb.setTables(Tables.ATTENDEES + ", " + Tables.EVENTS + ", " + Tables.CALENDARS); qb.setProjectionMap(sAttendeesProjectionMap); selectionArgs = insertSelectionArg(selectionArgs, uri.getPathSegments().get(1)); qb.appendWhere(SQL_WHERE_ATTENDEES_ID); break; case REMINDERS: qb.setTables(Tables.REMINDERS); break; case REMINDERS_ID: qb.setTables(Tables.REMINDERS + ", " + Tables.EVENTS + ", " + Tables.CALENDARS); qb.setProjectionMap(sRemindersProjectionMap); selectionArgs = insertSelectionArg(selectionArgs, uri.getLastPathSegment()); qb.appendWhere(SQL_WHERE_REMINDERS_ID); break; case CALENDAR_ALERTS: qb.setTables(Tables.CALENDAR_ALERTS + ", " + CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sCalendarAlertsProjectionMap); qb.appendWhere(SQL_WHERE_CALENDAR_ALERT); break; case CALENDAR_ALERTS_BY_INSTANCE: qb.setTables(Tables.CALENDAR_ALERTS + ", " + CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sCalendarAlertsProjectionMap); qb.appendWhere(SQL_WHERE_CALENDAR_ALERT); groupBy = CalendarAlerts.EVENT_ID + "," + CalendarAlerts.BEGIN; break; case CALENDAR_ALERTS_ID: qb.setTables(Tables.CALENDAR_ALERTS + ", " + CalendarDatabaseHelper.Views.EVENTS); qb.setProjectionMap(sCalendarAlertsProjectionMap); selectionArgs = insertSelectionArg(selectionArgs, uri.getLastPathSegment()); qb.appendWhere(SQL_WHERE_CALENDAR_ALERT_ID); break; case EXTENDED_PROPERTIES: qb.setTables(Tables.EXTENDED_PROPERTIES); break; case EXTENDED_PROPERTIES_ID: qb.setTables(Tables.EXTENDED_PROPERTIES); selectionArgs = insertSelectionArg(selectionArgs, uri.getPathSegments().get(1)); qb.appendWhere(SQL_WHERE_EXTENDED_PROPERTIES_ID); break; case PROVIDER_PROPERTIES: qb.setTables(Tables.CALENDAR_CACHE); qb.setProjectionMap(sCalendarCacheProjectionMap); break; default: throw new IllegalArgumentException("Unknown URL " + uri); } // run the query return query(db, qb, projection, selection, selectionArgs, sortOrder, groupBy, limit); } private void validateUriParameters(Set queryParameterNames) { final Set parameterNames = queryParameterNames; for (String parameterName : parameterNames) { if (!ALLOWED_URI_PARAMETERS.contains(parameterName)) { throw new IllegalArgumentException("Invalid URI parameter: " + parameterName); } } } private Cursor query(final SQLiteDatabase db, SQLiteQueryBuilder qb, String[] projection, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit) { if (projection != null && projection.length == 1 && BaseColumns._COUNT.equals(projection[0])) { qb.setProjectionMap(sCountProjectionMap); } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "query sql - projection: " + Arrays.toString(projection) + " selection: " + selection + " selectionArgs: " + Arrays.toString(selectionArgs) + " sortOrder: " + sortOrder + " groupBy: " + groupBy + " limit: " + limit); } final Cursor c = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit); if (c != null) { // TODO: is this the right notification Uri? c.setNotificationUri(mContentResolver, CalendarContract.Events.CONTENT_URI); } return c; } /* * Fills the Instances table, if necessary, for the given range and then * queries the Instances table. * * @param qb The query * @param rangeBegin start of range (Julian days or ms) * @param rangeEnd end of range (Julian days or ms) * @param projection The projection * @param selection The selection * @param sort How to sort * @param searchByDay if true, range is in Julian days, if false, range is in ms * @param forceExpansion force the Instance deletion and expansion if set to true * @param instancesTimezone timezone we need to use for computing the instances * @param isHomeTimezone if true, we are in the "home" timezone * @return */ private Cursor handleInstanceQuery(SQLiteQueryBuilder qb, long rangeBegin, long rangeEnd, String[] projection, String selection, String[] selectionArgs, String sort, boolean searchByDay, boolean forceExpansion, String instancesTimezone, boolean isHomeTimezone) { mDb = mDbHelper.getWritableDatabase(); qb.setTables(INSTANCE_QUERY_TABLES); qb.setProjectionMap(sInstancesProjectionMap); if (searchByDay) { // Convert the first and last Julian day range to a range that uses // UTC milliseconds. Time time = new Time(instancesTimezone); long beginMs = time.setJulianDay((int) rangeBegin); // We add one to lastDay because the time is set to 12am on the given // Julian day and we want to include all the events on the last day. long endMs = time.setJulianDay((int) rangeEnd + 1); // will lock the database. acquireInstanceRange(beginMs, endMs, true /* use minimum expansion window */, forceExpansion, instancesTimezone, isHomeTimezone); qb.appendWhere(SQL_WHERE_INSTANCES_BETWEEN_DAY); } else { // will lock the database. acquireInstanceRange(rangeBegin, rangeEnd, true /* use minimum expansion window */, forceExpansion, instancesTimezone, isHomeTimezone); qb.appendWhere(SQL_WHERE_INSTANCES_BETWEEN); } String[] newSelectionArgs = new String[] {String.valueOf(rangeEnd), String.valueOf(rangeBegin)}; if (selectionArgs == null) { selectionArgs = newSelectionArgs; } else { selectionArgs = combine(newSelectionArgs, selectionArgs); } return qb.query(mDb, projection, selection, selectionArgs, null /* groupBy */, null /* having */, sort); } /** * Combine a set of arrays in the order they are passed in. All arrays must * be of the same type. */ private static T[] combine(T[]... arrays) { if (arrays.length == 0) { throw new IllegalArgumentException("Must supply at least 1 array to combine"); } int totalSize = 0; for (T[] array : arrays) { totalSize += array.length; } T[] finalArray = (T[]) (Array.newInstance(arrays[0].getClass().getComponentType(), totalSize)); int currentPos = 0; for (T[] array : arrays) { int length = array.length; System.arraycopy(array, 0, finalArray, currentPos, length); currentPos += array.length; } return finalArray; } /** * Escape any special characters in the search token * @param token the token to escape * @return the escaped token */ @VisibleForTesting String escapeSearchToken(String token) { Matcher matcher = SEARCH_ESCAPE_PATTERN.matcher(token); return matcher.replaceAll(SEARCH_ESCAPE_CHAR + "$1"); } /** * Splits the search query into individual search tokens based on whitespace * and punctuation. Leaves both single quoted and double quoted strings * intact. * * @param query the search query * @return an array of tokens from the search query */ @VisibleForTesting String[] tokenizeSearchQuery(String query) { List matchList = new ArrayList(); Matcher matcher = SEARCH_TOKEN_PATTERN.matcher(query); String token; while (matcher.find()) { if (matcher.group(1) != null) { // double quoted string token = matcher.group(1); } else { // unquoted token token = matcher.group(); } matchList.add(escapeSearchToken(token)); } return matchList.toArray(new String[matchList.size()]); } /** * In order to support what most people would consider a reasonable * search behavior, we have to do some interesting things here. We * assume that when a user searches for something like "lunch meeting", * they really want any event that matches both "lunch" and "meeting", * not events that match the string "lunch meeting" itself. In order to * do this across multiple columns, we have to construct a WHERE clause * that looks like: * * WHERE (title LIKE "%lunch%" * OR description LIKE "%lunch%" * OR eventLocation LIKE "%lunch%") * AND (title LIKE "%meeting%" * OR description LIKE "%meeting%" * OR eventLocation LIKE "%meeting%") * * This "product of clauses" is a bit ugly, but produced a fairly good * approximation of full-text search across multiple columns. The set * of columns is specified by the SEARCH_COLUMNS constant. *

* Note the "WHERE" token isn't part of the returned string. The value * may be passed into a query as the "HAVING" clause. */ @VisibleForTesting String constructSearchWhere(String[] tokens) { if (tokens.length == 0) { return ""; } StringBuilder sb = new StringBuilder(); String column, token; for (int j = 0; j < tokens.length; j++) { sb.append("("); for (int i = 0; i < SEARCH_COLUMNS.length; i++) { sb.append(SEARCH_COLUMNS[i]); sb.append(" LIKE ? ESCAPE \""); sb.append(SEARCH_ESCAPE_CHAR); sb.append("\" "); if (i < SEARCH_COLUMNS.length - 1) { sb.append("OR "); } } sb.append(")"); if (j < tokens.length - 1) { sb.append(" AND "); } } return sb.toString(); } @VisibleForTesting String[] constructSearchArgs(String[] tokens) { int numCols = SEARCH_COLUMNS.length; int numArgs = tokens.length * numCols; String[] selectionArgs = new String[numArgs]; for (int j = 0; j < tokens.length; j++) { int start = numCols * j; for (int i = start; i < start + numCols; i++) { selectionArgs[i] = "%" + tokens[j] + "%"; } } return selectionArgs; } private Cursor handleInstanceSearchQuery(SQLiteQueryBuilder qb, long rangeBegin, long rangeEnd, String query, String[] projection, String selection, String[] selectionArgs, String sort, boolean searchByDay, String instancesTimezone, boolean isHomeTimezone) { mDb = mDbHelper.getWritableDatabase(); qb.setTables(INSTANCE_SEARCH_QUERY_TABLES); qb.setProjectionMap(sInstancesProjectionMap); String[] tokens = tokenizeSearchQuery(query); String[] searchArgs = constructSearchArgs(tokens); String[] timeRange = new String[] {String.valueOf(rangeEnd), String.valueOf(rangeBegin)}; if (selectionArgs == null) { selectionArgs = combine(timeRange, searchArgs); } else { // where clause comes first, so put selectionArgs before searchArgs. selectionArgs = combine(timeRange, selectionArgs, searchArgs); } // we pass this in as a HAVING instead of a WHERE so the filtering // happens after the grouping String searchWhere = constructSearchWhere(tokens); if (searchByDay) { // Convert the first and last Julian day range to a range that uses // UTC milliseconds. Time time = new Time(instancesTimezone); long beginMs = time.setJulianDay((int) rangeBegin); // We add one to lastDay because the time is set to 12am on the given // Julian day and we want to include all the events on the last day. long endMs = time.setJulianDay((int) rangeEnd + 1); // will lock the database. // we expand the instances here because we might be searching over // a range where instance expansion has not occurred yet acquireInstanceRange(beginMs, endMs, true /* use minimum expansion window */, false /* do not force Instances deletion and expansion */, instancesTimezone, isHomeTimezone ); qb.appendWhere(SQL_WHERE_INSTANCES_BETWEEN_DAY); } else { // will lock the database. // we expand the instances here because we might be searching over // a range where instance expansion has not occurred yet acquireInstanceRange(rangeBegin, rangeEnd, true /* use minimum expansion window */, false /* do not force Instances deletion and expansion */, instancesTimezone, isHomeTimezone ); qb.appendWhere(SQL_WHERE_INSTANCES_BETWEEN); } return qb.query(mDb, projection, selection, selectionArgs, Tables.INSTANCES + "." + Instances._ID /* groupBy */, searchWhere /* having */, sort); } private Cursor handleEventDayQuery(SQLiteQueryBuilder qb, int begin, int end, String[] projection, String selection, String instancesTimezone, boolean isHomeTimezone) { mDb = mDbHelper.getWritableDatabase(); qb.setTables(INSTANCE_QUERY_TABLES); qb.setProjectionMap(sInstancesProjectionMap); // Convert the first and last Julian day range to a range that uses // UTC milliseconds. Time time = new Time(instancesTimezone); long beginMs = time.setJulianDay(begin); // We add one to lastDay because the time is set to 12am on the given // Julian day and we want to include all the events on the last day. long endMs = time.setJulianDay(end + 1); acquireInstanceRange(beginMs, endMs, true, false /* do not force Instances expansion */, instancesTimezone, isHomeTimezone); qb.appendWhere(SQL_WHERE_INSTANCES_BETWEEN_DAY); String selectionArgs[] = new String[] {String.valueOf(end), String.valueOf(begin)}; return qb.query(mDb, projection, selection, selectionArgs, Instances.START_DAY /* groupBy */, null /* having */, null); } /** * Ensure that the date range given has all elements in the instance * table. Acquires the database lock and calls * {@link #acquireInstanceRangeLocked(long, long, boolean, boolean, String, boolean)}. * * @param begin start of range (ms) * @param end end of range (ms) * @param useMinimumExpansionWindow expand by at least MINIMUM_EXPANSION_SPAN * @param forceExpansion force the Instance deletion and expansion if set to true * @param instancesTimezone timezone we need to use for computing the instances * @param isHomeTimezone if true, we are in the "home" timezone */ private void acquireInstanceRange(final long begin, final long end, final boolean useMinimumExpansionWindow, final boolean forceExpansion, final String instancesTimezone, final boolean isHomeTimezone) { mDb.beginTransaction(); try { acquireInstanceRangeLocked(begin, end, useMinimumExpansionWindow, forceExpansion, instancesTimezone, isHomeTimezone); mDb.setTransactionSuccessful(); } finally { mDb.endTransaction(); } } /** * Ensure that the date range given has all elements in the instance * table. The database lock must be held when calling this method. * * @param begin start of range (ms) * @param end end of range (ms) * @param useMinimumExpansionWindow expand by at least MINIMUM_EXPANSION_SPAN * @param forceExpansion force the Instance deletion and expansion if set to true * @param instancesTimezone timezone we need to use for computing the instances * @param isHomeTimezone if true, we are in the "home" timezone */ void acquireInstanceRangeLocked(long begin, long end, boolean useMinimumExpansionWindow, boolean forceExpansion, String instancesTimezone, boolean isHomeTimezone) { long expandBegin = begin; long expandEnd = end; if (DEBUG_INSTANCES) { Log.d(TAG + "-i", "acquireInstanceRange begin=" + begin + " end=" + end + " useMin=" + useMinimumExpansionWindow + " force=" + forceExpansion); } if (instancesTimezone == null) { Log.e(TAG, "Cannot run acquireInstanceRangeLocked() because instancesTimezone is null"); return; } if (useMinimumExpansionWindow) { // if we end up having to expand events into the instances table, expand // events for a minimal amount of time, so we do not have to perform // expansions frequently. long span = end - begin; if (span < MINIMUM_EXPANSION_SPAN) { long additionalRange = (MINIMUM_EXPANSION_SPAN - span) / 2; expandBegin -= additionalRange; expandEnd += additionalRange; } } // Check if the timezone has changed. // We do this check here because the database is locked and we can // safely delete all the entries in the Instances table. MetaData.Fields fields = mMetaData.getFieldsLocked(); long maxInstance = fields.maxInstance; long minInstance = fields.minInstance; boolean timezoneChanged; if (isHomeTimezone) { String previousTimezone = mCalendarCache.readTimezoneInstancesPrevious(); timezoneChanged = !instancesTimezone.equals(previousTimezone); } else { String localTimezone = TimeZone.getDefault().getID(); timezoneChanged = !instancesTimezone.equals(localTimezone); // if we're in auto make sure we are using the device time zone if (timezoneChanged) { instancesTimezone = localTimezone; } } // if "home", then timezoneChanged only if current != previous // if "auto", then timezoneChanged, if !instancesTimezone.equals(localTimezone); if (maxInstance == 0 || timezoneChanged || forceExpansion) { if (DEBUG_INSTANCES) { Log.d(TAG + "-i", "Wiping instances and expanding from scratch"); } // Empty the Instances table and expand from scratch. mDb.execSQL("DELETE FROM " + Tables.INSTANCES + ";"); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "acquireInstanceRangeLocked() deleted Instances," + " timezone changed: " + timezoneChanged); } mInstancesHelper.expandInstanceRangeLocked(expandBegin, expandEnd, instancesTimezone); mMetaData.writeLocked(instancesTimezone, expandBegin, expandEnd); final String timezoneType = mCalendarCache.readTimezoneType(); // This may cause some double writes but guarantees the time zone in // the db and the time zone the instances are in is the same, which // future changes may affect. mCalendarCache.writeTimezoneInstances(instancesTimezone); // If we're in auto check if we need to fix the previous tz value if (CalendarCache.TIMEZONE_TYPE_AUTO.equals(timezoneType)) { String prevTZ = mCalendarCache.readTimezoneInstancesPrevious(); if (TextUtils.equals(TIMEZONE_GMT, prevTZ)) { mCalendarCache.writeTimezoneInstancesPrevious(instancesTimezone); } } return; } // If the desired range [begin, end] has already been // expanded, then simply return. The range is inclusive, that is, // events that touch either endpoint are included in the expansion. // This means that a zero-duration event that starts and ends at // the endpoint will be included. // We use [begin, end] here and not [expandBegin, expandEnd] for // checking the range because a common case is for the client to // request successive days or weeks, for example. If we checked // that the expanded range [expandBegin, expandEnd] then we would // always be expanding because there would always be one more day // or week that hasn't been expanded. if ((begin >= minInstance) && (end <= maxInstance)) { if (DEBUG_INSTANCES) { Log.d(TAG + "-i", "instances are already expanded"); } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Canceled instance query (" + expandBegin + ", " + expandEnd + ") falls within previously expanded range."); } return; } // If the requested begin point has not been expanded, then include // more events than requested in the expansion (use "expandBegin"). if (begin < minInstance) { mInstancesHelper.expandInstanceRangeLocked(expandBegin, minInstance, instancesTimezone); minInstance = expandBegin; } // If the requested end point has not been expanded, then include // more events than requested in the expansion (use "expandEnd"). if (end > maxInstance) { mInstancesHelper.expandInstanceRangeLocked(maxInstance, expandEnd, instancesTimezone); maxInstance = expandEnd; } // Update the bounds on the Instances table. mMetaData.writeLocked(instancesTimezone, minInstance, maxInstance); } @Override public String getType(Uri url) { int match = sUriMatcher.match(url); switch (match) { case EVENTS: return "vnd.android.cursor.dir/event"; case EVENTS_ID: return "vnd.android.cursor.item/event"; case REMINDERS: return "vnd.android.cursor.dir/reminder"; case REMINDERS_ID: return "vnd.android.cursor.item/reminder"; case CALENDAR_ALERTS: return "vnd.android.cursor.dir/calendar-alert"; case CALENDAR_ALERTS_BY_INSTANCE: return "vnd.android.cursor.dir/calendar-alert-by-instance"; case CALENDAR_ALERTS_ID: return "vnd.android.cursor.item/calendar-alert"; case INSTANCES: case INSTANCES_BY_DAY: case EVENT_DAYS: return "vnd.android.cursor.dir/event-instance"; case TIME: return "time/epoch"; case PROVIDER_PROPERTIES: return "vnd.android.cursor.dir/property"; default: throw new IllegalArgumentException("Unknown URL " + url); } } /** * Determines if the event is recurrent, based on the provided values. */ public static boolean isRecurrenceEvent(String rrule, String rdate, String originalId, String originalSyncId) { return (!TextUtils.isEmpty(rrule) || !TextUtils.isEmpty(rdate) || !TextUtils.isEmpty(originalId) || !TextUtils.isEmpty(originalSyncId)); } /** * Takes an event and corrects the hrs, mins, secs if it is an allDay event. *

* AllDay events should have hrs, mins, secs set to zero. This checks if this is true and * corrects the fields DTSTART, DTEND, and DURATION if necessary. * * @param values The values to check and correct * @param modValues Any updates will be stored here. This may be the same object as * values. * @return Returns true if a correction was necessary, false otherwise */ private boolean fixAllDayTime(ContentValues values, ContentValues modValues) { Integer allDayObj = values.getAsInteger(Events.ALL_DAY); if (allDayObj == null || allDayObj == 0) { return false; } boolean neededCorrection = false; Long dtstart = values.getAsLong(Events.DTSTART); Long dtend = values.getAsLong(Events.DTEND); String duration = values.getAsString(Events.DURATION); Time time = new Time(); String tempValue; // Change dtstart so h,m,s are 0 if necessary. time.clear(Time.TIMEZONE_UTC); time.set(dtstart.longValue()); if (time.hour != 0 || time.minute != 0 || time.second != 0) { time.hour = 0; time.minute = 0; time.second = 0; modValues.put(Events.DTSTART, time.toMillis(true)); neededCorrection = true; } // If dtend exists for this event make sure it's h,m,s are 0. if (dtend != null) { time.clear(Time.TIMEZONE_UTC); time.set(dtend.longValue()); if (time.hour != 0 || time.minute != 0 || time.second != 0) { time.hour = 0; time.minute = 0; time.second = 0; dtend = time.toMillis(true); modValues.put(Events.DTEND, dtend); neededCorrection = true; } } if (duration != null) { int len = duration.length(); /* duration is stored as either "PS" or "PD". This checks if it's * in the seconds format, and if so converts it to days. */ if (len == 0) { duration = null; } else if (duration.charAt(0) == 'P' && duration.charAt(len - 1) == 'S') { int seconds = Integer.parseInt(duration.substring(1, len - 1)); int days = (seconds + DAY_IN_SECONDS - 1) / DAY_IN_SECONDS; duration = "P" + days + "D"; modValues.put(Events.DURATION, duration); neededCorrection = true; } } return neededCorrection; } /** * Determines whether the strings in the set name columns that may be overridden * when creating a recurring event exception. *

* This uses a white list because it screens out unknown columns and is a bit safer to * maintain than a black list. */ private void checkAllowedInException(Set keys) { for (String str : keys) { if (!ALLOWED_IN_EXCEPTION.contains(str.intern())) { throw new IllegalArgumentException("Exceptions can't overwrite " + str); } } } /** * Splits a recurrent event at a specified instance. This is useful when modifying "this * and all future events". *

* If the recurrence rule has a COUNT specified, we need to split that at the point of the * exception. If the exception is instance N (0-based), the original COUNT is reduced * to N, and the exception's COUNT is set to (COUNT - N). *

* If the recurrence doesn't have a COUNT, we need to update or introduce an UNTIL value, * so that the original recurrence will end just before the exception instance. (Note * that UNTIL dates are inclusive.) *

* This should not be used to update the first instance ("update all events" action). * * @param values The original event values; must include EVENT_TIMEZONE and DTSTART. * The RRULE value may be modified (with the expectation that this will propagate * into the exception event). * @param endTimeMillis The time before which the event must end (i.e. the start time of the * exception event instance). * @return Values to apply to the original event. */ private static ContentValues setRecurrenceEnd(ContentValues values, long endTimeMillis) { boolean origAllDay = values.getAsBoolean(Events.ALL_DAY); String origRrule = values.getAsString(Events.RRULE); EventRecurrence origRecurrence = new EventRecurrence(); origRecurrence.parse(origRrule); // Get the start time of the first instance in the original recurrence. long startTimeMillis = values.getAsLong(Events.DTSTART); Time dtstart = new Time(); dtstart.timezone = values.getAsString(Events.EVENT_TIMEZONE); dtstart.set(startTimeMillis); ContentValues updateValues = new ContentValues(); if (origRecurrence.count > 0) { /* * Generate the full set of instances for this recurrence, from the first to the * one just before endTimeMillis. The list should never be empty, because this method * should not be called for the first instance. All we're really interested in is * the *number* of instances found. */ RecurrenceSet recurSet = new RecurrenceSet(values); RecurrenceProcessor recurProc = new RecurrenceProcessor(); long[] recurrences; try { recurrences = recurProc.expand(dtstart, recurSet, startTimeMillis, endTimeMillis); } catch (DateException de) { throw new RuntimeException(de); } if (recurrences.length == 0) { throw new RuntimeException("can't use this method on first instance"); } EventRecurrence excepRecurrence = new EventRecurrence(); excepRecurrence.parse(origRrule); // TODO: add/use a copy constructor to EventRecurrence excepRecurrence.count -= recurrences.length; values.put(Events.RRULE, excepRecurrence.toString()); origRecurrence.count = recurrences.length; } else { Time untilTime = new Time(); // The "until" time must be in UTC time in order for Google calendar // to display it properly. For all-day events, the "until" time string // must include just the date field, and not the time field. The // repeating events repeat up to and including the "until" time. untilTime.timezone = Time.TIMEZONE_UTC; // Subtract one second from the exception begin time to get the "until" time. untilTime.set(endTimeMillis - 1000); // subtract one second (1000 millis) if (origAllDay) { untilTime.hour = untilTime.minute = untilTime.second = 0; untilTime.allDay = true; untilTime.normalize(false); // This should no longer be necessary -- DTSTART should already be in the correct // format for an all-day event. dtstart.hour = dtstart.minute = dtstart.second = 0; dtstart.allDay = true; dtstart.timezone = Time.TIMEZONE_UTC; } origRecurrence.until = untilTime.format2445(); } updateValues.put(Events.RRULE, origRecurrence.toString()); updateValues.put(Events.DTSTART, dtstart.normalize(true)); return updateValues; } /** * Handles insertion of an exception to a recurring event. *

* There are two modes, selected based on the presence of "rrule" in modValues: *

    *
  1. Create a single instance exception ("modify current event only"). *
  2. Cap the original event, and create a new recurring event ("modify this and all * future events"). *
* This may be used for "modify all instances of the event" by simply selecting the * very first instance as the exception target. In that case, the ID of the "new" * exception event will be the same as the originalEventId. * * @param originalEventId The _id of the event to be modified * @param modValues Event columns to update * @param callerIsSyncAdapter Set if the content provider client is the sync adapter * @return the ID of the new "exception" event, or -1 on failure */ private long handleInsertException(long originalEventId, ContentValues modValues, boolean callerIsSyncAdapter) { if (DEBUG_EXCEPTION) { Log.i(TAG, "RE: values: " + modValues.toString()); } // Make sure they have specified an instance via originalInstanceTime. Long originalInstanceTime = modValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); if (originalInstanceTime == null) { throw new IllegalArgumentException("Exceptions must specify " + Events.ORIGINAL_INSTANCE_TIME); } // Check for attempts to override values that shouldn't be touched. checkAllowedInException(modValues.keySet()); // If this isn't the sync adapter, set the "dirty" flag in any Event we modify. if (!callerIsSyncAdapter) { modValues.put(Events.DIRTY, true); addMutator(modValues, Events.MUTATORS); } // Wrap all database accesses in a transaction. mDb.beginTransaction(); Cursor cursor = null; try { // TODO: verify that there's an instance corresponding to the specified time // (does this matter? it's weird, but not fatal?) // Grab the full set of columns for this event. cursor = mDb.query(Tables.EVENTS, null /* columns */, SQL_WHERE_ID, new String[] { String.valueOf(originalEventId) }, null /* groupBy */, null /* having */, null /* sortOrder */); if (cursor.getCount() != 1) { Log.e(TAG, "Original event ID " + originalEventId + " lookup failed (count is " + cursor.getCount() + ")"); return -1; } //DatabaseUtils.dumpCursor(cursor); // If there's a color index check that it's valid String color_index = modValues.getAsString(Events.EVENT_COLOR_KEY); if (!TextUtils.isEmpty(color_index)) { int calIdCol = cursor.getColumnIndex(Events.CALENDAR_ID); Long calId = cursor.getLong(calIdCol); String accountName = null; String accountType = null; if (calId != null) { Account account = getAccount(calId); if (account != null) { accountName = account.name; accountType = account.type; } } verifyColorExists(accountName, accountType, color_index, Colors.TYPE_EVENT); } /* * Verify that the original event is in fact a recurring event by checking for the * presence of an RRULE. If it's there, we assume that the event is otherwise * properly constructed (e.g. no DTEND). */ cursor.moveToFirst(); int rruleCol = cursor.getColumnIndex(Events.RRULE); if (TextUtils.isEmpty(cursor.getString(rruleCol))) { Log.e(TAG, "Original event has no rrule"); return -1; } if (DEBUG_EXCEPTION) { Log.d(TAG, "RE: old RRULE is " + cursor.getString(rruleCol)); } // Verify that the original event is not itself a (single-instance) exception. int originalIdCol = cursor.getColumnIndex(Events.ORIGINAL_ID); if (!TextUtils.isEmpty(cursor.getString(originalIdCol))) { Log.e(TAG, "Original event is an exception"); return -1; } boolean createSingleException = TextUtils.isEmpty(modValues.getAsString(Events.RRULE)); // TODO: check for the presence of an existing exception on this event+instance? // The caller should be modifying that, not creating another exception. // (Alternatively, we could do that for them.) // Create a new ContentValues for the new event. Start with the original event, // and drop in the new caller-supplied values. This will set originalInstanceTime. ContentValues values = new ContentValues(); DatabaseUtils.cursorRowToContentValues(cursor, values); cursor.close(); cursor = null; // TODO: if we're changing this to an all-day event, we should ensure that // hours/mins/secs on DTSTART are zeroed out (before computing DTEND). // See fixAllDayTime(). boolean createNewEvent = true; if (createSingleException) { /* * Save a copy of a few fields that will migrate to new places. */ String _id = values.getAsString(Events._ID); String _sync_id = values.getAsString(Events._SYNC_ID); boolean allDay = values.getAsBoolean(Events.ALL_DAY); /* * Wipe out some fields that we don't want to clone into the exception event. */ for (String str : DONT_CLONE_INTO_EXCEPTION) { values.remove(str); } /* * Merge the new values on top of the existing values. Note this sets * originalInstanceTime. */ values.putAll(modValues); /* * Copy some fields to their "original" counterparts: * _id --> original_id * _sync_id --> original_sync_id * allDay --> originalAllDay * * If this event hasn't been sync'ed with the server yet, the _sync_id field will * be null. We will need to fill original_sync_id in later. (May not be able to * do it right when our own _sync_id field gets populated, because the order of * events from the server may not be what we want -- could update the exception * before updating the original event.) * * _id is removed later (right before we write the event). */ values.put(Events.ORIGINAL_ID, _id); values.put(Events.ORIGINAL_SYNC_ID, _sync_id); values.put(Events.ORIGINAL_ALL_DAY, allDay); // Mark the exception event status as "tentative", unless the caller has some // other value in mind (like STATUS_CANCELED). if (!values.containsKey(Events.STATUS)) { values.put(Events.STATUS, Events.STATUS_TENTATIVE); } // We're converting from recurring to non-recurring. // Clear out RRULE, RDATE, EXRULE & EXDATE // Replace DURATION with DTEND. values.remove(Events.RRULE); values.remove(Events.RDATE); values.remove(Events.EXRULE); values.remove(Events.EXDATE); Duration duration = new Duration(); String durationStr = values.getAsString(Events.DURATION); try { duration.parse(durationStr); } catch (Exception ex) { // NullPointerException if the original event had no duration. // DateException if the duration was malformed. Log.w(TAG, "Bad duration in recurring event: " + durationStr, ex); return -1; } /* * We want to compute DTEND as an offset from the start time of the instance. * If the caller specified a new value for DTSTART, we want to use that; if not, * the DTSTART in "values" will be the start time of the first instance in the * recurrence, so we want to replace it with ORIGINAL_INSTANCE_TIME. */ long start; if (modValues.containsKey(Events.DTSTART)) { start = values.getAsLong(Events.DTSTART); } else { start = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME); values.put(Events.DTSTART, start); } values.put(Events.DTEND, start + duration.getMillis()); if (DEBUG_EXCEPTION) { Log.d(TAG, "RE: ORIG_INST_TIME=" + start + ", duration=" + duration.getMillis() + ", generated DTEND=" + values.getAsLong(Events.DTEND)); } values.remove(Events.DURATION); } else { /* * We're going to "split" the recurring event, making the old one stop before * this instance, and creating a new recurring event that starts here. * * No need to fill out the "original" fields -- the new event is not tied to * the previous event in any way. * * If this is the first event in the series, we can just update the existing * event with the values. */ boolean canceling = (values.getAsInteger(Events.STATUS) == Events.STATUS_CANCELED); if (originalInstanceTime.equals(values.getAsLong(Events.DTSTART))) { /* * Update fields in the existing event. Rather than use the merged data * from the cursor, we just do the update with the new value set after * removing the ORIGINAL_INSTANCE_TIME entry. */ if (canceling) { // TODO: should we just call deleteEventInternal? Log.d(TAG, "Note: canceling entire event via exception call"); } if (DEBUG_EXCEPTION) { Log.d(TAG, "RE: updating full event"); } if (!validateRecurrenceRule(modValues)) { throw new IllegalArgumentException("Invalid recurrence rule: " + values.getAsString(Events.RRULE)); } modValues.remove(Events.ORIGINAL_INSTANCE_TIME); mDb.update(Tables.EVENTS, modValues, SQL_WHERE_ID, new String[] { Long.toString(originalEventId) }); createNewEvent = false; // skip event creation and related-table cloning } else { if (DEBUG_EXCEPTION) { Log.d(TAG, "RE: splitting event"); } /* * Cap the original event so it ends just before the target instance. In * some cases (nonzero COUNT) this will also update the RRULE in "values", * so that the exception we're creating terminates appropriately. If a * new RRULE was specified by the caller, the new rule will overwrite our * changes when we merge the new values in below (which is the desired * behavior). */ ContentValues splitValues = setRecurrenceEnd(values, originalInstanceTime); mDb.update(Tables.EVENTS, splitValues, SQL_WHERE_ID, new String[] { Long.toString(originalEventId) }); /* * Prepare the new event. We remove originalInstanceTime, because we're now * creating a new event rather than an exception. * * We're always cloning a non-exception event (we tested to make sure the * event doesn't specify original_id, and we don't allow original_id in the * modValues), so we shouldn't end up creating a new event that looks like * an exception. */ values.putAll(modValues); values.remove(Events.ORIGINAL_INSTANCE_TIME); } } long newEventId; if (createNewEvent) { values.remove(Events._ID); // don't try to set this explicitly if (callerIsSyncAdapter) { scrubEventData(values, null); } else { validateEventData(values); } newEventId = mDb.insert(Tables.EVENTS, null, values); if (newEventId < 0) { Log.w(TAG, "Unable to add exception to recurring event"); Log.w(TAG, "Values: " + values); return -1; } if (DEBUG_EXCEPTION) { Log.d(TAG, "RE: new ID is " + newEventId); } // TODO: do we need to do something like this? //updateEventRawTimesLocked(id, updatedValues); /* * Force re-computation of the Instances associated with the recurrence event. */ mInstancesHelper.updateInstancesLocked(values, newEventId, true, mDb); /* * Some of the other tables (Attendees, Reminders, ExtendedProperties) reference * the Event ID. We need to copy the entries from the old event, filling in the * new event ID, so that somebody doing a SELECT on those tables will find * matching entries. */ CalendarDatabaseHelper.copyEventRelatedTables(mDb, newEventId, originalEventId); /* * If we modified Event.selfAttendeeStatus, we need to keep the corresponding * entry in the Attendees table in sync. */ if (modValues.containsKey(Events.SELF_ATTENDEE_STATUS)) { /* * Each Attendee is identified by email address. To find the entry that * corresponds to "self", we want to compare that address to the owner of * the Calendar. We're expecting to find one matching entry in Attendees. */ long calendarId = values.getAsLong(Events.CALENDAR_ID); String accountName = getOwner(calendarId); if (accountName != null) { ContentValues attValues = new ContentValues(); attValues.put(Attendees.ATTENDEE_STATUS, modValues.getAsString(Events.SELF_ATTENDEE_STATUS)); if (DEBUG_EXCEPTION) { Log.d(TAG, "Updating attendee status for event=" + newEventId + " name=" + accountName + " to " + attValues.getAsString(Attendees.ATTENDEE_STATUS)); } int count = mDb.update(Tables.ATTENDEES, attValues, Attendees.EVENT_ID + "=? AND " + Attendees.ATTENDEE_EMAIL + "=?", new String[] { String.valueOf(newEventId), accountName }); if (count != 1 && count != 2) { // We're only expecting one matching entry. We might briefly see // two during a server sync. Log.e(TAG, "Attendee status update on event=" + newEventId + " touched " + count + " rows. Expected one or two rows."); if (false) { // This dumps PII in the log, don't ship with it enabled. Cursor debugCursor = mDb.query(Tables.ATTENDEES, null, Attendees.EVENT_ID + "=? AND " + Attendees.ATTENDEE_EMAIL + "=?", new String[] { String.valueOf(newEventId), accountName }, null, null, null); DatabaseUtils.dumpCursor(debugCursor); if (debugCursor != null) { debugCursor.close(); } } throw new RuntimeException("Status update WTF"); } } } } else { /* * Update any Instances changed by the update to this Event. */ mInstancesHelper.updateInstancesLocked(values, originalEventId, false, mDb); newEventId = originalEventId; } mDb.setTransactionSuccessful(); return newEventId; } finally { if (cursor != null) { cursor.close(); } mDb.endTransaction(); } } /** * Fills in the originalId column for previously-created exceptions to this event. If * this event is not recurring or does not have a _sync_id, this does nothing. *

* The server might send exceptions before the event they refer to. When * this happens, the originalId field will not have been set in the * exception events (it's the recurrence events' _id field, so it can't be * known until the recurrence event is created). When we add a recurrence * event with a non-empty _sync_id field, we write that event's _id to the * originalId field of any events whose originalSyncId matches _sync_id. *

* Note _sync_id is only expected to be unique within a particular calendar. * * @param id The ID of the Event * @param values Values for the Event being inserted */ private void backfillExceptionOriginalIds(long id, ContentValues values) { String syncId = values.getAsString(Events._SYNC_ID); String rrule = values.getAsString(Events.RRULE); String rdate = values.getAsString(Events.RDATE); String calendarId = values.getAsString(Events.CALENDAR_ID); if (TextUtils.isEmpty(syncId) || TextUtils.isEmpty(calendarId) || (TextUtils.isEmpty(rrule) && TextUtils.isEmpty(rdate))) { // Not a recurring event, or doesn't have a server-provided sync ID. return; } ContentValues originalValues = new ContentValues(); originalValues.put(Events.ORIGINAL_ID, id); mDb.update(Tables.EVENTS, originalValues, Events.ORIGINAL_SYNC_ID + "=? AND " + Events.CALENDAR_ID + "=?", new String[] { syncId, calendarId }); } @Override protected Uri insertInTransaction(Uri uri, ContentValues values, boolean callerIsSyncAdapter) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "insertInTransaction: " + uri); } validateUriParameters(uri.getQueryParameterNames()); final int match = sUriMatcher.match(uri); verifyTransactionAllowed(TRANSACTION_INSERT, uri, values, callerIsSyncAdapter, match, null /* selection */, null /* selection args */); mDb = mDbHelper.getWritableDatabase(); long id = 0; switch (match) { case SYNCSTATE: id = mDbHelper.getSyncState().insert(mDb, values); break; case EVENTS: if (!callerIsSyncAdapter) { values.put(Events.DIRTY, 1); addMutator(values, Events.MUTATORS); } if (!values.containsKey(Events.DTSTART)) { if (values.containsKey(Events.ORIGINAL_SYNC_ID) && values.containsKey(Events.ORIGINAL_INSTANCE_TIME) && Events.STATUS_CANCELED == values.getAsInteger(Events.STATUS)) { // event is a canceled instance of a recurring event, it doesn't these // values but lets fake some to satisfy curious consumers. final long origStart = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME); values.put(Events.DTSTART, origStart); values.put(Events.DTEND, origStart); values.put(Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC); } else { throw new RuntimeException("DTSTART field missing from event"); } } // TODO: do we really need to make a copy? ContentValues updatedValues = new ContentValues(values); if (callerIsSyncAdapter) { scrubEventData(updatedValues, null); } else { validateEventData(updatedValues); } // updateLastDate must be after validation, to ensure proper last date computation updatedValues = updateLastDate(updatedValues); if (updatedValues == null) { throw new RuntimeException("Could not insert event."); // return null; } Long calendar_id = updatedValues.getAsLong(Events.CALENDAR_ID); if (calendar_id == null) { // validateEventData checks this for non-sync adapter // inserts throw new IllegalArgumentException("New events must specify a calendar id"); } // Verify the color is valid if it is being set String color_id = updatedValues.getAsString(Events.EVENT_COLOR_KEY); if (!TextUtils.isEmpty(color_id)) { Account account = getAccount(calendar_id); String accountName = null; String accountType = null; if (account != null) { accountName = account.name; accountType = account.type; } int color = verifyColorExists(accountName, accountType, color_id, Colors.TYPE_EVENT); updatedValues.put(Events.EVENT_COLOR, color); } String owner = null; if (!updatedValues.containsKey(Events.ORGANIZER)) { owner = getOwner(calendar_id); // TODO: This isn't entirely correct. If a guest is adding a recurrence // exception to an event, the organizer should stay the original organizer. // This value doesn't go to the server and it will get fixed on sync, // so it shouldn't really matter. if (owner != null) { updatedValues.put(Events.ORGANIZER, owner); } } if (updatedValues.containsKey(Events.ORIGINAL_SYNC_ID) && !updatedValues.containsKey(Events.ORIGINAL_ID)) { long originalId = getOriginalId(updatedValues .getAsString(Events.ORIGINAL_SYNC_ID), updatedValues.getAsString(Events.CALENDAR_ID)); if (originalId != -1) { updatedValues.put(Events.ORIGINAL_ID, originalId); } } else if (!updatedValues.containsKey(Events.ORIGINAL_SYNC_ID) && updatedValues.containsKey(Events.ORIGINAL_ID)) { String originalSyncId = getOriginalSyncId(updatedValues .getAsLong(Events.ORIGINAL_ID)); if (!TextUtils.isEmpty(originalSyncId)) { updatedValues.put(Events.ORIGINAL_SYNC_ID, originalSyncId); } } if (fixAllDayTime(updatedValues, updatedValues)) { if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "insertInTransaction: " + "allDay is true but sec, min, hour were not 0."); } } updatedValues.remove(Events.HAS_ALARM); // should not be set by caller // Insert the row id = mDbHelper.eventsInsert(updatedValues); if (id != -1) { updateEventRawTimesLocked(id, updatedValues); mInstancesHelper.updateInstancesLocked(updatedValues, id, true /* new event */, mDb); // If we inserted a new event that specified the self-attendee // status, then we need to add an entry to the attendees table. if (values.containsKey(Events.SELF_ATTENDEE_STATUS)) { int status = values.getAsInteger(Events.SELF_ATTENDEE_STATUS); if (owner == null) { owner = getOwner(calendar_id); } createAttendeeEntry(id, status, owner); } backfillExceptionOriginalIds(id, values); sendUpdateNotification(id, callerIsSyncAdapter); } break; case EXCEPTION_ID: long originalEventId = ContentUris.parseId(uri); id = handleInsertException(originalEventId, values, callerIsSyncAdapter); break; case CALENDARS: // TODO: verify that all required fields are present Integer syncEvents = values.getAsInteger(Calendars.SYNC_EVENTS); if (syncEvents != null && syncEvents == 1) { String accountName = values.getAsString(Calendars.ACCOUNT_NAME); String accountType = values.getAsString( Calendars.ACCOUNT_TYPE); final Account account = new Account(accountName, accountType); String eventsUrl = values.getAsString(Calendars.CAL_SYNC1); mDbHelper.scheduleSync(account, false /* two-way sync */, eventsUrl); } String cal_color_id = values.getAsString(Calendars.CALENDAR_COLOR_KEY); if (!TextUtils.isEmpty(cal_color_id)) { String accountName = values.getAsString(Calendars.ACCOUNT_NAME); String accountType = values.getAsString(Calendars.ACCOUNT_TYPE); int color = verifyColorExists(accountName, accountType, cal_color_id, Colors.TYPE_CALENDAR); values.put(Calendars.CALENDAR_COLOR, color); } id = mDbHelper.calendarsInsert(values); sendUpdateNotification(id, callerIsSyncAdapter); break; case COLORS: // verifyTransactionAllowed requires this be from a sync // adapter, all of the required fields are marked NOT NULL in // the db. TODO Do we need explicit checks here or should we // just let sqlite throw if something isn't specified? String accountName = uri.getQueryParameter(Colors.ACCOUNT_NAME); String accountType = uri.getQueryParameter(Colors.ACCOUNT_TYPE); String colorIndex = values.getAsString(Colors.COLOR_KEY); if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) { throw new IllegalArgumentException("Account name and type must be non" + " empty parameters for " + uri); } if (TextUtils.isEmpty(colorIndex)) { throw new IllegalArgumentException("COLOR_INDEX must be non empty for " + uri); } if (!values.containsKey(Colors.COLOR_TYPE) || !values.containsKey(Colors.COLOR)) { throw new IllegalArgumentException( "New colors must contain COLOR_TYPE and COLOR"); } // Make sure the account we're inserting for is the same one the // adapter is claiming to be. TODO should we throw if they // aren't the same? values.put(Colors.ACCOUNT_NAME, accountName); values.put(Colors.ACCOUNT_TYPE, accountType); // Verify the color doesn't already exist Cursor c = null; try { final long colorType = values.getAsLong(Colors.COLOR_TYPE); c = getColorByTypeIndex(accountName, accountType, colorType, colorIndex); if (c.getCount() != 0) { throw new IllegalArgumentException("color type " + colorType + " and index " + colorIndex + " already exists for account and type provided"); } } finally { if (c != null) c.close(); } id = mDbHelper.colorsInsert(values); break; case ATTENDEES: { if (!values.containsKey(Attendees.EVENT_ID)) { throw new IllegalArgumentException("Attendees values must " + "contain an event_id"); } Long eventIdObj = values.getAsLong(Reminders.EVENT_ID); if (!doesEventExist(eventIdObj)) { Log.i(TAG, "Trying to insert a attendee to a non-existent event"); return null; } if (!callerIsSyncAdapter) { final Long eventId = values.getAsLong(Attendees.EVENT_ID); mDbHelper.duplicateEvent(eventId); setEventDirty(eventId); } id = mDbHelper.attendeesInsert(values); // Copy the attendee status value to the Events table. updateEventAttendeeStatus(mDb, values); break; } case REMINDERS: { Long eventIdObj = values.getAsLong(Reminders.EVENT_ID); if (eventIdObj == null) { throw new IllegalArgumentException("Reminders values must " + "contain a numeric event_id"); } if (!doesEventExist(eventIdObj)) { Log.i(TAG, "Trying to insert a reminder to a non-existent event"); return null; } if (!callerIsSyncAdapter) { mDbHelper.duplicateEvent(eventIdObj); setEventDirty(eventIdObj); } id = mDbHelper.remindersInsert(values); // We know this event has at least one reminder, so make sure "hasAlarm" is 1. setHasAlarm(eventIdObj, 1); // Schedule another event alarm, if necessary if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "insertInternal() changing reminder"); } mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); break; } case CALENDAR_ALERTS: { Long eventIdObj = values.getAsLong(Reminders.EVENT_ID); if (eventIdObj == null) { throw new IllegalArgumentException("CalendarAlerts values must " + "contain a numeric event_id"); } if (!doesEventExist(eventIdObj)) { Log.i(TAG, "Trying to insert an alert to a non-existent event"); return null; } id = mDbHelper.calendarAlertsInsert(values); // Note: dirty bit is not set for Alerts because it is not synced. // It is generated from Reminders, which is synced. break; } case EXTENDED_PROPERTIES: { Long eventIdObj = values.getAsLong(Reminders.EVENT_ID); if (eventIdObj == null) { throw new IllegalArgumentException("ExtendedProperties values must " + "contain a numeric event_id"); } if (!doesEventExist(eventIdObj)) { Log.i(TAG, "Trying to insert extended properties to a non-existent event id = " + eventIdObj); return null; } if (!callerIsSyncAdapter) { final Long eventId = values .getAsLong(CalendarContract.ExtendedProperties.EVENT_ID); mDbHelper.duplicateEvent(eventId); setEventDirty(eventId); } id = mDbHelper.extendedPropertiesInsert(values); break; } case EMMA: // Special target used during code-coverage evaluation. handleEmmaRequest(values); break; case EVENTS_ID: case REMINDERS_ID: case CALENDAR_ALERTS_ID: case EXTENDED_PROPERTIES_ID: case INSTANCES: case INSTANCES_BY_DAY: case EVENT_DAYS: case PROVIDER_PROPERTIES: throw new UnsupportedOperationException("Cannot insert into that URL: " + uri); default: throw new IllegalArgumentException("Unknown URL " + uri); } if (id < 0) { return null; } return ContentUris.withAppendedId(uri, id); } private boolean doesEventExist(long eventId) { return DatabaseUtils.queryNumEntries(mDb, Tables.EVENTS, Events._ID + "=?", new String[]{String.valueOf(eventId)}) > 0; } /** * Handles special commands related to EMMA code-coverage testing. * * @param values Parameters from the caller. */ private static void handleEmmaRequest(ContentValues values) { /* * This is not part of the public API, so we can't share constants with the CTS * test code. * * Bad requests, or attempting to request EMMA coverage data when the coverage libs * aren't linked in, will cause an exception. */ String cmd = values.getAsString("cmd"); if (cmd.equals("start")) { // We'd like to reset the coverage data, but according to FAQ item 3.14 at // http://emma.sourceforge.net/faq.html, this isn't possible in 2.0. Log.d(TAG, "Emma coverage testing started"); } else if (cmd.equals("stop")) { // Call com.vladium.emma.rt.RT.dumpCoverageData() to cause a data dump. We // may not have been built with EMMA, so we need to do this through reflection. String filename = values.getAsString("outputFileName"); File coverageFile = new File(filename); try { Class emmaRTClass = Class.forName("com.vladium.emma.rt.RT"); Method dumpCoverageMethod = emmaRTClass.getMethod("dumpCoverageData", coverageFile.getClass(), boolean.class, boolean.class); dumpCoverageMethod.invoke(null, coverageFile, false /*merge*/, false /*stopDataCollection*/); Log.d(TAG, "Emma coverage data written to " + filename); } catch (Exception e) { throw new RuntimeException("Emma coverage dump failed", e); } } } /** * Validates the recurrence rule, if any. We allow single- and multi-rule RRULEs. *

* TODO: Validate RDATE, EXRULE, EXDATE (possibly passing in an indication of whether we * believe we have the full set, so we can reject EXRULE when not accompanied by RRULE). * * @return A boolean indicating successful validation. */ private boolean validateRecurrenceRule(ContentValues values) { String rrule = values.getAsString(Events.RRULE); if (!TextUtils.isEmpty(rrule)) { String[] ruleList = rrule.split("\n"); for (String recur : ruleList) { EventRecurrence er = new EventRecurrence(); try { er.parse(recur); } catch (EventRecurrence.InvalidFormatException ife) { Log.w(TAG, "Invalid recurrence rule: " + recur); dumpEventNoPII(values); return false; } } } return true; } private void dumpEventNoPII(ContentValues values) { if (values == null) { return; } StringBuilder bob = new StringBuilder(); bob.append("dtStart: ").append(values.getAsLong(Events.DTSTART)); bob.append("\ndtEnd: ").append(values.getAsLong(Events.DTEND)); bob.append("\nall_day: ").append(values.getAsInteger(Events.ALL_DAY)); bob.append("\ntz: ").append(values.getAsString(Events.EVENT_TIMEZONE)); bob.append("\ndur: ").append(values.getAsString(Events.DURATION)); bob.append("\nrrule: ").append(values.getAsString(Events.RRULE)); bob.append("\nrdate: ").append(values.getAsString(Events.RDATE)); bob.append("\nlast_date: ").append(values.getAsLong(Events.LAST_DATE)); bob.append("\nid: ").append(values.getAsLong(Events._ID)); bob.append("\nsync_id: ").append(values.getAsString(Events._SYNC_ID)); bob.append("\nori_id: ").append(values.getAsLong(Events.ORIGINAL_ID)); bob.append("\nori_sync_id: ").append(values.getAsString(Events.ORIGINAL_SYNC_ID)); bob.append("\nori_inst_time: ").append(values.getAsLong(Events.ORIGINAL_INSTANCE_TIME)); bob.append("\nori_all_day: ").append(values.getAsInteger(Events.ORIGINAL_ALL_DAY)); Log.i(TAG, bob.toString()); } /** * Do some scrubbing on event data before inserting or updating. In particular make * dtend, duration, etc make sense for the type of event (regular, recurrence, exception). * Remove any unexpected fields. * * @param values the ContentValues to insert. * @param modValues if non-null, explicit null entries will be added here whenever something * is removed from values. */ private void scrubEventData(ContentValues values, ContentValues modValues) { boolean hasDtend = values.getAsLong(Events.DTEND) != null; boolean hasDuration = !TextUtils.isEmpty(values.getAsString(Events.DURATION)); boolean hasRrule = !TextUtils.isEmpty(values.getAsString(Events.RRULE)); boolean hasRdate = !TextUtils.isEmpty(values.getAsString(Events.RDATE)); boolean hasOriginalEvent = !TextUtils.isEmpty(values.getAsString(Events.ORIGINAL_SYNC_ID)); boolean hasOriginalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME) != null; if (hasRrule || hasRdate) { // Recurrence: // dtstart is start time of first event // dtend is null // duration is the duration of the event // rrule is a valid recurrence rule // lastDate is the end of the last event or null if it repeats forever // originalEvent is null // originalInstanceTime is null if (!validateRecurrenceRule(values)) { throw new IllegalArgumentException("Invalid recurrence rule: " + values.getAsString(Events.RRULE)); } if (hasDtend || !hasDuration || hasOriginalEvent || hasOriginalInstanceTime) { Log.d(TAG, "Scrubbing DTEND, ORIGINAL_SYNC_ID, ORIGINAL_INSTANCE_TIME"); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Invalid values for recurrence: " + values); } values.remove(Events.DTEND); values.remove(Events.ORIGINAL_SYNC_ID); values.remove(Events.ORIGINAL_INSTANCE_TIME); if (modValues != null) { modValues.putNull(Events.DTEND); modValues.putNull(Events.ORIGINAL_SYNC_ID); modValues.putNull(Events.ORIGINAL_INSTANCE_TIME); } } } else if (hasOriginalEvent || hasOriginalInstanceTime) { // Recurrence exception // dtstart is start time of exception event // dtend is end time of exception event // duration is null // rrule is null // lastdate is same as dtend // originalEvent is the _sync_id of the recurrence // originalInstanceTime is the start time of the event being replaced if (!hasDtend || hasDuration || !hasOriginalEvent || !hasOriginalInstanceTime) { Log.d(TAG, "Scrubbing DURATION"); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Invalid values for recurrence exception: " + values); } values.remove(Events.DURATION); if (modValues != null) { modValues.putNull(Events.DURATION); } } } else { // Regular event // dtstart is the start time // dtend is the end time // duration is null // rrule is null // lastDate is the same as dtend // originalEvent is null // originalInstanceTime is null if (!hasDtend || hasDuration) { Log.d(TAG, "Scrubbing DURATION"); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Invalid values for event: " + values); } values.remove(Events.DURATION); if (modValues != null) { modValues.putNull(Events.DURATION); } } } } /** * Validates event data. Pass in the full set of values for the event (i.e. not just * a part that's being updated). * * @param values Event data. * @throws IllegalArgumentException if bad data is found. */ private void validateEventData(ContentValues values) { if (TextUtils.isEmpty(values.getAsString(Events.CALENDAR_ID))) { throw new IllegalArgumentException("Event values must include a calendar_id"); } if (TextUtils.isEmpty(values.getAsString(Events.EVENT_TIMEZONE))) { throw new IllegalArgumentException("Event values must include an eventTimezone"); } boolean hasDtstart = values.getAsLong(Events.DTSTART) != null; boolean hasDtend = values.getAsLong(Events.DTEND) != null; boolean hasDuration = !TextUtils.isEmpty(values.getAsString(Events.DURATION)); boolean hasRrule = !TextUtils.isEmpty(values.getAsString(Events.RRULE)); boolean hasRdate = !TextUtils.isEmpty(values.getAsString(Events.RDATE)); if (hasRrule || hasRdate) { if (!validateRecurrenceRule(values)) { throw new IllegalArgumentException("Invalid recurrence rule: " + values.getAsString(Events.RRULE)); } } if (!hasDtstart) { dumpEventNoPII(values); throw new IllegalArgumentException("DTSTART cannot be empty."); } if (!hasDuration && !hasDtend) { dumpEventNoPII(values); throw new IllegalArgumentException("DTEND and DURATION cannot both be null for " + "an event."); } if (hasDuration && hasDtend) { dumpEventNoPII(values); throw new IllegalArgumentException("Cannot have both DTEND and DURATION in an event"); } } private void setEventDirty(long eventId) { final String mutators = DatabaseUtils.stringForQuery( mDb, SQL_QUERY_EVENT_MUTATORS, new String[]{String.valueOf(eventId)}); final String packageName = getCallingPackageName(); final String newMutators; if (TextUtils.isEmpty(mutators)) { newMutators = packageName; } else { final String[] strings = mutators.split(","); boolean found = false; for (String string : strings) { if (string.equals(packageName)) { found = true; break; } } if (!found) { newMutators = mutators + "," + packageName; } else { newMutators = mutators; } } mDb.execSQL(SQL_UPDATE_EVENT_SET_DIRTY_AND_MUTATORS, new Object[] {newMutators, eventId}); } private long getOriginalId(String originalSyncId, String calendarId) { if (TextUtils.isEmpty(originalSyncId) || TextUtils.isEmpty(calendarId)) { return -1; } // Get the original id for this event long originalId = -1; Cursor c = null; try { c = query(Events.CONTENT_URI, ID_ONLY_PROJECTION, Events._SYNC_ID + "=?" + " AND " + Events.CALENDAR_ID + "=?", new String[] {originalSyncId, calendarId}, null); if (c != null && c.moveToFirst()) { originalId = c.getLong(0); } } finally { if (c != null) { c.close(); } } return originalId; } private String getOriginalSyncId(long originalId) { if (originalId == -1) { return null; } // Get the original id for this event String originalSyncId = null; Cursor c = null; try { c = query(Events.CONTENT_URI, new String[] {Events._SYNC_ID}, Events._ID + "=?", new String[] {Long.toString(originalId)}, null); if (c != null && c.moveToFirst()) { originalSyncId = c.getString(0); } } finally { if (c != null) { c.close(); } } return originalSyncId; } private Cursor getColorByTypeIndex(String accountName, String accountType, long colorType, String colorIndex) { return mDb.query(Tables.COLORS, COLORS_PROJECTION, COLOR_FULL_SELECTION, new String[] { accountName, accountType, Long.toString(colorType), colorIndex }, null, null, null); } /** * Gets a calendar's "owner account", i.e. the e-mail address of the owner of the calendar. * * @param calId The calendar ID. * @return email of owner or null */ private String getOwner(long calId) { if (calId < 0) { if (Log.isLoggable(TAG, Log.ERROR)) { Log.e(TAG, "Calendar Id is not valid: " + calId); } return null; } // Get the email address of this user from this Calendar String emailAddress = null; Cursor cursor = null; try { cursor = query(ContentUris.withAppendedId(Calendars.CONTENT_URI, calId), new String[] { Calendars.OWNER_ACCOUNT }, null /* selection */, null /* selectionArgs */, null /* sort */); if (cursor == null || !cursor.moveToFirst()) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Couldn't find " + calId + " in Calendars table"); } return null; } emailAddress = cursor.getString(0); } finally { if (cursor != null) { cursor.close(); } } return emailAddress; } private Account getAccount(long calId) { Account account = null; Cursor cursor = null; try { cursor = query(ContentUris.withAppendedId(Calendars.CONTENT_URI, calId), ACCOUNT_PROJECTION, null /* selection */, null /* selectionArgs */, null /* sort */); if (cursor == null || !cursor.moveToFirst()) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Couldn't find " + calId + " in Calendars table"); } return null; } account = new Account(cursor.getString(ACCOUNT_NAME_INDEX), cursor.getString(ACCOUNT_TYPE_INDEX)); } finally { if (cursor != null) { cursor.close(); } } return account; } /** * Creates an entry in the Attendees table that refers to the given event * and that has the given response status. * * @param eventId the event id that the new entry in the Attendees table * should refer to * @param status the response status * @param emailAddress the email of the attendee */ private void createAttendeeEntry(long eventId, int status, String emailAddress) { ContentValues values = new ContentValues(); values.put(Attendees.EVENT_ID, eventId); values.put(Attendees.ATTENDEE_STATUS, status); values.put(Attendees.ATTENDEE_TYPE, Attendees.TYPE_NONE); // TODO: The relationship could actually be ORGANIZER, but it will get straightened out // on sync. values.put(Attendees.ATTENDEE_RELATIONSHIP, Attendees.RELATIONSHIP_ATTENDEE); values.put(Attendees.ATTENDEE_EMAIL, emailAddress); // We don't know the ATTENDEE_NAME but that will be filled in by the // server and sent back to us. mDbHelper.attendeesInsert(values); } /** * Updates the attendee status in the Events table to be consistent with * the value in the Attendees table. * * @param db the database * @param attendeeValues the column values for one row in the Attendees table. */ private void updateEventAttendeeStatus(SQLiteDatabase db, ContentValues attendeeValues) { // Get the event id for this attendee Long eventIdObj = attendeeValues.getAsLong(Attendees.EVENT_ID); if (eventIdObj == null) { Log.w(TAG, "Attendee update values don't include an event_id"); return; } long eventId = eventIdObj; if (MULTIPLE_ATTENDEES_PER_EVENT) { // Get the calendar id for this event Cursor cursor = null; long calId; try { cursor = query(ContentUris.withAppendedId(Events.CONTENT_URI, eventId), new String[] { Events.CALENDAR_ID }, null /* selection */, null /* selectionArgs */, null /* sort */); if (cursor == null || !cursor.moveToFirst()) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Couldn't find " + eventId + " in Events table"); } return; } calId = cursor.getLong(0); } finally { if (cursor != null) { cursor.close(); } } // Get the owner email for this Calendar String calendarEmail = null; cursor = null; try { cursor = query(ContentUris.withAppendedId(Calendars.CONTENT_URI, calId), new String[] { Calendars.OWNER_ACCOUNT }, null /* selection */, null /* selectionArgs */, null /* sort */); if (cursor == null || !cursor.moveToFirst()) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Couldn't find " + calId + " in Calendars table"); } return; } calendarEmail = cursor.getString(0); } finally { if (cursor != null) { cursor.close(); } } if (calendarEmail == null) { return; } // Get the email address for this attendee String attendeeEmail = null; if (attendeeValues.containsKey(Attendees.ATTENDEE_EMAIL)) { attendeeEmail = attendeeValues.getAsString(Attendees.ATTENDEE_EMAIL); } // If the attendee email does not match the calendar email, then this // attendee is not the owner of this calendar so we don't update the // selfAttendeeStatus in the event. if (!calendarEmail.equals(attendeeEmail)) { return; } } // Select a default value for "status" based on the relationship. int status = Attendees.ATTENDEE_STATUS_NONE; Integer relationObj = attendeeValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); if (relationObj != null) { int rel = relationObj; if (rel == Attendees.RELATIONSHIP_ORGANIZER) { status = Attendees.ATTENDEE_STATUS_ACCEPTED; } } // If the status is specified, use that. Integer statusObj = attendeeValues.getAsInteger(Attendees.ATTENDEE_STATUS); if (statusObj != null) { status = statusObj; } ContentValues values = new ContentValues(); values.put(Events.SELF_ATTENDEE_STATUS, status); db.update(Tables.EVENTS, values, SQL_WHERE_ID, new String[] {String.valueOf(eventId)}); } /** * Set the "hasAlarm" column in the database. * * @param eventId The _id of the Event to update. * @param val The value to set it to (0 or 1). */ private void setHasAlarm(long eventId, int val) { ContentValues values = new ContentValues(); values.put(Events.HAS_ALARM, val); int count = mDb.update(Tables.EVENTS, values, SQL_WHERE_ID, new String[] { String.valueOf(eventId) }); if (count != 1) { Log.w(TAG, "setHasAlarm on event " + eventId + " updated " + count + " rows (expected 1)"); } } /** * Calculates the "last date" of the event. For a regular event this is the start time * plus the duration. For a recurring event this is the start date of the last event in * the recurrence, plus the duration. The event recurs forever, this returns -1. If * the recurrence rule can't be parsed, this returns -1. * * @param values * @return the date, in milliseconds, since the start of the epoch (UTC), or -1 if an * exceptional condition exists. * @throws DateException */ long calculateLastDate(ContentValues values) throws DateException { // Allow updates to some event fields like the title or hasAlarm // without requiring DTSTART. if (!values.containsKey(Events.DTSTART)) { if (values.containsKey(Events.DTEND) || values.containsKey(Events.RRULE) || values.containsKey(Events.DURATION) || values.containsKey(Events.EVENT_TIMEZONE) || values.containsKey(Events.RDATE) || values.containsKey(Events.EXRULE) || values.containsKey(Events.EXDATE)) { throw new RuntimeException("DTSTART field missing from event"); } return -1; } long dtstartMillis = values.getAsLong(Events.DTSTART); long lastMillis = -1; // Can we use dtend with a repeating event? What does that even // mean? // NOTE: if the repeating event has a dtend, we convert it to a // duration during event processing, so this situation should not // occur. Long dtEnd = values.getAsLong(Events.DTEND); if (dtEnd != null) { lastMillis = dtEnd; } else { // find out how long it is Duration duration = new Duration(); String durationStr = values.getAsString(Events.DURATION); if (durationStr != null) { duration.parse(durationStr); } RecurrenceSet recur = null; try { recur = new RecurrenceSet(values); } catch (EventRecurrence.InvalidFormatException e) { if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "Could not parse RRULE recurrence string: " + values.get(CalendarContract.Events.RRULE), e); } // TODO: this should throw an exception or return a distinct error code return lastMillis; // -1 } if (null != recur && recur.hasRecurrence()) { // the event is repeating, so find the last date it // could appear on String tz = values.getAsString(Events.EVENT_TIMEZONE); if (TextUtils.isEmpty(tz)) { // floating timezone tz = Time.TIMEZONE_UTC; } Time dtstartLocal = new Time(tz); dtstartLocal.set(dtstartMillis); RecurrenceProcessor rp = new RecurrenceProcessor(); lastMillis = rp.getLastOccurence(dtstartLocal, recur); if (lastMillis == -1) { // repeats forever return lastMillis; // -1 } } else { // the event is not repeating, just use dtstartMillis lastMillis = dtstartMillis; } // that was the beginning of the event. this is the end. lastMillis = duration.addTo(lastMillis); } return lastMillis; } /** * Add LAST_DATE to values. * @param values the ContentValues (in/out); must include DTSTART and, if the event is * recurring, the columns necessary to process a recurrence rule (RRULE, DURATION, * EVENT_TIMEZONE, etc). * @return values on success, null on failure */ private ContentValues updateLastDate(ContentValues values) { try { long last = calculateLastDate(values); if (last != -1) { values.put(Events.LAST_DATE, last); } return values; } catch (DateException e) { // don't add it if there was an error if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "Could not calculate last date.", e); } return null; } } /** * Creates or updates an entry in the EventsRawTimes table. * * @param eventId The ID of the event that was just created or is being updated. * @param values For a new event, the full set of event values; for an updated event, * the set of values that are being changed. */ private void updateEventRawTimesLocked(long eventId, ContentValues values) { ContentValues rawValues = new ContentValues(); rawValues.put(CalendarContract.EventsRawTimes.EVENT_ID, eventId); String timezone = values.getAsString(Events.EVENT_TIMEZONE); boolean allDay = false; Integer allDayInteger = values.getAsInteger(Events.ALL_DAY); if (allDayInteger != null) { allDay = allDayInteger != 0; } if (allDay || TextUtils.isEmpty(timezone)) { // floating timezone timezone = Time.TIMEZONE_UTC; } Time time = new Time(timezone); time.allDay = allDay; Long dtstartMillis = values.getAsLong(Events.DTSTART); if (dtstartMillis != null) { time.set(dtstartMillis); rawValues.put(CalendarContract.EventsRawTimes.DTSTART_2445, time.format2445()); } Long dtendMillis = values.getAsLong(Events.DTEND); if (dtendMillis != null) { time.set(dtendMillis); rawValues.put(CalendarContract.EventsRawTimes.DTEND_2445, time.format2445()); } Long originalInstanceMillis = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME); if (originalInstanceMillis != null) { // This is a recurrence exception so we need to get the all-day // status of the original recurring event in order to format the // date correctly. allDayInteger = values.getAsInteger(Events.ORIGINAL_ALL_DAY); if (allDayInteger != null) { time.allDay = allDayInteger != 0; } time.set(originalInstanceMillis); rawValues.put(CalendarContract.EventsRawTimes.ORIGINAL_INSTANCE_TIME_2445, time.format2445()); } Long lastDateMillis = values.getAsLong(Events.LAST_DATE); if (lastDateMillis != null) { time.allDay = allDay; time.set(lastDateMillis); rawValues.put(CalendarContract.EventsRawTimes.LAST_DATE_2445, time.format2445()); } mDbHelper.eventsRawTimesReplace(rawValues); } @Override protected int deleteInTransaction(Uri uri, String selection, String[] selectionArgs, boolean callerIsSyncAdapter) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "deleteInTransaction: " + uri); } validateUriParameters(uri.getQueryParameterNames()); final int match = sUriMatcher.match(uri); verifyTransactionAllowed(TRANSACTION_DELETE, uri, null, callerIsSyncAdapter, match, selection, selectionArgs); mDb = mDbHelper.getWritableDatabase(); switch (match) { case SYNCSTATE: return mDbHelper.getSyncState().delete(mDb, selection, selectionArgs); case SYNCSTATE_ID: String selectionWithId = (SyncState._ID + "=?") + (selection == null ? "" : " AND (" + selection + ")"); // Prepend id to selectionArgs selectionArgs = insertSelectionArg(selectionArgs, String.valueOf(ContentUris.parseId(uri))); return mDbHelper.getSyncState().delete(mDb, selectionWithId, selectionArgs); case COLORS: return deleteMatchingColors(appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE), selectionArgs); case EVENTS: { int result = 0; selection = appendAccountToSelection( uri, selection, Events.ACCOUNT_NAME, Events.ACCOUNT_TYPE); // Query this event to get the ids to delete. Cursor cursor = mDb.query(Views.EVENTS, ID_ONLY_PROJECTION, selection, selectionArgs, null /* groupBy */, null /* having */, null /* sortOrder */); try { while (cursor.moveToNext()) { long id = cursor.getLong(0); result += deleteEventInternal(id, callerIsSyncAdapter, true /* isBatch */); } mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); sendUpdateNotification(callerIsSyncAdapter); } finally { cursor.close(); cursor = null; } return result; } case EVENTS_ID: { long id = ContentUris.parseId(uri); return deleteEventInternal(id, callerIsSyncAdapter, false /* isBatch */); } case EXCEPTION_ID2: { // This will throw NumberFormatException on missing or malformed input. List segments = uri.getPathSegments(); long eventId = Long.parseLong(segments.get(1)); long excepId = Long.parseLong(segments.get(2)); // TODO: verify that this is an exception instance (has an ORIGINAL_ID field // that matches the supplied eventId) return deleteEventInternal(excepId, callerIsSyncAdapter, false /* isBatch */); } case ATTENDEES: { if (callerIsSyncAdapter) { return mDb.delete(Tables.ATTENDEES, selection, selectionArgs); } else { return deleteFromEventRelatedTable(Tables.ATTENDEES, uri, selection, selectionArgs); } } case ATTENDEES_ID: { if (callerIsSyncAdapter) { long id = ContentUris.parseId(uri); return mDb.delete(Tables.ATTENDEES, SQL_WHERE_ID, new String[] {String.valueOf(id)}); } else { return deleteFromEventRelatedTable(Tables.ATTENDEES, uri, null /* selection */, null /* selectionArgs */); } } case REMINDERS: { return deleteReminders(uri, false, selection, selectionArgs, callerIsSyncAdapter); } case REMINDERS_ID: { return deleteReminders(uri, true, null /*selection*/, null /*selectionArgs*/, callerIsSyncAdapter); } case EXTENDED_PROPERTIES: { if (callerIsSyncAdapter) { return mDb.delete(Tables.EXTENDED_PROPERTIES, selection, selectionArgs); } else { return deleteFromEventRelatedTable(Tables.EXTENDED_PROPERTIES, uri, selection, selectionArgs); } } case EXTENDED_PROPERTIES_ID: { if (callerIsSyncAdapter) { long id = ContentUris.parseId(uri); return mDb.delete(Tables.EXTENDED_PROPERTIES, SQL_WHERE_ID, new String[] {String.valueOf(id)}); } else { return deleteFromEventRelatedTable(Tables.EXTENDED_PROPERTIES, uri, null /* selection */, null /* selectionArgs */); } } case CALENDAR_ALERTS: { if (callerIsSyncAdapter) { return mDb.delete(Tables.CALENDAR_ALERTS, selection, selectionArgs); } else { return deleteFromEventRelatedTable(Tables.CALENDAR_ALERTS, uri, selection, selectionArgs); } } case CALENDAR_ALERTS_ID: { // Note: dirty bit is not set for Alerts because it is not synced. // It is generated from Reminders, which is synced. long id = ContentUris.parseId(uri); return mDb.delete(Tables.CALENDAR_ALERTS, SQL_WHERE_ID, new String[] {String.valueOf(id)}); } case CALENDARS_ID: StringBuilder selectionSb = new StringBuilder(Calendars._ID + "="); selectionSb.append(uri.getPathSegments().get(1)); if (!TextUtils.isEmpty(selection)) { selectionSb.append(" AND ("); selectionSb.append(selection); selectionSb.append(')'); } selection = selectionSb.toString(); // $FALL-THROUGH$ - fall through to CALENDARS for the actual delete case CALENDARS: selection = appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE); return deleteMatchingCalendars(selection, selectionArgs); case INSTANCES: case INSTANCES_BY_DAY: case EVENT_DAYS: case PROVIDER_PROPERTIES: throw new UnsupportedOperationException("Cannot delete that URL"); default: throw new IllegalArgumentException("Unknown URL " + uri); } } private int deleteEventInternal(long id, boolean callerIsSyncAdapter, boolean isBatch) { int result = 0; String selectionArgs[] = new String[] {String.valueOf(id)}; // Query this event to get the fields needed for deleting. Cursor cursor = mDb.query(Tables.EVENTS, EVENTS_PROJECTION, SQL_WHERE_ID, selectionArgs, null /* groupBy */, null /* having */, null /* sortOrder */); try { if (cursor.moveToNext()) { result = 1; String syncId = cursor.getString(EVENTS_SYNC_ID_INDEX); boolean emptySyncId = TextUtils.isEmpty(syncId); // If this was a recurring event or a recurrence // exception, then force a recalculation of the // instances. String rrule = cursor.getString(EVENTS_RRULE_INDEX); String rdate = cursor.getString(EVENTS_RDATE_INDEX); String origId = cursor.getString(EVENTS_ORIGINAL_ID_INDEX); String origSyncId = cursor.getString(EVENTS_ORIGINAL_SYNC_ID_INDEX); if (isRecurrenceEvent(rrule, rdate, origId, origSyncId)) { mMetaData.clearInstanceRange(); } boolean isRecurrence = !TextUtils.isEmpty(rrule) || !TextUtils.isEmpty(rdate); // we clean the Events and Attendees table if the caller is CalendarSyncAdapter // or if the event is local (no syncId) // // The EVENTS_CLEANUP_TRIGGER_SQL trigger will remove all associated data // (Attendees, Instances, Reminders, etc). if (callerIsSyncAdapter || emptySyncId) { mDb.delete(Tables.EVENTS, SQL_WHERE_ID, selectionArgs); // If this is a recurrence, and the event was never synced with the server, // we want to delete any exceptions as well. (If it has been to the server, // we'll let the sync adapter delete the events explicitly.) We assume that, // if the recurrence hasn't been synced, the exceptions haven't either. if (isRecurrence && emptySyncId) { mDb.delete(Tables.EVENTS, SQL_WHERE_ORIGINAL_ID, selectionArgs); } } else { // Event is on the server, so we "soft delete", i.e. mark as deleted so that // the sync adapter has a chance to tell the server about the deletion. After // the server sees the change, the sync adapter will do the "hard delete" // (above). ContentValues values = new ContentValues(); values.put(Events.DELETED, 1); values.put(Events.DIRTY, 1); addMutator(values, Events.MUTATORS); mDb.update(Tables.EVENTS, values, SQL_WHERE_ID, selectionArgs); // Exceptions that have been synced shouldn't be deleted -- the sync // adapter will take care of that -- but we want to "soft delete" them so // that they will be removed from the instances list. // TODO: this seems to confuse the sync adapter, and leaves you with an // invisible "ghost" event after the server sync. Maybe we can fix // this by making instance generation smarter? Not vital, since the // exception instances disappear after the server sync. //mDb.update(Tables.EVENTS, values, SQL_WHERE_ORIGINAL_ID_HAS_SYNC_ID, // selectionArgs); // It's possible for the original event to be on the server but have // exceptions that aren't. We want to remove all events with a matching // original_id and an empty _sync_id. mDb.delete(Tables.EVENTS, SQL_WHERE_ORIGINAL_ID_NO_SYNC_ID, selectionArgs); // Delete associated data; attendees, however, are deleted with the actual event // so that the sync adapter is able to notify attendees of the cancellation. mDb.delete(Tables.INSTANCES, SQL_WHERE_EVENT_ID, selectionArgs); mDb.delete(Tables.EVENTS_RAW_TIMES, SQL_WHERE_EVENT_ID, selectionArgs); mDb.delete(Tables.REMINDERS, SQL_WHERE_EVENT_ID, selectionArgs); mDb.delete(Tables.CALENDAR_ALERTS, SQL_WHERE_EVENT_ID, selectionArgs); mDb.delete(Tables.EXTENDED_PROPERTIES, SQL_WHERE_EVENT_ID, selectionArgs); } } } finally { cursor.close(); cursor = null; } if (!isBatch) { mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); sendUpdateNotification(callerIsSyncAdapter); } return result; } /** * Delete rows from an Event-related table (e.g. Attendees) and mark corresponding events * as dirty. * * @param table The table to delete from * @param uri The URI specifying the rows * @param selection for the query * @param selectionArgs for the query */ private int deleteFromEventRelatedTable(String table, Uri uri, String selection, String[] selectionArgs) { if (table.equals(Tables.EVENTS)) { throw new IllegalArgumentException("Don't delete Events with this method " + "(use deleteEventInternal)"); } ContentValues dirtyValues = new ContentValues(); dirtyValues.put(Events.DIRTY, "1"); addMutator(dirtyValues, Events.MUTATORS); /* * Re-issue the delete URI as a query. Note that, if this is a by-ID request, the ID * will be in the URI, not selection/selectionArgs. * * Note that the query will return data according to the access restrictions, * so we don't need to worry about deleting data we don't have permission to read. */ Cursor c = query(uri, ID_PROJECTION, selection, selectionArgs, GENERIC_EVENT_ID); int count = 0; try { long prevEventId = -1; while (c.moveToNext()) { long id = c.getLong(ID_INDEX); long eventId = c.getLong(EVENT_ID_INDEX); // Duplicate the event. As a minor optimization, don't try to duplicate an // event that we just duplicated on the previous iteration. if (eventId != prevEventId) { mDbHelper.duplicateEvent(eventId); } mDb.delete(table, SQL_WHERE_ID, new String[]{String.valueOf(id)}); if (eventId != prevEventId) { mDb.update(Tables.EVENTS, dirtyValues, SQL_WHERE_ID, new String[] { String.valueOf(eventId)} ); } prevEventId = eventId; count++; } } finally { c.close(); } return count; } /** * Deletes rows from the Reminders table and marks the corresponding events as dirty. * Ensures the hasAlarm column in the Event is updated. * * @return The number of rows deleted. */ private int deleteReminders(Uri uri, boolean byId, String selection, String[] selectionArgs, boolean callerIsSyncAdapter) { /* * If this is a by-ID URI, make sure we have a good ID. Also, confirm that the * selection is null, since we will be ignoring it. */ long rowId = -1; if (byId) { if (!TextUtils.isEmpty(selection)) { throw new UnsupportedOperationException("Selection not allowed for " + uri); } rowId = ContentUris.parseId(uri); if (rowId < 0) { throw new IllegalArgumentException("ID expected but not found in " + uri); } } /* * Determine the set of events affected by this operation. There can be multiple * reminders with the same event_id, so to avoid beating up the database with "how many * reminders are left" and "duplicate this event" requests, we want to generate a list * of affected event IDs and work off that. * * TODO: use GROUP BY to reduce the number of rows returned in the cursor. (The content * provider query() doesn't take it as an argument.) */ HashSet eventIdSet = new HashSet(); Cursor c = query(uri, new String[] { Attendees.EVENT_ID }, selection, selectionArgs, null); try { while (c.moveToNext()) { eventIdSet.add(c.getLong(0)); } } finally { c.close(); } /* * If this isn't a sync adapter, duplicate each event (along with its associated tables), * and mark each as "dirty". This is for the benefit of partial-update sync. */ if (!callerIsSyncAdapter) { ContentValues dirtyValues = new ContentValues(); dirtyValues.put(Events.DIRTY, "1"); addMutator(dirtyValues, Events.MUTATORS); Iterator iter = eventIdSet.iterator(); while (iter.hasNext()) { long eventId = iter.next(); mDbHelper.duplicateEvent(eventId); mDb.update(Tables.EVENTS, dirtyValues, SQL_WHERE_ID, new String[] { String.valueOf(eventId) }); } } /* * Issue the original deletion request. If we were called with a by-ID URI, generate * a selection. */ if (byId) { selection = SQL_WHERE_ID; selectionArgs = new String[] { String.valueOf(rowId) }; } int delCount = mDb.delete(Tables.REMINDERS, selection, selectionArgs); /* * For each event, set "hasAlarm" to zero if we've deleted the last of the reminders. * (If the event still has reminders, hasAlarm should already be 1.) Because we're * executing in an exclusive transaction there's no risk of racing against other * database updates. */ ContentValues noAlarmValues = new ContentValues(); noAlarmValues.put(Events.HAS_ALARM, 0); Iterator iter = eventIdSet.iterator(); while (iter.hasNext()) { long eventId = iter.next(); // Count up the number of reminders still associated with this event. Cursor reminders = mDb.query(Tables.REMINDERS, new String[] { GENERIC_ID }, SQL_WHERE_EVENT_ID, new String[] { String.valueOf(eventId) }, null, null, null); int reminderCount = reminders.getCount(); reminders.close(); if (reminderCount == 0) { mDb.update(Tables.EVENTS, noAlarmValues, SQL_WHERE_ID, new String[] { String.valueOf(eventId) }); } } return delCount; } /** * Update rows in a table and, if this is a non-sync-adapter update, mark the corresponding * events as dirty. *

* This only works for tables that are associated with an event. It is assumed that the * link to the Event row is a numeric identifier in a column called "event_id". * * @param uri The original request URI. * @param byId Set to true if the URI is expected to include an ID. * @param updateValues The new values to apply. Not all columns need be represented. * @param selection For non-by-ID operations, the "where" clause to use. * @param selectionArgs For non-by-ID operations, arguments to apply to the "where" clause. * @param callerIsSyncAdapter Set to true if the caller is a sync adapter. * @return The number of rows updated. */ private int updateEventRelatedTable(Uri uri, String table, boolean byId, ContentValues updateValues, String selection, String[] selectionArgs, boolean callerIsSyncAdapter) { /* * Confirm that the request has either an ID or a selection, but not both. It's not * actually "wrong" to have both, but it's not useful, and having neither is likely * a mistake. * * If they provided an ID in the URI, convert it to an ID selection. */ if (byId) { if (!TextUtils.isEmpty(selection)) { throw new UnsupportedOperationException("Selection not allowed for " + uri); } long rowId = ContentUris.parseId(uri); if (rowId < 0) { throw new IllegalArgumentException("ID expected but not found in " + uri); } selection = SQL_WHERE_ID; selectionArgs = new String[] { String.valueOf(rowId) }; } else { if (TextUtils.isEmpty(selection)) { throw new UnsupportedOperationException("Selection is required for " + uri); } } /* * Query the events to update. We want all the columns from the table, so we us a * null projection. */ Cursor c = mDb.query(table, null /*projection*/, selection, selectionArgs, null, null, null); int count = 0; try { if (c.getCount() == 0) { Log.d(TAG, "No query results for " + uri + ", selection=" + selection + " selectionArgs=" + Arrays.toString(selectionArgs)); return 0; } ContentValues dirtyValues = null; if (!callerIsSyncAdapter) { dirtyValues = new ContentValues(); dirtyValues.put(Events.DIRTY, "1"); addMutator(dirtyValues, Events.MUTATORS); } final int idIndex = c.getColumnIndex(GENERIC_ID); final int eventIdIndex = c.getColumnIndex(GENERIC_EVENT_ID); if (idIndex < 0 || eventIdIndex < 0) { throw new RuntimeException("Lookup on _id/event_id failed for " + uri); } /* * For each row found: * - merge original values with update values * - update database * - if not sync adapter, set "dirty" flag in corresponding event to 1 * - update Event attendee status */ while (c.moveToNext()) { /* copy the original values into a ContentValues, then merge the changes in */ ContentValues values = new ContentValues(); DatabaseUtils.cursorRowToContentValues(c, values); values.putAll(updateValues); long id = c.getLong(idIndex); long eventId = c.getLong(eventIdIndex); if (!callerIsSyncAdapter) { // Make a copy of the original, so partial-update code can see diff. mDbHelper.duplicateEvent(eventId); } mDb.update(table, values, SQL_WHERE_ID, new String[] { String.valueOf(id) }); if (!callerIsSyncAdapter) { mDb.update(Tables.EVENTS, dirtyValues, SQL_WHERE_ID, new String[] { String.valueOf(eventId) }); } count++; /* * The Events table has a "selfAttendeeStatus" field that usually mirrors the * "attendeeStatus" column of one row in the Attendees table. It's the provider's * job to keep these in sync, so we have to check for changes here. (We have * to do it way down here because this is the only point where we have the * merged Attendees values.) * * It's possible, but not expected, to have multiple Attendees entries with * matching attendeeEmail. The behavior in this case is not defined. * * We could do this more efficiently for "bulk" updates by caching the Calendar * owner email and checking it here. */ if (table.equals(Tables.ATTENDEES)) { updateEventAttendeeStatus(mDb, values); sendUpdateNotification(eventId, callerIsSyncAdapter); } } } finally { c.close(); } return count; } private int deleteMatchingColors(String selection, String[] selectionArgs) { // query to find all the colors that match, for each // - verify no one references it // - delete color Cursor c = mDb.query(Tables.COLORS, COLORS_PROJECTION, selection, selectionArgs, null, null, null); if (c == null) { return 0; } try { Cursor c2 = null; while (c.moveToNext()) { String index = c.getString(COLORS_COLOR_INDEX_INDEX); String accountName = c.getString(COLORS_ACCOUNT_NAME_INDEX); String accountType = c.getString(COLORS_ACCOUNT_TYPE_INDEX); boolean isCalendarColor = c.getInt(COLORS_COLOR_TYPE_INDEX) == Colors.TYPE_CALENDAR; try { if (isCalendarColor) { c2 = mDb.query(Tables.CALENDARS, ID_ONLY_PROJECTION, SQL_WHERE_CALENDAR_COLOR, new String[] { accountName, accountType, index }, null, null, null); if (c2.getCount() != 0) { throw new UnsupportedOperationException("Cannot delete color " + index + ". Referenced by " + c2.getCount() + " calendars."); } } else { c2 = query(Events.CONTENT_URI, ID_ONLY_PROJECTION, SQL_WHERE_EVENT_COLOR, new String[] {accountName, accountType, index}, null); if (c2.getCount() != 0) { throw new UnsupportedOperationException("Cannot delete color " + index + ". Referenced by " + c2.getCount() + " events."); } } } finally { if (c2 != null) { c2.close(); } } } } finally { if (c != null) { c.close(); } } return mDb.delete(Tables.COLORS, selection, selectionArgs); } private int deleteMatchingCalendars(String selection, String[] selectionArgs) { // query to find all the calendars that match, for each // - delete calendar subscription // - delete calendar Cursor c = mDb.query(Tables.CALENDARS, sCalendarsIdProjection, selection, selectionArgs, null /* groupBy */, null /* having */, null /* sortOrder */); if (c == null) { return 0; } try { while (c.moveToNext()) { long id = c.getLong(CALENDARS_INDEX_ID); modifyCalendarSubscription(id, false /* not selected */); } } finally { c.close(); } return mDb.delete(Tables.CALENDARS, selection, selectionArgs); } private boolean doesEventExistForSyncId(String syncId) { if (syncId == null) { if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "SyncID cannot be null: " + syncId); } return false; } long count = DatabaseUtils.longForQuery(mDb, SQL_SELECT_COUNT_FOR_SYNC_ID, new String[] { syncId }); return (count > 0); } // Check if an UPDATE with STATUS_CANCEL means that we will need to do an Update (instead of // a Deletion) // // Deletion will be done only and only if: // - event status = canceled // - event is a recurrence exception that does not have its original (parent) event anymore // // This is due to the Server semantics that generate STATUS_CANCELED for both creation // and deletion of a recurrence exception // See bug #3218104 private boolean doesStatusCancelUpdateMeanUpdate(ContentValues values, ContentValues modValues) { boolean isStatusCanceled = modValues.containsKey(Events.STATUS) && (modValues.getAsInteger(Events.STATUS) == Events.STATUS_CANCELED); if (isStatusCanceled) { String originalSyncId = values.getAsString(Events.ORIGINAL_SYNC_ID); if (!TextUtils.isEmpty(originalSyncId)) { // This event is an exception. See if the recurring event still exists. return doesEventExistForSyncId(originalSyncId); } } // This is the normal case, we just want an UPDATE return true; } private int handleUpdateColors(ContentValues values, String selection, String[] selectionArgs) { Cursor c = null; int result = mDb.update(Tables.COLORS, values, selection, selectionArgs); if (values.containsKey(Colors.COLOR)) { try { c = mDb.query(Tables.COLORS, COLORS_PROJECTION, selection, selectionArgs, null /* groupBy */, null /* having */, null /* orderBy */); while (c.moveToNext()) { boolean calendarColor = c.getInt(COLORS_COLOR_TYPE_INDEX) == Colors.TYPE_CALENDAR; int color = c.getInt(COLORS_COLOR_INDEX); String[] args = { c.getString(COLORS_ACCOUNT_NAME_INDEX), c.getString(COLORS_ACCOUNT_TYPE_INDEX), c.getString(COLORS_COLOR_INDEX_INDEX) }; ContentValues colorValue = new ContentValues(); if (calendarColor) { colorValue.put(Calendars.CALENDAR_COLOR, color); mDb.update(Tables.CALENDARS, colorValue, SQL_WHERE_CALENDAR_COLOR, args); } else { colorValue.put(Events.EVENT_COLOR, color); mDb.update(Tables.EVENTS, colorValue, SQL_WHERE_EVENT_COLOR, args); } } } finally { if (c != null) { c.close(); } } } return result; } /** * Handles a request to update one or more events. *

* The original event(s) will be loaded from the database, merged with the new values, * and the result checked for validity. In some cases this will alter the supplied * arguments (e.g. zeroing out the times on all-day events), change additional fields (e.g. * update LAST_DATE when DTSTART changes), or cause modifications to other tables (e.g. reset * Instances when a recurrence rule changes). * * @param cursor The set of events to update. * @param updateValues The changes to apply to each event. * @param callerIsSyncAdapter Indicates if the request comes from the sync adapter. * @return the number of rows updated */ private int handleUpdateEvents(Cursor cursor, ContentValues updateValues, boolean callerIsSyncAdapter) { /* * This field is considered read-only. It should not be modified by applications or * by the sync adapter. */ updateValues.remove(Events.HAS_ALARM); /* * For a single event, we can just load the event, merge modValues in, perform any * fix-ups (putting changes into modValues), check validity, and then update(). We have * to be careful that our fix-ups don't confuse the sync adapter. * * For multiple events, we need to load, merge, and validate each event individually. * If no single-event-specific changes need to be made, we could just issue the original * bulk update, which would be more efficient than a series of individual updates. * However, doing so would prevent us from taking advantage of the partial-update * mechanism. */ if (cursor.getCount() > 1) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Performing update on " + cursor.getCount() + " events"); } } while (cursor.moveToNext()) { // Make a copy of updateValues so we can make some local changes. ContentValues modValues = new ContentValues(updateValues); // Load the event into a ContentValues object. ContentValues values = new ContentValues(); DatabaseUtils.cursorRowToContentValues(cursor, values); boolean doValidate = false; if (!callerIsSyncAdapter) { try { // Check to see if the data in the database is valid. If not, we will skip // validation of the update, so that we don't blow up on attempts to // modify existing badly-formed events. validateEventData(values); doValidate = true; } catch (IllegalArgumentException iae) { Log.d(TAG, "Event " + values.getAsString(Events._ID) + " malformed, not validating update (" + iae.getMessage() + ")"); } } // Merge the modifications in. values.putAll(modValues); // If a color_index is being set make sure it's valid String color_id = modValues.getAsString(Events.EVENT_COLOR_KEY); if (!TextUtils.isEmpty(color_id)) { String accountName = null; String accountType = null; Cursor c = mDb.query(Tables.CALENDARS, ACCOUNT_PROJECTION, SQL_WHERE_ID, new String[] { values.getAsString(Events.CALENDAR_ID) }, null, null, null); try { if (c.moveToFirst()) { accountName = c.getString(ACCOUNT_NAME_INDEX); accountType = c.getString(ACCOUNT_TYPE_INDEX); } } finally { if (c != null) { c.close(); } } verifyColorExists(accountName, accountType, color_id, Colors.TYPE_EVENT); } // Scrub and/or validate the combined event. if (callerIsSyncAdapter) { scrubEventData(values, modValues); } if (doValidate) { validateEventData(values); } // Look for any updates that could affect LAST_DATE. It's defined as the end of // the last meeting, so we need to pay attention to DURATION. if (modValues.containsKey(Events.DTSTART) || modValues.containsKey(Events.DTEND) || modValues.containsKey(Events.DURATION) || modValues.containsKey(Events.EVENT_TIMEZONE) || modValues.containsKey(Events.RRULE) || modValues.containsKey(Events.RDATE) || modValues.containsKey(Events.EXRULE) || modValues.containsKey(Events.EXDATE)) { long newLastDate; try { newLastDate = calculateLastDate(values); } catch (DateException de) { throw new IllegalArgumentException("Unable to compute LAST_DATE", de); } Long oldLastDateObj = values.getAsLong(Events.LAST_DATE); long oldLastDate = (oldLastDateObj == null) ? -1 : oldLastDateObj; if (oldLastDate != newLastDate) { // This overwrites any caller-supplied LAST_DATE. This is okay, because the // caller isn't supposed to be messing with the LAST_DATE field. if (newLastDate < 0) { modValues.putNull(Events.LAST_DATE); } else { modValues.put(Events.LAST_DATE, newLastDate); } } } if (!callerIsSyncAdapter) { modValues.put(Events.DIRTY, 1); addMutator(modValues, Events.MUTATORS); } // Disallow updating the attendee status in the Events // table. In the future, we could support this but we // would have to query and update the attendees table // to keep the values consistent. if (modValues.containsKey(Events.SELF_ATTENDEE_STATUS)) { throw new IllegalArgumentException("Updating " + Events.SELF_ATTENDEE_STATUS + " in Events table is not allowed."); } if (fixAllDayTime(values, modValues)) { if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "handleUpdateEvents: " + "allDay is true but sec, min, hour were not 0."); } } // For taking care about recurrences exceptions cancelations, check if this needs // to be an UPDATE or a DELETE boolean isUpdate = doesStatusCancelUpdateMeanUpdate(values, modValues); long id = values.getAsLong(Events._ID); if (isUpdate) { // If a user made a change, possibly duplicate the event so we can do a partial // update. If a sync adapter made a change and that change marks an event as // un-dirty, remove any duplicates that may have been created earlier. if (!callerIsSyncAdapter) { mDbHelper.duplicateEvent(id); } else { if (modValues.containsKey(Events.DIRTY) && modValues.getAsInteger(Events.DIRTY) == 0) { modValues.put(Events.MUTATORS, (String) null); mDbHelper.removeDuplicateEvent(id); } } int result = mDb.update(Tables.EVENTS, modValues, SQL_WHERE_ID, new String[] { String.valueOf(id) }); if (result > 0) { updateEventRawTimesLocked(id, modValues); mInstancesHelper.updateInstancesLocked(modValues, id, false /* not a new event */, mDb); // XXX: should we also be doing this when RRULE changes (e.g. instances // are introduced or removed?) if (modValues.containsKey(Events.DTSTART) || modValues.containsKey(Events.STATUS)) { // If this is a cancellation knock it out // of the instances table if (modValues.containsKey(Events.STATUS) && modValues.getAsInteger(Events.STATUS) == Events.STATUS_CANCELED) { String[] args = new String[] {String.valueOf(id)}; mDb.delete(Tables.INSTANCES, SQL_WHERE_EVENT_ID, args); } // The start time or status of the event changed, so run the // event alarm scheduler. if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "updateInternal() changing event"); } mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); } sendUpdateNotification(id, callerIsSyncAdapter); } } else { deleteEventInternal(id, callerIsSyncAdapter, true /* isBatch */); mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); sendUpdateNotification(callerIsSyncAdapter); } } return cursor.getCount(); } @Override protected int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs, boolean callerIsSyncAdapter) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "updateInTransaction: " + uri); } validateUriParameters(uri.getQueryParameterNames()); final int match = sUriMatcher.match(uri); verifyTransactionAllowed(TRANSACTION_UPDATE, uri, values, callerIsSyncAdapter, match, selection, selectionArgs); mDb = mDbHelper.getWritableDatabase(); switch (match) { case SYNCSTATE: return mDbHelper.getSyncState().update(mDb, values, appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE), selectionArgs); case SYNCSTATE_ID: { selection = appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE); String selectionWithId = (SyncState._ID + "=?") + (selection == null ? "" : " AND (" + selection + ")"); // Prepend id to selectionArgs selectionArgs = insertSelectionArg(selectionArgs, String.valueOf(ContentUris.parseId(uri))); return mDbHelper.getSyncState().update(mDb, values, selectionWithId, selectionArgs); } case COLORS: int validValues = 0; if (values.getAsInteger(Colors.COLOR) != null) { validValues++; } if (values.getAsString(Colors.DATA) != null) { validValues++; } if (values.size() != validValues) { throw new UnsupportedOperationException("You may only change the COLOR and" + " DATA columns for an existing Colors entry."); } return handleUpdateColors(values, appendAccountToSelection(uri, selection, Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE), selectionArgs); case CALENDARS: case CALENDARS_ID: { long id; if (match == CALENDARS_ID) { id = ContentUris.parseId(uri); } else { // TODO: for supporting other sync adapters, we will need to // be able to deal with the following cases: // 1) selection to "_id=?" and pass in a selectionArgs // 2) selection to "_id IN (1, 2, 3)" // 3) selection to "delete=0 AND _id=1" if (selection != null && TextUtils.equals(selection,"_id=?")) { id = Long.parseLong(selectionArgs[0]); } else if (selection != null && selection.startsWith("_id=")) { // The ContentProviderOperation generates an _id=n string instead of // adding the id to the URL, so parse that out here. id = Long.parseLong(selection.substring(4)); } else { return mDb.update(Tables.CALENDARS, values, selection, selectionArgs); } } if (!callerIsSyncAdapter) { values.put(Calendars.DIRTY, 1); addMutator(values, Calendars.MUTATORS); } else { if (values.containsKey(Calendars.DIRTY) && values.getAsInteger(Calendars.DIRTY) == 0) { values.put(Calendars.MUTATORS, (String) null); } } Integer syncEvents = values.getAsInteger(Calendars.SYNC_EVENTS); if (syncEvents != null) { modifyCalendarSubscription(id, syncEvents == 1); } String color_id = values.getAsString(Calendars.CALENDAR_COLOR_KEY); if (!TextUtils.isEmpty(color_id)) { String accountName = values.getAsString(Calendars.ACCOUNT_NAME); String accountType = values.getAsString(Calendars.ACCOUNT_TYPE); if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) { Account account = getAccount(id); if (account != null) { accountName = account.name; accountType = account.type; } } verifyColorExists(accountName, accountType, color_id, Colors.TYPE_CALENDAR); } int result = mDb.update(Tables.CALENDARS, values, SQL_WHERE_ID, new String[] {String.valueOf(id)}); if (result > 0) { // if visibility was toggled, we need to update alarms if (values.containsKey(Calendars.VISIBLE)) { // pass false for removeAlarms since the call to // scheduleNextAlarmLocked will remove any alarms for // non-visible events anyways. removeScheduledAlarmsLocked // does not actually have the effect we want mCalendarAlarm.checkNextAlarm(false); } // update the widget sendUpdateNotification(callerIsSyncAdapter); } return result; } case EVENTS: case EVENTS_ID: { Cursor events = null; // Grab the full set of columns for each selected event. // TODO: define a projection with just the data we need (e.g. we don't need to // validate the SYNC_* columns) try { if (match == EVENTS_ID) { // Single event, identified by ID. long id = ContentUris.parseId(uri); events = mDb.query(Tables.EVENTS, null /* columns */, SQL_WHERE_ID, new String[] { String.valueOf(id) }, null /* groupBy */, null /* having */, null /* sortOrder */); } else { // One or more events, identified by the selection / selectionArgs. events = mDb.query(Tables.EVENTS, null /* columns */, selection, selectionArgs, null /* groupBy */, null /* having */, null /* sortOrder */); } if (events.getCount() == 0) { Log.i(TAG, "No events to update: uri=" + uri + " selection=" + selection + " selectionArgs=" + Arrays.toString(selectionArgs)); return 0; } return handleUpdateEvents(events, values, callerIsSyncAdapter); } finally { if (events != null) { events.close(); } } } case ATTENDEES: return updateEventRelatedTable(uri, Tables.ATTENDEES, false, values, selection, selectionArgs, callerIsSyncAdapter); case ATTENDEES_ID: return updateEventRelatedTable(uri, Tables.ATTENDEES, true, values, null, null, callerIsSyncAdapter); case CALENDAR_ALERTS_ID: { // Note: dirty bit is not set for Alerts because it is not synced. // It is generated from Reminders, which is synced. long id = ContentUris.parseId(uri); return mDb.update(Tables.CALENDAR_ALERTS, values, SQL_WHERE_ID, new String[] {String.valueOf(id)}); } case CALENDAR_ALERTS: { // Note: dirty bit is not set for Alerts because it is not synced. // It is generated from Reminders, which is synced. return mDb.update(Tables.CALENDAR_ALERTS, values, selection, selectionArgs); } case REMINDERS: return updateEventRelatedTable(uri, Tables.REMINDERS, false, values, selection, selectionArgs, callerIsSyncAdapter); case REMINDERS_ID: { int count = updateEventRelatedTable(uri, Tables.REMINDERS, true, values, null, null, callerIsSyncAdapter); // Reschedule the event alarms because the // "minutes" field may have changed. if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "updateInternal() changing reminder"); } mCalendarAlarm.checkNextAlarm(false /* do not remove alarms */); return count; } case EXTENDED_PROPERTIES_ID: return updateEventRelatedTable(uri, Tables.EXTENDED_PROPERTIES, true, values, null, null, callerIsSyncAdapter); case SCHEDULE_ALARM_REMOVE: { mCalendarAlarm.checkNextAlarm(true); return 0; } case PROVIDER_PROPERTIES: { if (!selection.equals("key=?")) { throw new UnsupportedOperationException("Selection should be key=? for " + uri); } List list = Arrays.asList(selectionArgs); if (list.contains(CalendarCache.KEY_TIMEZONE_INSTANCES_PREVIOUS)) { throw new UnsupportedOperationException("Invalid selection key: " + CalendarCache.KEY_TIMEZONE_INSTANCES_PREVIOUS + " for " + uri); } // Before it may be changed, save current Instances timezone for later use String timezoneInstancesBeforeUpdate = mCalendarCache.readTimezoneInstances(); // Update the database with the provided values (this call may change the value // of timezone Instances) int result = mDb.update(Tables.CALENDAR_CACHE, values, selection, selectionArgs); // if successful, do some house cleaning: // if the timezone type is set to "home", set the Instances // timezone to the previous // if the timezone type is set to "auto", set the Instances // timezone to the current // device one // if the timezone Instances is set AND if we are in "home" // timezone type, then save the timezone Instance into // "previous" too if (result > 0) { // If we are changing timezone type... if (list.contains(CalendarCache.KEY_TIMEZONE_TYPE)) { String value = values.getAsString(CalendarCache.COLUMN_NAME_VALUE); if (value != null) { // if we are setting timezone type to "home" if (value.equals(CalendarCache.TIMEZONE_TYPE_HOME)) { String previousTimezone = mCalendarCache.readTimezoneInstancesPrevious(); if (previousTimezone != null) { mCalendarCache.writeTimezoneInstances(previousTimezone); } // Regenerate Instances if the "home" timezone has changed // and notify widgets if (!timezoneInstancesBeforeUpdate.equals(previousTimezone) ) { regenerateInstancesTable(); sendUpdateNotification(callerIsSyncAdapter); } } // if we are setting timezone type to "auto" else if (value.equals(CalendarCache.TIMEZONE_TYPE_AUTO)) { String localTimezone = TimeZone.getDefault().getID(); mCalendarCache.writeTimezoneInstances(localTimezone); if (!timezoneInstancesBeforeUpdate.equals(localTimezone)) { regenerateInstancesTable(); sendUpdateNotification(callerIsSyncAdapter); } } } } // If we are changing timezone Instances... else if (list.contains(CalendarCache.KEY_TIMEZONE_INSTANCES)) { // if we are in "home" timezone type... if (isHomeTimezone()) { String timezoneInstances = mCalendarCache.readTimezoneInstances(); // Update the previous value mCalendarCache.writeTimezoneInstancesPrevious(timezoneInstances); // Recompute Instances if the "home" timezone has changed // and send notifications to any widgets if (timezoneInstancesBeforeUpdate != null && !timezoneInstancesBeforeUpdate.equals(timezoneInstances)) { regenerateInstancesTable(); sendUpdateNotification(callerIsSyncAdapter); } } } } return result; } default: throw new IllegalArgumentException("Unknown URL " + uri); } } /** * Verifies that a color with the given index exists for the given Calendar * entry. * * @param accountName The email of the account the color is for * @param accountType The type of account the color is for * @param colorIndex The color_index being set for the calendar * @param colorType The type of color expected (Calendar/Event) * @return The color specified by the index */ private int verifyColorExists(String accountName, String accountType, String colorIndex, int colorType) { if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) { throw new IllegalArgumentException("Cannot set color. A valid account does" + " not exist for this calendar."); } int color; Cursor c = null; try { c = getColorByTypeIndex(accountName, accountType, colorType, colorIndex); if (!c.moveToFirst()) { throw new IllegalArgumentException("Color type: " + colorType + " and index " + colorIndex + " does not exist for account."); } color = c.getInt(COLORS_COLOR_INDEX); } finally { if (c != null) { c.close(); } } return color; } private String appendLastSyncedColumnToSelection(String selection, Uri uri) { if (getIsCallerSyncAdapter(uri)) { return selection; } final StringBuilder sb = new StringBuilder(); sb.append(CalendarContract.Events.LAST_SYNCED).append(" = 0"); return appendSelection(sb, selection); } private String appendAccountToSelection( Uri uri, String selection, String accountNameColumn, String accountTypeColumn) { final String accountName = QueryParameterUtils.getQueryParameter(uri, CalendarContract.EventsEntity.ACCOUNT_NAME); final String accountType = QueryParameterUtils.getQueryParameter(uri, CalendarContract.EventsEntity.ACCOUNT_TYPE); if (!TextUtils.isEmpty(accountName)) { final StringBuilder sb = new StringBuilder() .append(accountNameColumn) .append("=") .append(DatabaseUtils.sqlEscapeString(accountName)) .append(" AND ") .append(accountTypeColumn) .append("=") .append(DatabaseUtils.sqlEscapeString(accountType)); return appendSelection(sb, selection); } else { return selection; } } private String appendSelection(StringBuilder sb, String selection) { if (!TextUtils.isEmpty(selection)) { sb.append(" AND ("); sb.append(selection); sb.append(')'); } return sb.toString(); } /** * Verifies that the operation is allowed and throws an exception if it * isn't. This defines the limits of a sync adapter call vs an app call. *

* Also rejects calls that have a selection but shouldn't, or that don't have a selection * but should. * * @param type The type of call, {@link #TRANSACTION_QUERY}, * {@link #TRANSACTION_INSERT}, {@link #TRANSACTION_UPDATE}, or * {@link #TRANSACTION_DELETE} * @param uri * @param values * @param isSyncAdapter */ private void verifyTransactionAllowed(int type, Uri uri, ContentValues values, boolean isSyncAdapter, int uriMatch, String selection, String[] selectionArgs) { // Queries are never restricted to app- or sync-adapter-only, and we don't // restrict the set of columns that may be accessed. if (type == TRANSACTION_QUERY) { return; } if (type == TRANSACTION_UPDATE || type == TRANSACTION_DELETE) { // TODO review this list, document in contract. if (!TextUtils.isEmpty(selection)) { // Only allow selections for the URIs that can reasonably use them. // Whitelist of URIs allowed selections switch (uriMatch) { case SYNCSTATE: case CALENDARS: case EVENTS: case ATTENDEES: case CALENDAR_ALERTS: case REMINDERS: case EXTENDED_PROPERTIES: case PROVIDER_PROPERTIES: case COLORS: break; default: throw new IllegalArgumentException("Selection not permitted for " + uri); } } else { // Disallow empty selections for some URIs. // Blacklist of URIs _not_ allowed empty selections switch (uriMatch) { case EVENTS: case ATTENDEES: case REMINDERS: case PROVIDER_PROPERTIES: throw new IllegalArgumentException("Selection must be specified for " + uri); default: break; } } } // Only the sync adapter can use these to make changes. if (!isSyncAdapter) { switch (uriMatch) { case SYNCSTATE: case SYNCSTATE_ID: case EXTENDED_PROPERTIES: case EXTENDED_PROPERTIES_ID: case COLORS: throw new IllegalArgumentException("Only sync adapters may write using " + uri); default: break; } } switch (type) { case TRANSACTION_INSERT: if (uriMatch == INSTANCES) { throw new UnsupportedOperationException( "Inserting into instances not supported"); } // Check there are no columns restricted to the provider verifyColumns(values, uriMatch); if (isSyncAdapter) { // check that account and account type are specified verifyHasAccount(uri, selection, selectionArgs); } else { // check that sync only columns aren't included verifyNoSyncColumns(values, uriMatch); } return; case TRANSACTION_UPDATE: if (uriMatch == INSTANCES) { throw new UnsupportedOperationException("Updating instances not supported"); } // Check there are no columns restricted to the provider verifyColumns(values, uriMatch); if (isSyncAdapter) { // check that account and account type are specified verifyHasAccount(uri, selection, selectionArgs); } else { // check that sync only columns aren't included verifyNoSyncColumns(values, uriMatch); } return; case TRANSACTION_DELETE: if (uriMatch == INSTANCES) { throw new UnsupportedOperationException("Deleting instances not supported"); } if (isSyncAdapter) { // check that account and account type are specified verifyHasAccount(uri, selection, selectionArgs); } return; } } private void verifyHasAccount(Uri uri, String selection, String[] selectionArgs) { String accountName = QueryParameterUtils.getQueryParameter(uri, Calendars.ACCOUNT_NAME); String accountType = QueryParameterUtils.getQueryParameter(uri, Calendars.ACCOUNT_TYPE); if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) { if (selection != null && selection.startsWith(ACCOUNT_SELECTION_PREFIX)) { accountName = selectionArgs[0]; accountType = selectionArgs[1]; } } if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) { throw new IllegalArgumentException( "Sync adapters must specify an account and account type: " + uri); } } private void verifyColumns(ContentValues values, int uriMatch) { if (values == null || values.size() == 0) { return; } String[] columns; switch (uriMatch) { case EVENTS: case EVENTS_ID: case EVENT_ENTITIES: case EVENT_ENTITIES_ID: columns = Events.PROVIDER_WRITABLE_COLUMNS; break; default: columns = PROVIDER_WRITABLE_DEFAULT_COLUMNS; break; } for (int i = 0; i < columns.length; i++) { if (values.containsKey(columns[i])) { throw new IllegalArgumentException("Only the provider may write to " + columns[i]); } } } private void verifyNoSyncColumns(ContentValues values, int uriMatch) { if (values == null || values.size() == 0) { return; } String[] syncColumns; switch (uriMatch) { case CALENDARS: case CALENDARS_ID: case CALENDAR_ENTITIES: case CALENDAR_ENTITIES_ID: syncColumns = Calendars.SYNC_WRITABLE_COLUMNS; break; case EVENTS: case EVENTS_ID: case EVENT_ENTITIES: case EVENT_ENTITIES_ID: syncColumns = Events.SYNC_WRITABLE_COLUMNS; break; default: syncColumns = SYNC_WRITABLE_DEFAULT_COLUMNS; break; } for (int i = 0; i < syncColumns.length; i++) { if (values.containsKey(syncColumns[i])) { throw new IllegalArgumentException("Only sync adapters may write to " + syncColumns[i]); } } } private void modifyCalendarSubscription(long id, boolean syncEvents) { // get the account, url, and current selected state // for this calendar. Cursor cursor = query(ContentUris.withAppendedId(Calendars.CONTENT_URI, id), new String[] {Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_TYPE, Calendars.CAL_SYNC1, Calendars.SYNC_EVENTS}, null /* selection */, null /* selectionArgs */, null /* sort */); Account account = null; String calendarUrl = null; boolean oldSyncEvents = false; if (cursor != null) { try { if (cursor.moveToFirst()) { final String accountName = cursor.getString(0); final String accountType = cursor.getString(1); account = new Account(accountName, accountType); calendarUrl = cursor.getString(2); oldSyncEvents = (cursor.getInt(3) != 0); } } finally { if (cursor != null) cursor.close(); } } if (account == null) { // should not happen? if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "Cannot update subscription because account " + "is empty -- should not happen."); } return; } if (TextUtils.isEmpty(calendarUrl)) { // Passing in a null Url will cause it to not add any extras // Should only happen for non-google calendars. calendarUrl = null; } if (oldSyncEvents == syncEvents) { // nothing to do return; } // If the calendar is not selected for syncing, then don't download // events. mDbHelper.scheduleSync(account, !syncEvents, calendarUrl); } /** * Call this to trigger a broadcast of the ACTION_PROVIDER_CHANGED intent. * This also provides a timeout, so any calls to this method will be batched * over a period of BROADCAST_TIMEOUT_MILLIS defined in this class. * * @param callerIsSyncAdapter whether or not the update is being triggered by a sync */ private void sendUpdateNotification(boolean callerIsSyncAdapter) { // We use -1 to represent an update to all events sendUpdateNotification(-1, callerIsSyncAdapter); } /** * Call this to trigger a broadcast of the ACTION_PROVIDER_CHANGED intent with a delay. * This also provides a timeout, so any calls to this method will be batched * over a period of BROADCAST_TIMEOUT_MILLIS defined in this class. * * TODO add support for eventId * * @param eventId the ID of the event that changed, or -1 for no specific event * @param callerIsSyncAdapter whether or not the update is being triggered by a sync */ private void sendUpdateNotification(long eventId, boolean callerIsSyncAdapter) { // We use a much longer delay for sync-related updates, to prevent any // receivers from slowing down the sync final long delay = callerIsSyncAdapter ? SYNC_UPDATE_BROADCAST_TIMEOUT_MILLIS : UPDATE_BROADCAST_TIMEOUT_MILLIS; if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "sendUpdateNotification: delay=" + delay); } mCalendarAlarm.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + delay, PendingIntent.getBroadcast(mContext, 0, createProviderChangedBroadcast(), PendingIntent.FLAG_UPDATE_CURRENT)); } private Intent createProviderChangedBroadcast() { return new Intent(Intent.ACTION_PROVIDER_CHANGED, CalendarContract.CONTENT_URI) .addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING); } private static final int TRANSACTION_QUERY = 0; private static final int TRANSACTION_INSERT = 1; private static final int TRANSACTION_UPDATE = 2; private static final int TRANSACTION_DELETE = 3; // @formatter:off private static final String[] SYNC_WRITABLE_DEFAULT_COLUMNS = new String[] { CalendarContract.Calendars.DIRTY, CalendarContract.Calendars._SYNC_ID }; private static final String[] PROVIDER_WRITABLE_DEFAULT_COLUMNS = new String[] { }; // @formatter:on private static final int EVENTS = 1; private static final int EVENTS_ID = 2; private static final int INSTANCES = 3; private static final int CALENDARS = 4; private static final int CALENDARS_ID = 5; private static final int ATTENDEES = 6; private static final int ATTENDEES_ID = 7; private static final int REMINDERS = 8; private static final int REMINDERS_ID = 9; private static final int EXTENDED_PROPERTIES = 10; private static final int EXTENDED_PROPERTIES_ID = 11; private static final int CALENDAR_ALERTS = 12; private static final int CALENDAR_ALERTS_ID = 13; private static final int CALENDAR_ALERTS_BY_INSTANCE = 14; private static final int INSTANCES_BY_DAY = 15; private static final int SYNCSTATE = 16; private static final int SYNCSTATE_ID = 17; private static final int EVENT_ENTITIES = 18; private static final int EVENT_ENTITIES_ID = 19; private static final int EVENT_DAYS = 20; private static final int SCHEDULE_ALARM_REMOVE = 22; private static final int TIME = 23; private static final int CALENDAR_ENTITIES = 24; private static final int CALENDAR_ENTITIES_ID = 25; private static final int INSTANCES_SEARCH = 26; private static final int INSTANCES_SEARCH_BY_DAY = 27; private static final int PROVIDER_PROPERTIES = 28; private static final int EXCEPTION_ID = 29; private static final int EXCEPTION_ID2 = 30; private static final int EMMA = 31; private static final int COLORS = 32; private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); private static final HashMap sInstancesProjectionMap; private static final HashMap sColorsProjectionMap; protected static final HashMap sCalendarsProjectionMap; protected static final HashMap sEventsProjectionMap; private static final HashMap sEventEntitiesProjectionMap; private static final HashMap sAttendeesProjectionMap; private static final HashMap sRemindersProjectionMap; private static final HashMap sCalendarAlertsProjectionMap; private static final HashMap sCalendarCacheProjectionMap; private static final HashMap sCountProjectionMap; static { sUriMatcher.addURI(CalendarContract.AUTHORITY, "instances/when/*/*", INSTANCES); sUriMatcher.addURI(CalendarContract.AUTHORITY, "instances/whenbyday/*/*", INSTANCES_BY_DAY); sUriMatcher.addURI(CalendarContract.AUTHORITY, "instances/search/*/*/*", INSTANCES_SEARCH); sUriMatcher.addURI(CalendarContract.AUTHORITY, "instances/searchbyday/*/*/*", INSTANCES_SEARCH_BY_DAY); sUriMatcher.addURI(CalendarContract.AUTHORITY, "instances/groupbyday/*/*", EVENT_DAYS); sUriMatcher.addURI(CalendarContract.AUTHORITY, "events", EVENTS); sUriMatcher.addURI(CalendarContract.AUTHORITY, "events/#", EVENTS_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "event_entities", EVENT_ENTITIES); sUriMatcher.addURI(CalendarContract.AUTHORITY, "event_entities/#", EVENT_ENTITIES_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendars", CALENDARS); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendars/#", CALENDARS_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendar_entities", CALENDAR_ENTITIES); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendar_entities/#", CALENDAR_ENTITIES_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "attendees", ATTENDEES); sUriMatcher.addURI(CalendarContract.AUTHORITY, "attendees/#", ATTENDEES_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "reminders", REMINDERS); sUriMatcher.addURI(CalendarContract.AUTHORITY, "reminders/#", REMINDERS_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "extendedproperties", EXTENDED_PROPERTIES); sUriMatcher.addURI(CalendarContract.AUTHORITY, "extendedproperties/#", EXTENDED_PROPERTIES_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendar_alerts", CALENDAR_ALERTS); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendar_alerts/#", CALENDAR_ALERTS_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "calendar_alerts/by_instance", CALENDAR_ALERTS_BY_INSTANCE); sUriMatcher.addURI(CalendarContract.AUTHORITY, "syncstate", SYNCSTATE); sUriMatcher.addURI(CalendarContract.AUTHORITY, "syncstate/#", SYNCSTATE_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, CalendarAlarmManager.SCHEDULE_ALARM_REMOVE_PATH, SCHEDULE_ALARM_REMOVE); sUriMatcher.addURI(CalendarContract.AUTHORITY, "time/#", TIME); sUriMatcher.addURI(CalendarContract.AUTHORITY, "time", TIME); sUriMatcher.addURI(CalendarContract.AUTHORITY, "properties", PROVIDER_PROPERTIES); sUriMatcher.addURI(CalendarContract.AUTHORITY, "exception/#", EXCEPTION_ID); sUriMatcher.addURI(CalendarContract.AUTHORITY, "exception/#/#", EXCEPTION_ID2); sUriMatcher.addURI(CalendarContract.AUTHORITY, "emma", EMMA); sUriMatcher.addURI(CalendarContract.AUTHORITY, "colors", COLORS); /** Contains just BaseColumns._COUNT */ sCountProjectionMap = new HashMap(); sCountProjectionMap.put(BaseColumns._COUNT, "COUNT(*) AS " + BaseColumns._COUNT); sColorsProjectionMap = new HashMap(); sColorsProjectionMap.put(Colors._ID, Colors._ID); sColorsProjectionMap.put(Colors.DATA, Colors.DATA); sColorsProjectionMap.put(Colors.ACCOUNT_NAME, Colors.ACCOUNT_NAME); sColorsProjectionMap.put(Colors.ACCOUNT_TYPE, Colors.ACCOUNT_TYPE); sColorsProjectionMap.put(Colors.COLOR_KEY, Colors.COLOR_KEY); sColorsProjectionMap.put(Colors.COLOR_TYPE, Colors.COLOR_TYPE); sColorsProjectionMap.put(Colors.COLOR, Colors.COLOR); sCalendarsProjectionMap = new HashMap(); sCalendarsProjectionMap.put(Calendars._ID, Calendars._ID); sCalendarsProjectionMap.put(Calendars.ACCOUNT_NAME, Calendars.ACCOUNT_NAME); sCalendarsProjectionMap.put(Calendars.ACCOUNT_TYPE, Calendars.ACCOUNT_TYPE); sCalendarsProjectionMap.put(Calendars._SYNC_ID, Calendars._SYNC_ID); sCalendarsProjectionMap.put(Calendars.DIRTY, Calendars.DIRTY); sCalendarsProjectionMap.put(Calendars.MUTATORS, Calendars.MUTATORS); sCalendarsProjectionMap.put(Calendars.NAME, Calendars.NAME); sCalendarsProjectionMap.put( Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_DISPLAY_NAME); sCalendarsProjectionMap.put(Calendars.CALENDAR_COLOR, Calendars.CALENDAR_COLOR); sCalendarsProjectionMap.put(Calendars.CALENDAR_COLOR_KEY, Calendars.CALENDAR_COLOR_KEY); sCalendarsProjectionMap.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CALENDAR_ACCESS_LEVEL); sCalendarsProjectionMap.put(Calendars.VISIBLE, Calendars.VISIBLE); sCalendarsProjectionMap.put(Calendars.SYNC_EVENTS, Calendars.SYNC_EVENTS); sCalendarsProjectionMap.put(Calendars.CALENDAR_LOCATION, Calendars.CALENDAR_LOCATION); sCalendarsProjectionMap.put(Calendars.CALENDAR_TIME_ZONE, Calendars.CALENDAR_TIME_ZONE); sCalendarsProjectionMap.put(Calendars.OWNER_ACCOUNT, Calendars.OWNER_ACCOUNT); sCalendarsProjectionMap.put(Calendars.IS_PRIMARY, "COALESCE(" + Events.IS_PRIMARY + ", " + Calendars.OWNER_ACCOUNT + " = " + Calendars.ACCOUNT_NAME + ") AS " + Calendars.IS_PRIMARY); sCalendarsProjectionMap.put(Calendars.CAN_ORGANIZER_RESPOND, Calendars.CAN_ORGANIZER_RESPOND); sCalendarsProjectionMap.put(Calendars.CAN_MODIFY_TIME_ZONE, Calendars.CAN_MODIFY_TIME_ZONE); sCalendarsProjectionMap.put(Calendars.CAN_PARTIALLY_UPDATE, Calendars.CAN_PARTIALLY_UPDATE); sCalendarsProjectionMap.put(Calendars.MAX_REMINDERS, Calendars.MAX_REMINDERS); sCalendarsProjectionMap.put(Calendars.ALLOWED_REMINDERS, Calendars.ALLOWED_REMINDERS); sCalendarsProjectionMap.put(Calendars.ALLOWED_AVAILABILITY, Calendars.ALLOWED_AVAILABILITY); sCalendarsProjectionMap.put(Calendars.ALLOWED_ATTENDEE_TYPES, Calendars.ALLOWED_ATTENDEE_TYPES); sCalendarsProjectionMap.put(Calendars.DELETED, Calendars.DELETED); sCalendarsProjectionMap.put(Calendars.CAL_SYNC1, Calendars.CAL_SYNC1); sCalendarsProjectionMap.put(Calendars.CAL_SYNC2, Calendars.CAL_SYNC2); sCalendarsProjectionMap.put(Calendars.CAL_SYNC3, Calendars.CAL_SYNC3); sCalendarsProjectionMap.put(Calendars.CAL_SYNC4, Calendars.CAL_SYNC4); sCalendarsProjectionMap.put(Calendars.CAL_SYNC5, Calendars.CAL_SYNC5); sCalendarsProjectionMap.put(Calendars.CAL_SYNC6, Calendars.CAL_SYNC6); sCalendarsProjectionMap.put(Calendars.CAL_SYNC7, Calendars.CAL_SYNC7); sCalendarsProjectionMap.put(Calendars.CAL_SYNC8, Calendars.CAL_SYNC8); sCalendarsProjectionMap.put(Calendars.CAL_SYNC9, Calendars.CAL_SYNC9); sCalendarsProjectionMap.put(Calendars.CAL_SYNC10, Calendars.CAL_SYNC10); sEventsProjectionMap = new HashMap(); // Events columns sEventsProjectionMap.put(Events.ACCOUNT_NAME, Events.ACCOUNT_NAME); sEventsProjectionMap.put(Events.ACCOUNT_TYPE, Events.ACCOUNT_TYPE); sEventsProjectionMap.put(Events.TITLE, Events.TITLE); sEventsProjectionMap.put(Events.EVENT_LOCATION, Events.EVENT_LOCATION); sEventsProjectionMap.put(Events.DESCRIPTION, Events.DESCRIPTION); sEventsProjectionMap.put(Events.STATUS, Events.STATUS); sEventsProjectionMap.put(Events.EVENT_COLOR, Events.EVENT_COLOR); sEventsProjectionMap.put(Events.EVENT_COLOR_KEY, Events.EVENT_COLOR_KEY); sEventsProjectionMap.put(Events.SELF_ATTENDEE_STATUS, Events.SELF_ATTENDEE_STATUS); sEventsProjectionMap.put(Events.DTSTART, Events.DTSTART); sEventsProjectionMap.put(Events.DTEND, Events.DTEND); sEventsProjectionMap.put(Events.EVENT_TIMEZONE, Events.EVENT_TIMEZONE); sEventsProjectionMap.put(Events.EVENT_END_TIMEZONE, Events.EVENT_END_TIMEZONE); sEventsProjectionMap.put(Events.DURATION, Events.DURATION); sEventsProjectionMap.put(Events.ALL_DAY, Events.ALL_DAY); sEventsProjectionMap.put(Events.ACCESS_LEVEL, Events.ACCESS_LEVEL); sEventsProjectionMap.put(Events.AVAILABILITY, Events.AVAILABILITY); sEventsProjectionMap.put(Events.HAS_ALARM, Events.HAS_ALARM); sEventsProjectionMap.put(Events.HAS_EXTENDED_PROPERTIES, Events.HAS_EXTENDED_PROPERTIES); sEventsProjectionMap.put(Events.RRULE, Events.RRULE); sEventsProjectionMap.put(Events.RDATE, Events.RDATE); sEventsProjectionMap.put(Events.EXRULE, Events.EXRULE); sEventsProjectionMap.put(Events.EXDATE, Events.EXDATE); sEventsProjectionMap.put(Events.ORIGINAL_SYNC_ID, Events.ORIGINAL_SYNC_ID); sEventsProjectionMap.put(Events.ORIGINAL_ID, Events.ORIGINAL_ID); sEventsProjectionMap.put(Events.ORIGINAL_INSTANCE_TIME, Events.ORIGINAL_INSTANCE_TIME); sEventsProjectionMap.put(Events.ORIGINAL_ALL_DAY, Events.ORIGINAL_ALL_DAY); sEventsProjectionMap.put(Events.LAST_DATE, Events.LAST_DATE); sEventsProjectionMap.put(Events.HAS_ATTENDEE_DATA, Events.HAS_ATTENDEE_DATA); sEventsProjectionMap.put(Events.CALENDAR_ID, Events.CALENDAR_ID); sEventsProjectionMap.put(Events.GUESTS_CAN_INVITE_OTHERS, Events.GUESTS_CAN_INVITE_OTHERS); sEventsProjectionMap.put(Events.GUESTS_CAN_MODIFY, Events.GUESTS_CAN_MODIFY); sEventsProjectionMap.put(Events.GUESTS_CAN_SEE_GUESTS, Events.GUESTS_CAN_SEE_GUESTS); sEventsProjectionMap.put(Events.ORGANIZER, Events.ORGANIZER); sEventsProjectionMap.put(Events.IS_ORGANIZER, Events.IS_ORGANIZER); sEventsProjectionMap.put(Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_PACKAGE); sEventsProjectionMap.put(Events.CUSTOM_APP_URI, Events.CUSTOM_APP_URI); sEventsProjectionMap.put(Events.UID_2445, Events.UID_2445); sEventsProjectionMap.put(Events.DELETED, Events.DELETED); sEventsProjectionMap.put(Events._SYNC_ID, Events._SYNC_ID); // Put the shared items into the Attendees, Reminders projection map sAttendeesProjectionMap = new HashMap(sEventsProjectionMap); sRemindersProjectionMap = new HashMap(sEventsProjectionMap); // Calendar columns sEventsProjectionMap.put(Calendars.CALENDAR_COLOR, Calendars.CALENDAR_COLOR); sEventsProjectionMap.put(Calendars.CALENDAR_COLOR_KEY, Calendars.CALENDAR_COLOR_KEY); sEventsProjectionMap.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CALENDAR_ACCESS_LEVEL); sEventsProjectionMap.put(Calendars.VISIBLE, Calendars.VISIBLE); sEventsProjectionMap.put(Calendars.CALENDAR_TIME_ZONE, Calendars.CALENDAR_TIME_ZONE); sEventsProjectionMap.put(Calendars.OWNER_ACCOUNT, Calendars.OWNER_ACCOUNT); sEventsProjectionMap.put(Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_DISPLAY_NAME); sEventsProjectionMap.put(Calendars.ALLOWED_REMINDERS, Calendars.ALLOWED_REMINDERS); sEventsProjectionMap .put(Calendars.ALLOWED_ATTENDEE_TYPES, Calendars.ALLOWED_ATTENDEE_TYPES); sEventsProjectionMap.put(Calendars.ALLOWED_AVAILABILITY, Calendars.ALLOWED_AVAILABILITY); sEventsProjectionMap.put(Calendars.MAX_REMINDERS, Calendars.MAX_REMINDERS); sEventsProjectionMap.put(Calendars.CAN_ORGANIZER_RESPOND, Calendars.CAN_ORGANIZER_RESPOND); sEventsProjectionMap.put(Calendars.CAN_MODIFY_TIME_ZONE, Calendars.CAN_MODIFY_TIME_ZONE); sEventsProjectionMap.put(Events.DISPLAY_COLOR, Events.DISPLAY_COLOR); // Put the shared items into the Instances projection map // The Instances and CalendarAlerts are joined with Calendars, so the projections include // the above Calendar columns. sInstancesProjectionMap = new HashMap(sEventsProjectionMap); sCalendarAlertsProjectionMap = new HashMap(sEventsProjectionMap); sEventsProjectionMap.put(Events._ID, Events._ID); sEventsProjectionMap.put(Events.SYNC_DATA1, Events.SYNC_DATA1); sEventsProjectionMap.put(Events.SYNC_DATA2, Events.SYNC_DATA2); sEventsProjectionMap.put(Events.SYNC_DATA3, Events.SYNC_DATA3); sEventsProjectionMap.put(Events.SYNC_DATA4, Events.SYNC_DATA4); sEventsProjectionMap.put(Events.SYNC_DATA5, Events.SYNC_DATA5); sEventsProjectionMap.put(Events.SYNC_DATA6, Events.SYNC_DATA6); sEventsProjectionMap.put(Events.SYNC_DATA7, Events.SYNC_DATA7); sEventsProjectionMap.put(Events.SYNC_DATA8, Events.SYNC_DATA8); sEventsProjectionMap.put(Events.SYNC_DATA9, Events.SYNC_DATA9); sEventsProjectionMap.put(Events.SYNC_DATA10, Events.SYNC_DATA10); sEventsProjectionMap.put(Calendars.CAL_SYNC1, Calendars.CAL_SYNC1); sEventsProjectionMap.put(Calendars.CAL_SYNC2, Calendars.CAL_SYNC2); sEventsProjectionMap.put(Calendars.CAL_SYNC3, Calendars.CAL_SYNC3); sEventsProjectionMap.put(Calendars.CAL_SYNC4, Calendars.CAL_SYNC4); sEventsProjectionMap.put(Calendars.CAL_SYNC5, Calendars.CAL_SYNC5); sEventsProjectionMap.put(Calendars.CAL_SYNC6, Calendars.CAL_SYNC6); sEventsProjectionMap.put(Calendars.CAL_SYNC7, Calendars.CAL_SYNC7); sEventsProjectionMap.put(Calendars.CAL_SYNC8, Calendars.CAL_SYNC8); sEventsProjectionMap.put(Calendars.CAL_SYNC9, Calendars.CAL_SYNC9); sEventsProjectionMap.put(Calendars.CAL_SYNC10, Calendars.CAL_SYNC10); sEventsProjectionMap.put(Events.DIRTY, Events.DIRTY); sEventsProjectionMap.put(Events.MUTATORS, Events.MUTATORS); sEventsProjectionMap.put(Events.LAST_SYNCED, Events.LAST_SYNCED); sEventEntitiesProjectionMap = new HashMap(); sEventEntitiesProjectionMap.put(Events.TITLE, Events.TITLE); sEventEntitiesProjectionMap.put(Events.EVENT_LOCATION, Events.EVENT_LOCATION); sEventEntitiesProjectionMap.put(Events.DESCRIPTION, Events.DESCRIPTION); sEventEntitiesProjectionMap.put(Events.STATUS, Events.STATUS); sEventEntitiesProjectionMap.put(Events.EVENT_COLOR, Events.EVENT_COLOR); sEventEntitiesProjectionMap.put(Events.EVENT_COLOR_KEY, Events.EVENT_COLOR_KEY); sEventEntitiesProjectionMap.put(Events.SELF_ATTENDEE_STATUS, Events.SELF_ATTENDEE_STATUS); sEventEntitiesProjectionMap.put(Events.DTSTART, Events.DTSTART); sEventEntitiesProjectionMap.put(Events.DTEND, Events.DTEND); sEventEntitiesProjectionMap.put(Events.EVENT_TIMEZONE, Events.EVENT_TIMEZONE); sEventEntitiesProjectionMap.put(Events.EVENT_END_TIMEZONE, Events.EVENT_END_TIMEZONE); sEventEntitiesProjectionMap.put(Events.DURATION, Events.DURATION); sEventEntitiesProjectionMap.put(Events.ALL_DAY, Events.ALL_DAY); sEventEntitiesProjectionMap.put(Events.ACCESS_LEVEL, Events.ACCESS_LEVEL); sEventEntitiesProjectionMap.put(Events.AVAILABILITY, Events.AVAILABILITY); sEventEntitiesProjectionMap.put(Events.HAS_ALARM, Events.HAS_ALARM); sEventEntitiesProjectionMap.put(Events.HAS_EXTENDED_PROPERTIES, Events.HAS_EXTENDED_PROPERTIES); sEventEntitiesProjectionMap.put(Events.RRULE, Events.RRULE); sEventEntitiesProjectionMap.put(Events.RDATE, Events.RDATE); sEventEntitiesProjectionMap.put(Events.EXRULE, Events.EXRULE); sEventEntitiesProjectionMap.put(Events.EXDATE, Events.EXDATE); sEventEntitiesProjectionMap.put(Events.ORIGINAL_SYNC_ID, Events.ORIGINAL_SYNC_ID); sEventEntitiesProjectionMap.put(Events.ORIGINAL_ID, Events.ORIGINAL_ID); sEventEntitiesProjectionMap.put(Events.ORIGINAL_INSTANCE_TIME, Events.ORIGINAL_INSTANCE_TIME); sEventEntitiesProjectionMap.put(Events.ORIGINAL_ALL_DAY, Events.ORIGINAL_ALL_DAY); sEventEntitiesProjectionMap.put(Events.LAST_DATE, Events.LAST_DATE); sEventEntitiesProjectionMap.put(Events.HAS_ATTENDEE_DATA, Events.HAS_ATTENDEE_DATA); sEventEntitiesProjectionMap.put(Events.CALENDAR_ID, Events.CALENDAR_ID); sEventEntitiesProjectionMap.put(Events.GUESTS_CAN_INVITE_OTHERS, Events.GUESTS_CAN_INVITE_OTHERS); sEventEntitiesProjectionMap.put(Events.GUESTS_CAN_MODIFY, Events.GUESTS_CAN_MODIFY); sEventEntitiesProjectionMap.put(Events.GUESTS_CAN_SEE_GUESTS, Events.GUESTS_CAN_SEE_GUESTS); sEventEntitiesProjectionMap.put(Events.ORGANIZER, Events.ORGANIZER); sEventEntitiesProjectionMap.put(Events.IS_ORGANIZER, Events.IS_ORGANIZER); sEventEntitiesProjectionMap.put(Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_PACKAGE); sEventEntitiesProjectionMap.put(Events.CUSTOM_APP_URI, Events.CUSTOM_APP_URI); sEventEntitiesProjectionMap.put(Events.UID_2445, Events.UID_2445); sEventEntitiesProjectionMap.put(Events.DELETED, Events.DELETED); sEventEntitiesProjectionMap.put(Events._ID, Events._ID); sEventEntitiesProjectionMap.put(Events._SYNC_ID, Events._SYNC_ID); sEventEntitiesProjectionMap.put(Events.SYNC_DATA1, Events.SYNC_DATA1); sEventEntitiesProjectionMap.put(Events.SYNC_DATA2, Events.SYNC_DATA2); sEventEntitiesProjectionMap.put(Events.SYNC_DATA3, Events.SYNC_DATA3); sEventEntitiesProjectionMap.put(Events.SYNC_DATA4, Events.SYNC_DATA4); sEventEntitiesProjectionMap.put(Events.SYNC_DATA5, Events.SYNC_DATA5); sEventEntitiesProjectionMap.put(Events.SYNC_DATA6, Events.SYNC_DATA6); sEventEntitiesProjectionMap.put(Events.SYNC_DATA7, Events.SYNC_DATA7); sEventEntitiesProjectionMap.put(Events.SYNC_DATA8, Events.SYNC_DATA8); sEventEntitiesProjectionMap.put(Events.SYNC_DATA9, Events.SYNC_DATA9); sEventEntitiesProjectionMap.put(Events.SYNC_DATA10, Events.SYNC_DATA10); sEventEntitiesProjectionMap.put(Events.DIRTY, Events.DIRTY); sEventEntitiesProjectionMap.put(Events.MUTATORS, Events.MUTATORS); sEventEntitiesProjectionMap.put(Events.LAST_SYNCED, Events.LAST_SYNCED); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC1, Calendars.CAL_SYNC1); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC2, Calendars.CAL_SYNC2); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC3, Calendars.CAL_SYNC3); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC4, Calendars.CAL_SYNC4); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC5, Calendars.CAL_SYNC5); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC6, Calendars.CAL_SYNC6); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC7, Calendars.CAL_SYNC7); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC8, Calendars.CAL_SYNC8); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC9, Calendars.CAL_SYNC9); sEventEntitiesProjectionMap.put(Calendars.CAL_SYNC10, Calendars.CAL_SYNC10); // Instances columns sInstancesProjectionMap.put(Events.DELETED, "Events.deleted as deleted"); sInstancesProjectionMap.put(Instances.BEGIN, "begin"); sInstancesProjectionMap.put(Instances.END, "end"); sInstancesProjectionMap.put(Instances.EVENT_ID, "Instances.event_id AS event_id"); sInstancesProjectionMap.put(Instances._ID, "Instances._id AS _id"); sInstancesProjectionMap.put(Instances.START_DAY, "startDay"); sInstancesProjectionMap.put(Instances.END_DAY, "endDay"); sInstancesProjectionMap.put(Instances.START_MINUTE, "startMinute"); sInstancesProjectionMap.put(Instances.END_MINUTE, "endMinute"); // Attendees columns sAttendeesProjectionMap.put(Attendees.EVENT_ID, "event_id"); sAttendeesProjectionMap.put(Attendees._ID, "Attendees._id AS _id"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_NAME, "attendeeName"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_EMAIL, "attendeeEmail"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_STATUS, "attendeeStatus"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_RELATIONSHIP, "attendeeRelationship"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_TYPE, "attendeeType"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_IDENTITY, "attendeeIdentity"); sAttendeesProjectionMap.put(Attendees.ATTENDEE_ID_NAMESPACE, "attendeeIdNamespace"); sAttendeesProjectionMap.put(Events.DELETED, "Events.deleted AS deleted"); sAttendeesProjectionMap.put(Events._SYNC_ID, "Events._sync_id AS _sync_id"); // Reminders columns sRemindersProjectionMap.put(Reminders.EVENT_ID, "event_id"); sRemindersProjectionMap.put(Reminders._ID, "Reminders._id AS _id"); sRemindersProjectionMap.put(Reminders.MINUTES, "minutes"); sRemindersProjectionMap.put(Reminders.METHOD, "method"); sRemindersProjectionMap.put(Events.DELETED, "Events.deleted AS deleted"); sRemindersProjectionMap.put(Events._SYNC_ID, "Events._sync_id AS _sync_id"); // CalendarAlerts columns sCalendarAlertsProjectionMap.put(CalendarAlerts.EVENT_ID, "event_id"); sCalendarAlertsProjectionMap.put(CalendarAlerts._ID, "CalendarAlerts._id AS _id"); sCalendarAlertsProjectionMap.put(CalendarAlerts.BEGIN, "begin"); sCalendarAlertsProjectionMap.put(CalendarAlerts.END, "end"); sCalendarAlertsProjectionMap.put(CalendarAlerts.ALARM_TIME, "alarmTime"); sCalendarAlertsProjectionMap.put(CalendarAlerts.NOTIFY_TIME, "notifyTime"); sCalendarAlertsProjectionMap.put(CalendarAlerts.STATE, "state"); sCalendarAlertsProjectionMap.put(CalendarAlerts.MINUTES, "minutes"); // CalendarCache columns sCalendarCacheProjectionMap = new HashMap(); sCalendarCacheProjectionMap.put(CalendarCache.COLUMN_NAME_KEY, "key"); sCalendarCacheProjectionMap.put(CalendarCache.COLUMN_NAME_VALUE, "value"); } /** * This is called by AccountManager when the set of accounts is updated. *

* We are overriding this since we need to delete from the * Calendars table, which is not syncable, which has triggers that * will delete from the Events and tables, which are * syncable. TODO: update comment, make sure deletes don't get synced. * * @param accounts The list of currently active accounts. */ @Override public void onAccountsUpdated(Account[] accounts) { Thread thread = new AccountsUpdatedThread(accounts); thread.start(); } private class AccountsUpdatedThread extends Thread { private Account[] mAccounts; AccountsUpdatedThread(Account[] accounts) { mAccounts = accounts; } @Override public void run() { // The process could be killed while the thread runs. Right now that isn't a problem, // because we'll just call removeStaleAccounts() again when the provider restarts, but // if we want to do additional actions we may need to use a service (e.g. start // EmptyService in onAccountsUpdated() and stop it when we finish here). Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); removeStaleAccounts(mAccounts); } } /** * Makes sure there are no entries for accounts that no longer exist. */ private void removeStaleAccounts(Account[] accounts) { mDb = mDbHelper.getWritableDatabase(); if (mDb == null) { return; } HashSet validAccounts = new HashSet(); for (Account account : accounts) { validAccounts.add(new Account(account.name, account.type)); } ArrayList accountsToDelete = new ArrayList(); mDb.beginTransaction(); Cursor c = null; try { for (String table : new String[]{Tables.CALENDARS, Tables.COLORS}) { // Find all the accounts the calendar DB knows about, mark the ones that aren't // in the valid set for deletion. c = mDb.rawQuery("SELECT DISTINCT " + Calendars.ACCOUNT_NAME + "," + Calendars.ACCOUNT_TYPE + " FROM " + table, null); while (c.moveToNext()) { // ACCOUNT_TYPE_LOCAL is to store calendars not associated // with a system account. Typically, a calendar must be // associated with an account on the device or it will be // deleted. if (c.getString(0) != null && c.getString(1) != null && !TextUtils.equals(c.getString(1), CalendarContract.ACCOUNT_TYPE_LOCAL)) { Account currAccount = new Account(c.getString(0), c.getString(1)); if (!validAccounts.contains(currAccount)) { accountsToDelete.add(currAccount); } } } c.close(); c = null; } for (Account account : accountsToDelete) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "removing data for removed account " + account); } String[] params = new String[]{account.name, account.type}; mDb.execSQL(SQL_DELETE_FROM_CALENDARS, params); // This will be a no-op for accounts without a color palette. mDb.execSQL(SQL_DELETE_FROM_COLORS, params); } mDbHelper.getSyncState().onAccountsChanged(mDb, accounts); mDb.setTransactionSuccessful(); } finally { if (c != null) { c.close(); } mDb.endTransaction(); } // make sure the widget reflects the account changes if (!accountsToDelete.isEmpty()) { sendUpdateNotification(false); } } /** * Inserts an argument at the beginning of the selection arg list. * * The {@link android.database.sqlite.SQLiteQueryBuilder}'s where clause is * prepended to the user's where clause (combined with 'AND') to generate * the final where close, so arguments associated with the QueryBuilder are * prepended before any user selection args to keep them in the right order. */ private String[] insertSelectionArg(String[] selectionArgs, String arg) { if (selectionArgs == null) { return new String[] {arg}; } else { int newLength = selectionArgs.length + 1; String[] newSelectionArgs = new String[newLength]; newSelectionArgs[0] = arg; System.arraycopy(selectionArgs, 0, newSelectionArgs, 1, selectionArgs.length); return newSelectionArgs; } } private String getCallingPackageName() { if (getCachedCallingPackage() != null) { // If the calling package is null, use the best available as a fallback. return getCachedCallingPackage(); } if (!Boolean.TRUE.equals(mCallingPackageErrorLogged.get())) { Log.e(TAG, "Failed to get the cached calling package.", new Throwable()); mCallingPackageErrorLogged.set(Boolean.TRUE); } final PackageManager pm = getContext().getPackageManager(); final int uid = Binder.getCallingUid(); final String[] packages = pm.getPackagesForUid(uid); if (packages != null && packages.length == 1) { return packages[0]; } final String name = pm.getNameForUid(uid); if (name != null) { return name; } return String.valueOf(uid); } private void addMutator(ContentValues values, String columnName) { final String packageName = getCallingPackageName(); final String mutators = values.getAsString(columnName); if (TextUtils.isEmpty(mutators)) { values.put(columnName, packageName); } else { values.put(columnName, mutators + "," + packageName); } } }