blob: 4bc51dc06348e1617091c2cdf175409d7e335b22 [file] [log] [blame]
/*
**
** Copyright 2006, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** See the License for the specific language governing permissions and
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** limitations under the License.
*/
package com.android.providers.calendar;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.OperationCanceledException;
import android.accounts.AuthenticatorException;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.AbstractSyncableContentProvider;
import android.content.AbstractTableMerger;
import android.content.BroadcastReceiver;
import android.content.ContentProvider;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Entity;
import android.content.EntityIterator;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.OperationApplicationException;
import android.content.SyncContext;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.SQLException;
import android.database.sqlite.SQLiteCursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.Bundle;
import android.os.Debug;
import android.os.Process;
import android.os.RemoteException;
import android.pim.DateException;
import android.pim.RecurrenceSet;
import android.provider.Calendar;
import android.provider.Calendar.Attendees;
import android.provider.Calendar.BusyBits;
import android.provider.Calendar.CalendarAlerts;
import android.provider.Calendar.Calendars;
import android.provider.Calendar.Events;
import android.provider.Calendar.ExtendedProperties;
import android.provider.Calendar.Instances;
import android.provider.Calendar.Reminders;
import android.text.TextUtils;
import android.text.format.Time;
import android.util.Config;
import android.util.Log;
import android.util.TimeFormatException;
import com.google.android.collect.Maps;
import com.google.android.collect.Sets;
import com.google.android.gdata.client.AndroidGDataClient;
import com.google.android.gdata.client.AndroidXmlParserFactory;
import com.google.android.providers.AbstractGDataSyncAdapter;
import com.google.android.providers.AbstractGDataSyncAdapter.GDataSyncData;
import com.google.android.googlelogin.GoogleLoginServiceConstants;
import com.google.wireless.gdata.calendar.client.CalendarClient;
import com.google.wireless.gdata.calendar.parser.xml.XmlCalendarGDataParserFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.io.IOException;
public class CalendarProvider extends AbstractSyncableContentProvider {
private static final boolean PROFILE = false;
private static final boolean MULTIPLE_ATTENDEES_PER_EVENT = true;
private static final String[] ACCOUNTS_PROJECTION =
new String[] {Calendars._SYNC_ACCOUNT, Calendars._SYNC_ACCOUNT_TYPE};
private static final String[] EVENTS_PROJECTION = new String[] {
Events._SYNC_ID,
Events._SYNC_VERSION,
Events._SYNC_ACCOUNT,
Events._SYNC_ACCOUNT_TYPE,
Events.CALENDAR_ID,
Events.RRULE,
Events.RDATE,
Events.ORIGINAL_EVENT,
};
private static final int EVENTS_SYNC_ID_INDEX = 0;
private static final int EVENTS_SYNC_VERSION_INDEX = 1;
private static final int EVENTS_SYNC_ACCOUNT_NAME_INDEX = 2;
private static final int EVENTS_SYNC_ACCOUNT_TYPE_INDEX = 3;
private static final int EVENTS_CALENDAR_ID_INDEX = 4;
private static final int EVENTS_RRULE_INDEX = 5;
private static final int EVENTS_RDATE_INDEX = 6;
private static final int EVENTS_ORIGINAL_EVENT_INDEX = 7;
private DatabaseUtils.InsertHelper mCalendarsInserter;
private DatabaseUtils.InsertHelper mEventsInserter;
private DatabaseUtils.InsertHelper mEventsRawTimesInserter;
private DatabaseUtils.InsertHelper mDeletedEventsInserter;
private DatabaseUtils.InsertHelper mInstancesInserter;
private DatabaseUtils.InsertHelper mAttendeesInserter;
private DatabaseUtils.InsertHelper mRemindersInserter;
private DatabaseUtils.InsertHelper mCalendarAlertsInserter;
private DatabaseUtils.InsertHelper mExtendedPropertiesInserter;
/**
* The cached copy of the CalendarMetaData database table.
* Make this "package private" instead of "private" so that test code
* can access it.
*/
MetaData mMetaData;
// The interval in minutes for calculating busy bits
private static final int BUSYBIT_INTERVAL = 60;
// A lookup table for getting a bit mask of length N, for N <= 32
// For example, BIT_MASKS[4] gives 0xf (which has 4 bits set to 1).
// We use this for computing the busy bits for events.
private static final int[] BIT_MASKS = {
0,
0x00000001, 0x00000003, 0x00000007, 0x0000000f,
0x0000001f, 0x0000003f, 0x0000007f, 0x000000ff,
0x000001ff, 0x000003ff, 0x000007ff, 0x00000fff,
0x00001fff, 0x00003fff, 0x00007fff, 0x0000ffff,
0x0001ffff, 0x0003ffff, 0x0007ffff, 0x000fffff,
0x001fffff, 0x003fffff, 0x007fffff, 0x00ffffff,
0x01ffffff, 0x03ffffff, 0x07ffffff, 0x0fffffff,
0x1fffffff, 0x3fffffff, 0x7fffffff, 0xffffffff,
};
// To determine if a recurrence exception originally overlapped the
// window, we need to assume a maximum duration, since we only know
// the original start time.
private static final int MAX_ASSUMED_DURATION = 7*24*60*60*1000;
public static final class TimeRange {
public long begin;
public long end;
public boolean allDay;
}
public static final class InstancesRange {
public long begin;
public long end;
public InstancesRange(long begin, long end) {
this.begin = begin;
this.end = end;
}
}
public static final class InstancesList
extends ArrayList<ContentValues> {
}
public static final class EventInstancesMap
extends HashMap<String, InstancesList> {
public void add(String syncId, ContentValues values) {
InstancesList instances = get(syncId);
if (instances == null) {
instances = new InstancesList();
put(syncId, instances);
}
instances.add(values);
}
}
// A thread that runs in the background and schedules the next
// calendar event alarm.
private class AlarmScheduler extends Thread {
boolean mRemoveAlarms;
public AlarmScheduler(boolean removeAlarms) {
mRemoveAlarms = removeAlarms;
}
public void run() {
try {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
runScheduleNextAlarm(mRemoveAlarms);
} catch (SQLException e) {
Log.e(TAG, "runScheduleNextAlarm() failed", e);
}
}
}
/**
* We search backward in time for event reminders that we may have missed
* and schedule them if the event has not yet expired. The amount in
* the past to search backwards is controlled by this constant. It
* should be at least a few minutes to allow for an event that was
* recently created on the web to make its way to the phone. Two hours
* might seem like overkill, but it is useful in the case where the user
* just crossed into a new timezone and might have just missed an alarm.
*/
private static final long SCHEDULE_ALARM_SLACK = 2 * android.text.format.DateUtils.HOUR_IN_MILLIS;
/**
* Alarms older than this threshold will be deleted from the CalendarAlerts
* table. This should be at least a day because if the timezone is
* wrong and the user corrects it we might delete good alarms that
* appear to be old because the device time was incorrectly in the future.
* This threshold must also be larger than SCHEDULE_ALARM_SLACK. We add
* the SCHEDULE_ALARM_SLACK to ensure this.
*
* To make it easier to find and debug problems with missed reminders,
* set this to something greater than a day.
*/
private static final long CLEAR_OLD_ALARM_THRESHOLD =
7 * android.text.format.DateUtils.DAY_IN_MILLIS + SCHEDULE_ALARM_SLACK;
// A lock for synchronizing access to fields that are shared
// with the AlarmScheduler thread.
private Object mAlarmLock = new Object();
private static final String TAG = "CalendarProvider";
private static final String DATABASE_NAME = "calendar.db";
// Note: if you update the version number, you must also update the code
// in upgradeDatabase() to modify the database (gracefully, if possible).
private static final int DATABASE_VERSION = 57;
// 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;
// Allocate the string constant once here instead of on the heap
private static final String CALENDAR_ID_SELECTION = "calendar_id=?";
private static final String[] sInstancesProjection =
new String[] { Instances.START_DAY, Instances.END_DAY,
Instances.START_MINUTE, Instances.END_MINUTE, Instances.ALL_DAY };
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;
private static final String[] sBusyBitProjection = new String[] {
BusyBits.DAY, BusyBits.BUSYBITS, BusyBits.ALL_DAY_COUNT };
private static final int BUSYBIT_INDEX_DAY = 0;
private static final int BUSYBIT_INDEX_BUSYBITS= 1;
private static final int BUSYBIT_INDEX_ALL_DAY_COUNT = 2;
private CalendarClient mCalendarClient = null;
private AlarmManager mAlarmManager;
private CalendarAppWidgetProvider mAppWidgetProvider = CalendarAppWidgetProvider.getInstance();
/**
* 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();
scheduleNextAlarm(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();
scheduleNextAlarm(false /* do not remove alarms */);
} else if (Intent.ACTION_TIME_CHANGED.equals(action)) {
scheduleNextAlarm(false /* do not remove alarms */);
}
}
};
public CalendarProvider() {
super(DATABASE_NAME, DATABASE_VERSION, Calendars.CONTENT_URI);
}
@Override
public boolean onCreate() {
super.onCreate();
setTempProviderSyncAdapter(new CalendarSyncAdapter(getContext(), this));
// 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);
final Context c = getContext();
// 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.
c.registerReceiver(mIntentReceiver, filter);
mMetaData = new MetaData(mOpenHelper);
updateTimezoneDependentFields();
return true;
}
/**
* This creates a background thread to check the timezone and update
* the timezone dependent fields in the Instances table if the timezone
* has changes.
*/
private void updateTimezoneDependentFields() {
Thread thread = new TimezoneCheckerThread();
thread.start();
}
private class TimezoneCheckerThread extends Thread {
@Override
public void run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
try {
doUpdateTimezoneDependentFields();
} catch (SQLException e) {
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) {
Log.e(TAG, "clearInstanceRange() also failed: " + e2);
}
}
}
}
/**
* This method runs in a background thread. If the timezone has changed
* then the Instances table will be regenerated.
*/
private void doUpdateTimezoneDependentFields() {
MetaData.Fields fields = mMetaData.getFields();
String localTimezone = TimeZone.getDefault().getID();
if (TextUtils.equals(fields.timezone, localTimezone)) {
// Even if the timezone hasn't changed, check for missed alarms.
// This code executes when the CalendarProvider is created and
// helps to catch missed alarms when the Calendar process is
// killed (because of low-memory conditions) and then restarted.
rescheduleMissedAlarms();
return;
}
// 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();
Time time = new Time();
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;
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
handleInstanceQuery(qb, begin, end, new String[] { Instances._ID },
null /* selection */, null /* sort */, false /* searchByDayInsteadOfMillis */);
// Also pre-compute the BusyBits table for this month.
int startDay = Time.getJulianDay(begin, time.gmtoff);
int endDay = startDay + 31;
qb = new SQLiteQueryBuilder();
handleBusyBitsQuery(qb, startDay, endDay, sBusyBitProjection,
null /* selection */, null /* sort */);
rescheduleMissedAlarms();
}
private void rescheduleMissedAlarms() {
AlarmManager manager = getAlarmManager();
if (manager != null) {
Context context = getContext();
ContentResolver cr = context.getContentResolver();
CalendarAlerts.rescheduleMissedAlarms(cr, context, manager);
}
}
@Override
protected void onDatabaseOpened(SQLiteDatabase db) {
db.markTableSyncable("Events", "DeletedEvents");
if (!isTemporary()) {
mCalendarClient = new CalendarClient(
new AndroidGDataClient(getContext(), CalendarSyncAdapter.USER_AGENT_APP_VERSION),
new XmlCalendarGDataParserFactory(
new AndroidXmlParserFactory()));
}
mCalendarsInserter = new DatabaseUtils.InsertHelper(db, "Calendars");
mEventsInserter = new DatabaseUtils.InsertHelper(db, "Events");
mEventsRawTimesInserter = new DatabaseUtils.InsertHelper(db, "EventsRawTimes");
mDeletedEventsInserter = new DatabaseUtils.InsertHelper(db, "DeletedEvents");
mInstancesInserter = new DatabaseUtils.InsertHelper(db, "Instances");
mAttendeesInserter = new DatabaseUtils.InsertHelper(db, "Attendees");
mRemindersInserter = new DatabaseUtils.InsertHelper(db, "Reminders");
mCalendarAlertsInserter = new DatabaseUtils.InsertHelper(db, "CalendarAlerts");
mExtendedPropertiesInserter =
new DatabaseUtils.InsertHelper(db, "ExtendedProperties");
}
@Override
protected boolean upgradeDatabase(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.i(TAG, "Upgrading DB from version " + oldVersion
+ " to " + newVersion);
if (oldVersion < 46) {
dropTables(db);
bootstrapDatabase(db);
return false; // this was lossy
}
if (oldVersion == 46) {
Log.w(TAG, "Upgrading CalendarAlerts table");
db.execSQL("UPDATE CalendarAlerts SET reminder_id=NULL;");
db.execSQL("ALTER TABLE CalendarAlerts ADD COLUMN minutes INTEGER DEFAULT 0;");
oldVersion += 1;
}
if (oldVersion == 47) {
// Changing to version 48 was intended to force a data wipe
dropTables(db);
bootstrapDatabase(db);
return false; // this was lossy
}
if (oldVersion == 48) {
// Changing to version 49 was intended to force a data wipe
dropTables(db);
bootstrapDatabase(db);
return false; // this was lossy
}
if (oldVersion == 49) {
Log.w(TAG, "Upgrading DeletedEvents table");
// We don't have enough information to fill in the correct
// value of the calendar_id for old rows in the DeletedEvents
// table, but rows in that table are transient so it is unlikely
// that there are any rows. Plus, the calendar_id is used only
// when deleting a calendar, which is a rare event. All new rows
// will have the correct calendar_id.
db.execSQL("ALTER TABLE DeletedEvents ADD COLUMN calendar_id INTEGER;");
// Trigger to remove a calendar's events when we delete the calendar
db.execSQL("DROP TRIGGER IF EXISTS calendar_cleanup");
db.execSQL("CREATE TRIGGER calendar_cleanup DELETE ON Calendars " +
"BEGIN " +
"DELETE FROM Events WHERE calendar_id = old._id;" +
"DELETE FROM DeletedEvents WHERE calendar_id = old._id;" +
"END");
db.execSQL("DROP TRIGGER IF EXISTS event_to_deleted");
oldVersion += 1;
}
if (oldVersion == 50) {
// This should have been deleted in the upgrade from version 49
// but we missed it.
db.execSQL("DROP TRIGGER IF EXISTS event_to_deleted");
oldVersion += 1;
}
if (oldVersion == 51) {
// We added "originalAllDay" to the Events table to keep track of
// the allDay status of the original recurring event for entries
// that are exceptions to that recurring event. We need this so
// that we can format the date correctly for the "originalInstanceTime"
// column when we make a change to the recurrence exception and
// send it to the server.
db.execSQL("ALTER TABLE Events ADD COLUMN originalAllDay INTEGER;");
// Iterate through the Events table and for each recurrence
// exception, fill in the correct value for "originalAllDay",
// if possible. The only times where this might not be possible
// are (1) the original recurring event no longer exists, or
// (2) the original recurring event does not yet have a _sync_id
// because it was created on the phone and hasn't been synced to the
// server yet. In both cases the originalAllDay field will be set
// to null. In the first case we don't care because the recurrence
// exception will not be displayed and we won't be able to make
// any changes to it (and even if we did, the server should ignore
// them, right?). In the second case, the calendar client already
// disallows making changes to an instance of a recurring event
// until the recurring event has been synced to the server so the
// second case should never occur.
// "cursor" iterates over all the recurrences exceptions.
Cursor cursor = db.rawQuery("SELECT _id,originalEvent FROM Events"
+ " WHERE originalEvent IS NOT NULL", null /* selection args */);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
long id = cursor.getLong(0);
String originalEvent = cursor.getString(1);
// Find the original recurring event (if it exists)
Cursor recur = db.rawQuery("SELECT allDay FROM Events"
+ " WHERE _sync_id=?", new String[] {originalEvent});
if (recur == null) {
continue;
}
try {
// Fill in the "originalAllDay" field of the
// recurrence exception with the "allDay" value
// from the recurring event.
if (recur.moveToNext()) {
int allDay = recur.getInt(0);
db.execSQL("UPDATE Events SET originalAllDay=" + allDay
+ " WHERE _id="+id);
}
} finally {
recur.close();
}
}
} finally {
cursor.close();
}
}
oldVersion += 1;
}
if (oldVersion == 52) {
Log.w(TAG, "Upgrading CalendarAlerts table");
db.execSQL("ALTER TABLE CalendarAlerts ADD COLUMN creationTime INTEGER DEFAULT 0;");
db.execSQL("ALTER TABLE CalendarAlerts ADD COLUMN receivedTime INTEGER DEFAULT 0;");
db.execSQL("ALTER TABLE CalendarAlerts ADD COLUMN notifyTime INTEGER DEFAULT 0;");
oldVersion += 1;
}
if (oldVersion == 53) {
Log.w(TAG, "adding eventSyncAccountAndIdIndex");
db.execSQL("CREATE INDEX eventSyncAccountAndIdIndex ON Events ("
+ Events._SYNC_ACCOUNT + ", " + Events._SYNC_ID + ");");
oldVersion += 1;
}
if (oldVersion == 54) {
db.execSQL("ALTER TABLE Calendars ADD COLUMN _sync_account_type TEXT;");
db.execSQL("ALTER TABLE Events ADD COLUMN _sync_account_type TEXT;");
db.execSQL("ALTER TABLE DeletedEvents ADD COLUMN _sync_account_type TEXT;");
db.execSQL("UPDATE Calendars"
+ " SET _sync_account_type='com.google'"
+ " WHERE _sync_account IS NOT NULL");
db.execSQL("UPDATE Events"
+ " SET _sync_account_type='com.google'"
+ " WHERE _sync_account IS NOT NULL");
db.execSQL("UPDATE DeletedEvents"
+ " SET _sync_account_type='com.google'"
+ " WHERE _sync_account IS NOT NULL");
Log.w(TAG, "re-creating eventSyncAccountAndIdIndex");
db.execSQL("DROP INDEX eventSyncAccountAndIdIndex");
db.execSQL("CREATE INDEX eventSyncAccountAndIdIndex ON Events ("
+ Events._SYNC_ACCOUNT_TYPE + ", " + Events._SYNC_ACCOUNT + ", "
+ Events._SYNC_ID + ");");
oldVersion += 1;
}
if (oldVersion == 55 || oldVersion == 56) { // Both require resync
// Delete sync state, so all records will be re-synced.
db.execSQL("DELETE FROM _sync_state;");
// "cursor" iterates over all the calendars
Cursor cursor = db.rawQuery("SELECT _sync_account,_sync_account_type,url "
+ "FROM Calendars",
null /* selection args */);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
String accountName = cursor.getString(0);
String accountType = cursor.getString(1);
final Account account = new Account(accountName, accountType);
String calendarUrl = cursor.getString(2);
scheduleSync(account, false /* two-way sync */, calendarUrl);
}
} finally {
cursor.close();
}
}
}
if (oldVersion == 55) {
db.execSQL("ALTER TABLE Calendars ADD COLUMN ownerAccount TEXT;");
db.execSQL("ALTER TABLE Events ADD COLUMN hasAttendeeData INTEGER;");
// Clear _sync_dirty to avoid a client-to-server sync that could blow away
// server attendees.
// Clear _sync_version to pull down the server's event (with attendees)
// Change the URLs from full-selfattendance to full
db.execSQL("UPDATE Events"
+ " SET _sync_dirty=0,"
+ " _sync_version=NULL,"
+ " _sync_id="
+ "REPLACE(_sync_id, '/private/full-selfattendance', '/private/full'),"
+ " commentsUri ="
+ "REPLACE(commentsUri, '/private/full-selfattendance', '/private/full');");
db.execSQL("UPDATE Calendars"
+ " SET url="
+ "REPLACE(url, '/private/full-selfattendance', '/private/full');");
// "cursor" iterates over all the calendars
Cursor cursor = db.rawQuery("SELECT _id, url FROM Calendars",
null /* selection args */);
// Add the owner column.
if (cursor != null) {
try {
while (cursor.moveToNext()) {
Long id = cursor.getLong(0);
String url = cursor.getString(1);
String owner = CalendarSyncAdapter.calendarEmailAddressFromFeedUrl(url);
db.execSQL("UPDATE Calendars SET ownerAccount=? WHERE _id=?",
new Object[] {owner, id});
}
} finally {
cursor.close();
}
}
oldVersion += 1;
}
if (oldVersion == 56) {
db.execSQL("ALTER TABLE Events ADD COLUMN guestsCanModify"
+ " INTEGER NOT NULL DEFAULT 0;");
db.execSQL("ALTER TABLE Events ADD COLUMN guestsCanInviteOthers"
+ " INTEGER NOT NULL DEFAULT 1;");
db.execSQL("ALTER TABLE Events ADD COLUMN guestsCanSeeGuests"
+ " INTEGER NOT NULL DEFAULT 1;");
db.execSQL("ALTER TABLE Events ADD COLUMN organizer STRING;");
db.execSQL("UPDATE Events SET organizer="
+ "(SELECT attendeeEmail FROM Attendees WHERE "
+ "Attendees.event_id = Events._id AND Attendees.attendeeRelationship=2);");
oldVersion += 1;
}
return true; // this was lossless
}
private void dropTables(SQLiteDatabase db) {
db.execSQL("DROP TABLE IF EXISTS Calendars;");
db.execSQL("DROP TABLE IF EXISTS Events;");
db.execSQL("DROP TABLE IF EXISTS EventsRawTimes;");
db.execSQL("DROP TABLE IF EXISTS DeletedEvents;");
db.execSQL("DROP TABLE IF EXISTS Instances;");
db.execSQL("DROP TABLE IF EXISTS CalendarMetaData;");
db.execSQL("DROP TABLE IF EXISTS BusyBits;");
db.execSQL("DROP TABLE IF EXISTS Attendees;");
db.execSQL("DROP TABLE IF EXISTS Reminders;");
db.execSQL("DROP TABLE IF EXISTS CalendarAlerts;");
db.execSQL("DROP TABLE IF EXISTS ExtendedProperties;");
}
@Override
protected void bootstrapDatabase(SQLiteDatabase db) {
super.bootstrapDatabase(db);
db.execSQL("CREATE TABLE Calendars (" +
"_id INTEGER PRIMARY KEY," +
"_sync_account TEXT," +
"_sync_account_type TEXT," +
"_sync_id TEXT," +
"_sync_version TEXT," +
"_sync_time TEXT," + // UTC
"_sync_local_id INTEGER," +
"_sync_dirty INTEGER," +
"_sync_mark INTEGER," + // Used to filter out new rows
"url TEXT," +
"name TEXT," +
"displayName TEXT," +
"hidden INTEGER NOT NULL DEFAULT 0," +
"color INTEGER," +
"access_level INTEGER," +
"selected INTEGER NOT NULL DEFAULT 1," +
"sync_events INTEGER NOT NULL DEFAULT 0," +
"location TEXT," +
"timezone TEXT," +
"ownerAccount TEXT" +
");");
// Trigger to remove a calendar's events when we delete the calendar
db.execSQL("CREATE TRIGGER calendar_cleanup DELETE ON Calendars " +
"BEGIN " +
"DELETE FROM Events WHERE calendar_id = old._id;" +
"DELETE FROM DeletedEvents WHERE calendar_id = old._id;" +
"END");
// TODO: do we need both dtend and duration?
db.execSQL("CREATE TABLE Events (" +
"_id INTEGER PRIMARY KEY," +
"_sync_account TEXT," +
"_sync_account_type TEXT," +
"_sync_id TEXT," +
"_sync_version TEXT," +
"_sync_time TEXT," + // UTC
"_sync_local_id INTEGER," +
"_sync_dirty INTEGER," +
"_sync_mark INTEGER," + // To filter out new rows
"calendar_id INTEGER NOT NULL," +
"htmlUri TEXT," +
"title TEXT," +
"eventLocation TEXT," +
"description TEXT," +
"eventStatus INTEGER," +
"selfAttendeeStatus INTEGER NOT NULL DEFAULT 0," +
"commentsUri TEXT," +
"dtstart INTEGER," + // millis since epoch
"dtend INTEGER," + // millis since epoch
"eventTimezone TEXT," + // timezone for event
"duration TEXT," +
"allDay INTEGER NOT NULL DEFAULT 0," +
"visibility INTEGER NOT NULL DEFAULT 0," +
"transparency INTEGER NOT NULL DEFAULT 0," +
"hasAlarm INTEGER NOT NULL DEFAULT 0," +
"hasExtendedProperties INTEGER NOT NULL DEFAULT 0," +
"rrule TEXT," +
"rdate TEXT," +
"exrule TEXT," +
"exdate TEXT," +
"originalEvent TEXT," + // _sync_id of recurring event
"originalInstanceTime INTEGER," + // millis since epoch
"originalAllDay INTEGER," +
"lastDate INTEGER," + // millis since epoch
"hasAttendeeData INTEGER NOT NULL DEFAULT 0," +
"guestsCanModify INTEGER NOT NULL DEFAULT 0," +
"guestsCanInviteOthers INTEGER NOT NULL DEFAULT 1," +
"guestsCanSeeGuests INTEGER NOT NULL DEFAULT 1," +
"organizer STRING" +
");");
db.execSQL("CREATE INDEX eventSyncAccountAndIdIndex ON Events ("
+ Events._SYNC_ACCOUNT_TYPE + ", " + Events._SYNC_ACCOUNT + ", "
+ Events._SYNC_ID + ");");
db.execSQL("CREATE INDEX eventsCalendarIdIndex ON Events (" +
Events.CALENDAR_ID +
");");
db.execSQL("CREATE TABLE EventsRawTimes (" +
"_id INTEGER PRIMARY KEY," +
"event_id INTEGER NOT NULL," +
"dtstart2445 TEXT," +
"dtend2445 TEXT," +
"originalInstanceTime2445 TEXT," +
"lastDate2445 TEXT," +
"UNIQUE (event_id)" +
");");
// NOTE: we do not create a trigger to delete an event's instances upon update,
// as all rows currently get updated during a merge.
db.execSQL("CREATE TABLE DeletedEvents (" +
"_sync_id TEXT," +
"_sync_version TEXT," +
"_sync_account TEXT," +
"_sync_account_type TEXT," +
(isTemporary() ? "_sync_local_id INTEGER," : "") + // Used while syncing,
"_sync_mark INTEGER," + // To filter out new rows
"calendar_id INTEGER" +
");");
db.execSQL("CREATE TABLE Instances (" +
"_id INTEGER PRIMARY KEY," +
"event_id INTEGER," +
"begin INTEGER," + // UTC millis
"end INTEGER," + // UTC millis
"startDay INTEGER," + // Julian start day
"endDay INTEGER," + // Julian end day
"startMinute INTEGER," + // minutes from midnight
"endMinute INTEGER," + // minutes from midnight
"UNIQUE (event_id, begin, end)" +
");");
db.execSQL("CREATE INDEX instancesStartDayIndex ON Instances (" +
Instances.START_DAY +
");");
db.execSQL("CREATE TABLE CalendarMetaData (" +
"_id INTEGER PRIMARY KEY," +
"localTimezone TEXT," +
"minInstance INTEGER," + // UTC millis
"maxInstance INTEGER," + // UTC millis
"minBusyBits INTEGER," + // UTC millis
"maxBusyBits INTEGER" + // UTC millis
");");
db.execSQL("CREATE TABLE BusyBits(" +
"day INTEGER PRIMARY KEY," + // the Julian day
"busyBits INTEGER," + // 24 bits for 60-minute intervals
"allDayCount INTEGER" + // number of all-day events
");");
db.execSQL("CREATE TABLE Attendees (" +
"_id INTEGER PRIMARY KEY," +
"event_id INTEGER," +
"attendeeName TEXT," +
"attendeeEmail TEXT," +
"attendeeStatus INTEGER," +
"attendeeRelationship INTEGER," +
"attendeeType INTEGER" +
");");
db.execSQL("CREATE INDEX attendeesEventIdIndex ON Attendees (" +
Attendees.EVENT_ID +
");");
db.execSQL("CREATE TABLE Reminders (" +
"_id INTEGER PRIMARY KEY," +
"event_id INTEGER," +
"minutes INTEGER," +
"method INTEGER NOT NULL" +
" DEFAULT " + Reminders.METHOD_DEFAULT +
");");
db.execSQL("CREATE INDEX remindersEventIdIndex ON Reminders (" +
Reminders.EVENT_ID +
");");
// This table stores the Calendar notifications that have gone off.
db.execSQL("CREATE TABLE CalendarAlerts (" +
"_id INTEGER PRIMARY KEY," +
"event_id INTEGER," +
"begin INTEGER NOT NULL," + // UTC millis
"end INTEGER NOT NULL," + // UTC millis
"alarmTime INTEGER NOT NULL," + // UTC millis
"creationTime INTEGER NOT NULL," + // UTC millis
"receivedTime INTEGER NOT NULL," + // UTC millis
"notifyTime INTEGER NOT NULL," + // UTC millis
"state INTEGER NOT NULL," +
"minutes INTEGER," +
"UNIQUE (alarmTime, begin, event_id)" +
");");
db.execSQL("CREATE INDEX calendarAlertsEventIdIndex ON CalendarAlerts (" +
CalendarAlerts.EVENT_ID +
");");
db.execSQL("CREATE TABLE ExtendedProperties (" +
"_id INTEGER PRIMARY KEY," +
"event_id INTEGER," +
"name TEXT," +
"value TEXT" +
");");
db.execSQL("CREATE INDEX extendedPropertiesEventIdIndex ON ExtendedProperties (" +
ExtendedProperties.EVENT_ID +
");");
// Trigger to remove data tied to an event when we delete that event.
db.execSQL("CREATE TRIGGER events_cleanup_delete DELETE ON Events " +
"BEGIN " +
"DELETE FROM Instances WHERE event_id = old._id;" +
"DELETE FROM EventsRawTimes WHERE event_id = old._id;" +
"DELETE FROM Attendees WHERE event_id = old._id;" +
"DELETE FROM Reminders WHERE event_id = old._id;" +
"DELETE FROM CalendarAlerts WHERE event_id = old._id;" +
"DELETE FROM ExtendedProperties WHERE event_id = old._id;" +
"END");
// Triggers to set the _sync_dirty flag when an attendee is changed,
// inserted or deleted
db.execSQL("CREATE TRIGGER attendees_update UPDATE ON Attendees " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=old.event_id;" +
"END");
db.execSQL("CREATE TRIGGER attendees_insert INSERT ON Attendees " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=new.event_id;" +
"END");
db.execSQL("CREATE TRIGGER attendees_delete DELETE ON Attendees " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=old.event_id;" +
"END");
// Triggers to set the _sync_dirty flag when a reminder is changed,
// inserted or deleted
db.execSQL("CREATE TRIGGER reminders_update UPDATE ON Reminders " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=old.event_id;" +
"END");
db.execSQL("CREATE TRIGGER reminders_insert INSERT ON Reminders " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=new.event_id;" +
"END");
db.execSQL("CREATE TRIGGER reminders_delete DELETE ON Reminders " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=old.event_id;" +
"END");
// Triggers to set the _sync_dirty flag when an extended property is changed,
// inserted or deleted
db.execSQL("CREATE TRIGGER extended_properties_update UPDATE ON ExtendedProperties " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=old.event_id;" +
"END");
db.execSQL("CREATE TRIGGER extended_properties_insert UPDATE ON ExtendedProperties " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=new.event_id;" +
"END");
db.execSQL("CREATE TRIGGER extended_properties_delete UPDATE ON ExtendedProperties " +
"BEGIN " +
"UPDATE Events SET _sync_dirty=1 WHERE Events._id=old.event_id;" +
"END");
}
/**
* Make sure that there are no entries for accounts that no longer
* exist. 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 DeletedEvents tables, which are
* syncable.
*/
@Override
protected void onAccountsChanged(final Account[] accountsArray) {
super.onAccountsChanged(accountsArray);
final Map<Account, Boolean> accounts = Maps.newHashMap();
for (Account account : accountsArray) {
accounts.put(account, false);
}
mDb.beginTransaction();
try {
deleteRowsForRemovedAccounts(accounts, "Calendars");
mDb.setTransactionSuccessful();
} finally {
mDb.endTransaction();
}
if (mCalendarClient == null) {
return;
}
// If we have calendars for unknown accounts, delete them.
// If there are no calendars at all for a given account, add the
// default calendar.
// TODO: allow caller to specify which account's feeds should be updated
String[] features = new String[]{
GoogleLoginServiceConstants.FEATURE_LEGACY_HOSTED_OR_GOOGLE};
AccountManagerCallback<Account[]> callback = new AccountManagerCallback<Account[]>() {
public void run(AccountManagerFuture<Account[]> accountManagerFuture) {
Account[] currentAccounts = new Account[0];
try {
currentAccounts = accountManagerFuture.getResult();
} catch (OperationCanceledException e) {
Log.w(TAG, "onAccountsChanged", e);
return;
} catch (IOException e) {
Log.w(TAG, "onAccountsChanged", e);
return;
} catch (AuthenticatorException e) {
Log.w(TAG, "onAccountsChanged", e);
return;
}
if (currentAccounts.length < 1) {
Log.w(TAG, "getPrimaryAccount: no primary account configured.");
return;
}
Account primaryAccount = currentAccounts[0];
for (Map.Entry<Account, Boolean> entry : accounts.entrySet()) {
// TODO: change this when Calendar supports multiple accounts. Until then
// pretend that only the primary exists.
boolean ignore = primaryAccount == null ||
!primaryAccount.equals(entry.getKey());
entry.setValue(ignore);
}
Set<Account> handledAccounts = Sets.newHashSet();
if (Config.LOGV) Log.v(TAG, "querying calendars");
Cursor c = queryInternal(Calendars.CONTENT_URI, ACCOUNTS_PROJECTION, null, null,
null);
try {
while (c.moveToNext()) {
final String accountName = c.getString(0);
final String accountType = c.getString(1);
final Account account = new Account(accountName, accountType);
if (handledAccounts.contains(account)) {
continue;
}
handledAccounts.add(account);
if (accounts.containsKey(account)) {
if (Config.LOGV) {
Log.v(TAG, "calendars for account " + account + " exist");
}
accounts.put(account, true /* hasCalendar */);
}
}
} finally {
c.close();
c = null;
}
if (Config.LOGV) {
Log.v(TAG, "scanning over " + accounts.size() + " account(s)");
}
for (Map.Entry<Account, Boolean> entry : accounts.entrySet()) {
final Account account = entry.getKey();
boolean hasCalendar = entry.getValue();
if (hasCalendar) {
if (Config.LOGV) {
Log.v(TAG, "ignoring account " + account +
" since it matched an existing calendar");
}
continue;
}
String feedUrl = mCalendarClient.getDefaultCalendarUrl(account.name,
CalendarClient.PROJECTION_PRIVATE_FULL, null/* query params */);
feedUrl = CalendarSyncAdapter.rewriteUrlforAccount(account, feedUrl);
if (Config.LOGV) {
Log.v(TAG, "adding default calendar for account " + account);
}
ContentValues values = new ContentValues();
values.put(Calendars._SYNC_ACCOUNT, account.name);
values.put(Calendars._SYNC_ACCOUNT_TYPE, account.type);
values.put(Calendars.URL, feedUrl);
values.put(Calendars.OWNER_ACCOUNT,
CalendarSyncAdapter.calendarEmailAddressFromFeedUrl(feedUrl));
values.put(Calendars.DISPLAY_NAME,
getContext().getString(R.string.calendar_default_name));
values.put(Calendars.SYNC_EVENTS, 1);
values.put(Calendars.SELECTED, 1);
values.put(Calendars.HIDDEN, 0);
values.put(Calendars.COLOR, -14069085 /* blue */);
// this is just our best guess. the real value will get updated
// when the user does a sync.
values.put(Calendars.TIMEZONE, Time.getCurrentTimezone());
values.put(Calendars.ACCESS_LEVEL, Calendars.OWNER_ACCESS);
insertInternal(Calendars.CONTENT_URI, values);
scheduleSync(account, false /* do a full sync */, null /* no url */);
}
// Call the CalendarSyncAdapter's onAccountsChanged
getTempProviderSyncAdapter().onAccountsChanged(accountsArray);
}
};
AccountManager.get(getContext()).getAccountsByTypeAndFeatures(
GoogleLoginServiceConstants.ACCOUNT_TYPE, features, callback, null);
}
@Override
public Cursor queryInternal(Uri url, String[] projectionIn,
String selection, String[] selectionArgs, String sort) {
final SQLiteDatabase db = getDatabase();
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
Cursor ret;
// Generate the body of the query
int match = sURLMatcher.match(url);
switch (match)
{
case EVENTS:
qb.setTables("Events, Calendars");
qb.setProjectionMap(sEventsProjectionMap);
qb.appendWhere("Events.calendar_id=Calendars._id");
break;
case EVENTS_ID:
qb.setTables("Events, Calendars");
qb.setProjectionMap(sEventsProjectionMap);
qb.appendWhere("Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=");
qb.appendWhere(url.getPathSegments().get(1));
break;
case DELETED_EVENTS:
if (isTemporary()) {
qb.setTables("DeletedEvents");
break;
} else {
throw new IllegalArgumentException("Unknown URL " + url);
}
case CALENDARS:
qb.setTables("Calendars");
break;
case CALENDARS_ID:
qb.setTables("Calendars");
qb.appendWhere("_id=");
qb.appendWhere(url.getPathSegments().get(1));
break;
case INSTANCES:
case INSTANCES_BY_DAY:
long begin;
long end;
try {
begin = Long.valueOf(url.getPathSegments().get(2));
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Cannot parse begin "
+ url.getPathSegments().get(2));
}
try {
end = Long.valueOf(url.getPathSegments().get(3));
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Cannot parse end "
+ url.getPathSegments().get(3));
}
return handleInstanceQuery(qb, begin, end, projectionIn,
selection, sort, match == INSTANCES_BY_DAY);
case BUSYBITS:
int startDay;
int endDay;
try {
startDay = Integer.valueOf(url.getPathSegments().get(2));
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Cannot parse start day "
+ url.getPathSegments().get(2));
}
try {
endDay = Integer.valueOf(url.getPathSegments().get(3));
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Cannot parse end day "
+ url.getPathSegments().get(3));
}
return handleBusyBitsQuery(qb, startDay, endDay, projectionIn,
selection, sort);
case ATTENDEES:
qb.setTables("Attendees, Events, Calendars");
qb.setProjectionMap(sAttendeesProjectionMap);
qb.appendWhere("Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=Attendees.event_id");
break;
case ATTENDEES_ID:
qb.setTables("Attendees, Events, Calendars");
qb.setProjectionMap(sAttendeesProjectionMap);
qb.appendWhere("Attendees._id=");
qb.appendWhere(url.getPathSegments().get(1));
qb.appendWhere(" AND Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=Attendees.event_id");
break;
case REMINDERS:
qb.setTables("Reminders");
break;
case REMINDERS_ID:
qb.setTables("Reminders, Events, Calendars");
qb.setProjectionMap(sRemindersProjectionMap);
qb.appendWhere("Reminders._id=");
qb.appendWhere(url.getLastPathSegment());
qb.appendWhere(" AND Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=Reminders.event_id");
break;
case CALENDAR_ALERTS:
qb.setTables("CalendarAlerts, Events, Calendars");
qb.setProjectionMap(sCalendarAlertsProjectionMap);
qb.appendWhere("Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=CalendarAlerts.event_id");
break;
case CALENDAR_ALERTS_BY_INSTANCE:
qb.setTables("CalendarAlerts, Events, Calendars");
qb.setProjectionMap(sCalendarAlertsProjectionMap);
qb.appendWhere("Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=CalendarAlerts.event_id");
String groupBy = CalendarAlerts.EVENT_ID + "," + CalendarAlerts.BEGIN;
return qb.query(db, projectionIn, selection, selectionArgs,
groupBy, null, sort);
case CALENDAR_ALERTS_ID:
qb.setTables("CalendarAlerts, Events, Calendars");
qb.setProjectionMap(sCalendarAlertsProjectionMap);
qb.appendWhere("CalendarAlerts._id=");
qb.appendWhere(url.getLastPathSegment());
qb.appendWhere(" AND Events.calendar_id=Calendars._id");
qb.appendWhere(" AND Events._id=CalendarAlerts.event_id");
break;
case EXTENDED_PROPERTIES:
qb.setTables("ExtendedProperties");
break;
case EXTENDED_PROPERTIES_ID:
qb.setTables("ExtendedProperties, Events, Calendars");
// not sure if we need a projection map or a join. see what callers want.
// qb.setProjectionMap(sExtendedPropertiesProjectionMap);
qb.appendWhere("ExtendedProperties._id=");
qb.appendWhere(url.getPathSegments().get(1));
// qb.appendWhere(" AND Events.calendar_id = Calendars._id");
// qb.appendWhere(" AND Events._id=ExtendedProperties.event_id");
break;
default:
throw new IllegalArgumentException("Unknown URL " + url);
}
// run the query
ret = qb.query(db, projectionIn, selection, selectionArgs, null, null, sort);
return ret;
}
/*
* 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 projectionIn 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
* @return
*/
private Cursor handleInstanceQuery(SQLiteQueryBuilder qb, long rangeBegin,
long rangeEnd, String[] projectionIn,
String selection, String sort, boolean searchByDay) {
final SQLiteDatabase db = getDatabase();
qb.setTables("Instances INNER JOIN Events ON (Instances.event_id=Events._id) " +
"INNER JOIN Calendars ON (Events.calendar_id = Calendars._id)");
qb.setProjectionMap(sInstancesProjectionMap);
if (searchByDay) {
// Convert the first and last Julian day range to a range that uses
// UTC milliseconds.
Time time = new Time();
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 */);
qb.appendWhere("startDay <= ");
qb.appendWhere(String.valueOf(rangeEnd));
qb.appendWhere(" AND endDay >= ");
} else {
// will lock the database.
acquireInstanceRange(rangeBegin, rangeEnd, true /* use minimum expansion window */);
qb.appendWhere("begin <= ");
qb.appendWhere(String.valueOf(rangeEnd));
qb.appendWhere(" AND end >= ");
}
qb.appendWhere(String.valueOf(rangeBegin));
return qb.query(db, projectionIn, selection, null, null, null, sort);
}
private Cursor handleBusyBitsQuery(SQLiteQueryBuilder qb, int startDay,
int endDay, String[] projectionIn,
String selection, String sort) {
final SQLiteDatabase db = getDatabase();
acquireBusyBitRange(startDay, endDay);
qb.setTables("BusyBits");
qb.setProjectionMap(sBusyBitsProjectionMap);
qb.appendWhere("day >= ");
qb.appendWhere(String.valueOf(startDay));
qb.appendWhere(" AND day <= ");
qb.appendWhere(String.valueOf(endDay));
return qb.query(db, projectionIn, selection, null, null, null, sort);
}
/**
* Ensure that the date range given has all elements in the instance
* table. Acquires the database lock and calls {@link #acquireInstanceRangeLocked}.
*
* @param begin start of range (ms)
* @param end end of range (ms)
* @param useMinimumExpansionWindow expand by at least MINIMUM_EXPANSION_SPAN
*/
private void acquireInstanceRange(final long begin,
final long end,
final boolean useMinimumExpansionWindow) {
mDb.beginTransaction();
try {
acquireInstanceRangeLocked(begin, end, useMinimumExpansionWindow);
mDb.setTransactionSuccessful();
} finally {
mDb.endTransaction();
}
}
/**
* Expands the Instances table (if needed) and the BusyBits table.
* Acquires the database lock and calls {@link #acquireBusyBitRangeLocked}.
*/
private void acquireBusyBitRange(final int startDay, final int endDay) {
mDb.beginTransaction();
try {
acquireBusyBitRangeLocked(startDay, endDay);
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
*/
private void acquireInstanceRangeLocked(long begin, long end,
boolean useMinimumExpansionWindow) {
long expandBegin = begin;
long expandEnd = end;
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();
String dbTimezone = fields.timezone;
long maxInstance = fields.maxInstance;
long minInstance = fields.minInstance;
String localTimezone = TimeZone.getDefault().getID();
boolean timezoneChanged = (dbTimezone == null) || !dbTimezone.equals(localTimezone);
if (maxInstance == 0 || timezoneChanged) {
// Empty the Instances table and expand from scratch.
mDb.execSQL("DELETE FROM Instances;");
mDb.execSQL("DELETE FROM BusyBits;");
if (Config.LOGV) {
Log.v(TAG, "acquireInstanceRangeLocked() deleted Instances and Busybits,"
+ " timezone changed: " + timezoneChanged);
}
expandInstanceRangeLocked(expandBegin, expandEnd, localTimezone);
mMetaData.writeLocked(localTimezone, expandBegin, expandEnd,
0 /* startDay */, 0 /* endDay */);
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 (Config.LOGV) {
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) {
expandInstanceRangeLocked(expandBegin, minInstance, localTimezone);
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) {
expandInstanceRangeLocked(maxInstance, expandEnd, localTimezone);
maxInstance = expandEnd;
}
// Update the bounds on the Instances table.
mMetaData.writeLocked(localTimezone, minInstance, maxInstance,
fields.minBusyBit, fields.maxBusyBit);
}
private void acquireBusyBitRangeLocked(int firstDay, int lastDay) {
if (firstDay > lastDay) {
throw new IllegalArgumentException("firstDay must not be greater than lastDay");
}
String localTimezone = TimeZone.getDefault().getID();
MetaData.Fields fields = mMetaData.getFieldsLocked();
String dbTimezone = fields.timezone;
int minBusyBit = fields.minBusyBit;
int maxBusyBit = fields.maxBusyBit;
boolean timezoneChanged = (dbTimezone == null) || !dbTimezone.equals(localTimezone);
if (firstDay >= minBusyBit && lastDay <= maxBusyBit && !timezoneChanged) {
if (Config.LOGV) {
Log.v(TAG, "acquireBusyBitRangeLocked() no expansion needed");
}
return;
}
// Avoid gaps in the BusyBit table and avoid recomputing the busy bits
// that are already in the table. If the busy bit range has been cleared,
// don't bother checking.
if (maxBusyBit != 0) {
if (firstDay > maxBusyBit) {
firstDay = maxBusyBit;
} else if (lastDay < minBusyBit) {
lastDay = minBusyBit;
} else if (firstDay < minBusyBit && lastDay <= maxBusyBit) {
lastDay = minBusyBit;
} else if (lastDay > maxBusyBit && firstDay >= minBusyBit) {
firstDay = maxBusyBit;
}
}
// Allocate space for the busy bits, one 32-bit integer for each day.
int numDays = lastDay - firstDay + 1;
int[] busybits = new int[numDays];
int[] allDayCounts = new int[numDays];
// Convert the first and last Julian day range to a range that uses
// UTC milliseconds.
Time time = new Time();
long begin = time.setJulianDay(firstDay);
// 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 end = time.setJulianDay(lastDay + 1);
// Make sure the Instances table includes events in the range
// [begin, end].
acquireInstanceRange(begin, end, true /* use minimum expansion window */);
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables("Instances INNER JOIN Events ON (Instances.event_id=Events._id) " +
"INNER JOIN Calendars ON (Events.calendar_id = Calendars._id)");
qb.setProjectionMap(sInstancesProjectionMap);
qb.appendWhere("begin <= ");
qb.appendWhere(String.valueOf(end));
qb.appendWhere(" AND end >= ");
qb.appendWhere(String.valueOf(begin));
qb.appendWhere(" AND ");
qb.appendWhere(Instances.SELECTED);
qb.appendWhere("=1");
final SQLiteDatabase db = getDatabase();
// Get all the instances that overlap the range [begin,end]
Cursor cursor = qb.query(db, sInstancesProjection, null, null, null, null, null);
int count = 0;
try {
count = cursor.getCount();
while (cursor.moveToNext()) {
int startDay = cursor.getInt(INSTANCES_INDEX_START_DAY);
int endDay = cursor.getInt(INSTANCES_INDEX_END_DAY);
int startMinute = cursor.getInt(INSTANCES_INDEX_START_MINUTE);
int endMinute = cursor.getInt(INSTANCES_INDEX_END_MINUTE);
boolean allDay = cursor.getInt(INSTANCES_INDEX_ALL_DAY) != 0;
fillBusyBits(firstDay, startDay, endDay, startMinute, endMinute,
allDay, busybits, allDayCounts);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
if (count == 0) {
return;
}
// Read the busybit range again because that may have changed when we
// called acquireInstanceRange().
fields = mMetaData.getFieldsLocked();
minBusyBit = fields.minBusyBit;
maxBusyBit = fields.maxBusyBit;
// If the busybit range was cleared, then delete all the entries.
if (maxBusyBit == 0) {
mDb.execSQL("DELETE FROM BusyBits;");
}
// Merge the busy bits with the database.
mergeBusyBits(firstDay, lastDay, busybits, allDayCounts);
if (maxBusyBit == 0) {
minBusyBit = firstDay;
maxBusyBit = lastDay;
} else {
if (firstDay < minBusyBit) {
minBusyBit = firstDay;
}
if (lastDay > maxBusyBit) {
maxBusyBit = lastDay;
}
}
// Update the busy bit range
mMetaData.writeLocked(fields.timezone, fields.minInstance, fields.maxInstance,
minBusyBit, maxBusyBit);
}
private static final String[] EXPAND_COLUMNS = new String[] {
Events._ID,
Events._SYNC_ID,
Events.STATUS,
Events.DTSTART,
Events.DTEND,
Events.EVENT_TIMEZONE,
Events.RRULE,
Events.RDATE,
Events.EXRULE,
Events.EXDATE,
Events.DURATION,
Events.ALL_DAY,
Events.ORIGINAL_EVENT,
Events.ORIGINAL_INSTANCE_TIME
};
/**
* Make instances for the given range.
*/
private void expandInstanceRangeLocked(long begin, long end, String localTimezone) {
if (PROFILE) {
Debug.startMethodTracing("expandInstanceRangeLocked");
}
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Expanding events between " + begin + " and " + end);
}
Cursor entries = getEntries(begin, end);
try {
performInstanceExpansion(begin, end, localTimezone, entries);
} finally {
if (entries != null) {
entries.close();
}
}
if (PROFILE) {
Debug.stopMethodTracing();
}
}
/**
* Get all entries affecting the given window.
* @param begin Window start (ms).
* @param end Window end (ms).
* @return Cursor for the entries; caller must close it.
*/
private Cursor getEntries(long begin, long end) {
final SQLiteDatabase db = getDatabase();
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables("Events INNER JOIN Calendars ON (calendar_id = Calendars._id)");
qb.setProjectionMap(sEventsProjectionMap);
String beginString = String.valueOf(begin);
String endString = String.valueOf(end);
qb.appendWhere("(dtstart <= ");
qb.appendWhere(endString);
qb.appendWhere(" AND ");
qb.appendWhere("(lastDate IS NULL OR lastDate >= ");
qb.appendWhere(beginString);
qb.appendWhere(")) OR (");
// grab recurrence exceptions that fall outside our expansion window but modify
// recurrences that do fall within our window. we won't insert these into the output
// set of instances, but instead will just add them to our cancellations list, so we
// can cancel the correct recurrence expansion instances.
qb.appendWhere("originalInstanceTime IS NOT NULL ");
qb.appendWhere("AND originalInstanceTime <= ");
qb.appendWhere(endString);
qb.appendWhere(" AND ");
// we don't have originalInstanceDuration or end time. for now, assume the original
// instance lasts no longer than 1 week.
// TODO: compute the originalInstanceEndTime or get this from the server.
qb.appendWhere("originalInstanceTime >= ");
qb.appendWhere(String.valueOf(begin - MAX_ASSUMED_DURATION));
qb.appendWhere(")");
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Retrieving events to expand: " + qb.toString());
}
return qb.query(db, EXPAND_COLUMNS, null, null, null, null, null);
}
/**
* Perform instance expansion on the given entries.
* @param begin Window start (ms).
* @param end Window end (ms).
* @param localTimezone
* @param entries The entries to process.
*/
private void performInstanceExpansion(long begin, long end, String localTimezone, Cursor entries) {
RecurrenceProcessor rp = new RecurrenceProcessor();
int statusColumn = entries.getColumnIndex(Events.STATUS);
int dtstartColumn = entries.getColumnIndex(Events.DTSTART);
int dtendColumn = entries.getColumnIndex(Events.DTEND);
int eventTimezoneColumn = entries.getColumnIndex(Events.EVENT_TIMEZONE);
int durationColumn = entries.getColumnIndex(Events.DURATION);
int rruleColumn = entries.getColumnIndex(Events.RRULE);
int rdateColumn = entries.getColumnIndex(Events.RDATE);
int exruleColumn = entries.getColumnIndex(Events.EXRULE);
int exdateColumn = entries.getColumnIndex(Events.EXDATE);
int allDayColumn = entries.getColumnIndex(Events.ALL_DAY);
int idColumn = entries.getColumnIndex(Events._ID);
int syncIdColumn = entries.getColumnIndex(Events._SYNC_ID);
int originalEventColumn = entries.getColumnIndex(Events.ORIGINAL_EVENT);
int originalInstanceTimeColumn = entries.getColumnIndex(Events.ORIGINAL_INSTANCE_TIME);
ContentValues initialValues;
EventInstancesMap instancesMap = new EventInstancesMap();
Duration duration = new Duration();
Time eventTime = new Time();
// Invariant: entries contains all events that affect the current
// window. It consists of:
// a) Individual events that fall in the window. These will be
// displayed.
// b) Recurrences that included the window. These will be displayed
// if not canceled.
// c) Recurrence exceptions that fall in the window. These will be
// displayed if not cancellations.
// d) Recurrence exceptions that modify an instance inside the
// window (subject to 1 week assumption above), but are outside
// the window. These will not be displayed. Cases c and d are
// distingushed by the start / end time.
while (entries.moveToNext()) {
try {
initialValues = null;
boolean allDay = entries.getInt(allDayColumn) != 0;
String eventTimezone = entries.getString(eventTimezoneColumn);
if (allDay || TextUtils.isEmpty(eventTimezone)) {
// in the events table, allDay events start at midnight.
// this forces them to stay at midnight for all day events
// TODO: check that this actually does the right thing.
eventTimezone = Time.TIMEZONE_UTC;
}
long dtstartMillis = entries.getLong(dtstartColumn);
Long eventId = Long.valueOf(entries.getLong(idColumn));
String durationStr = entries.getString(durationColumn);
if (durationStr != null) {
try {
duration.parse(durationStr);
}
catch (DateException e) {
Log.w(TAG, "error parsing duration for event "
+ eventId + "'" + durationStr + "'", e);
duration.sign = 1;
duration.weeks = 0;
duration.days = 0;
duration.hours = 0;
duration.minutes = 0;
duration.seconds = 0;
durationStr = "+P0S";
}
}
String syncId = entries.getString(syncIdColumn);
String originalEvent = entries.getString(originalEventColumn);
long originalInstanceTimeMillis = -1;
if (!entries.isNull(originalInstanceTimeColumn)) {
originalInstanceTimeMillis= entries.getLong(originalInstanceTimeColumn);
}
int status = entries.getInt(statusColumn);
String rruleStr = entries.getString(rruleColumn);
String rdateStr = entries.getString(rdateColumn);
String exruleStr = entries.getString(exruleColumn);
String exdateStr = entries.getString(exdateColumn);
RecurrenceSet recur = new RecurrenceSet(rruleStr, rdateStr, exruleStr, exdateStr);
if (recur.hasRecurrence()) {
// the event is repeating
if (status == Events.STATUS_CANCELED) {
// should not happen!
Log.e(TAG, "Found canceled recurring event in "
+ "Events table. Ignoring.");
continue;
}
// need to parse the event into a local calendar.
eventTime.timezone = eventTimezone;
eventTime.set(dtstartMillis);
eventTime.allDay = allDay;
if (durationStr == null) {
// should not happen.
Log.e(TAG, "Repeating event has no duration -- "
+ "should not happen.");
if (allDay) {
// set to one day.
duration.sign = 1;
duration.weeks = 0;
duration.days = 1;
duration.hours = 0;
duration.minutes = 0;
duration.seconds = 0;
durationStr = "+P1D";
} else {
// compute the duration from dtend, if we can.
// otherwise, use 0s.
duration.sign = 1;
duration.weeks = 0;
duration.days = 0;
duration.hours = 0;
duration.minutes = 0;
if (!entries.isNull(dtendColumn)) {
long dtendMillis = entries.getLong(dtendColumn);
duration.seconds = (int) ((dtendMillis - dtstartMillis) / 1000);
durationStr = "+P" + duration.seconds + "S";
} else {
duration.seconds = 0;
durationStr = "+P0S";
}
}
}
long[] dates;
dates = rp.expand(eventTime, recur, begin, end);
// Initialize the "eventTime" timezone outside the loop.
// This is used in computeTimezoneDependentFields().
if (allDay) {
eventTime.timezone = Time.TIMEZONE_UTC;
} else {
eventTime.timezone = localTimezone;
}
long durationMillis = duration.getMillis();
for (long date : dates) {
initialValues = new ContentValues();
initialValues.put(Instances.EVENT_ID, eventId);
initialValues.put(Instances.BEGIN, date);
long dtendMillis = date + durationMillis;
initialValues.put(Instances.END, dtendMillis);
computeTimezoneDependentFields(date, dtendMillis,
eventTime, initialValues);
instancesMap.add(syncId, initialValues);
}
} else {
// the event is not repeating
initialValues = new ContentValues();
// if this event has an "original" field, then record
// that we need to cancel the original event (we can't
// do that here because the order of this loop isn't
// defined)
if (originalEvent != null && originalInstanceTimeMillis != -1) {
initialValues.put(Events.ORIGINAL_EVENT, originalEvent);
initialValues.put(Events.ORIGINAL_INSTANCE_TIME,
originalInstanceTimeMillis);
initialValues.put(Events.STATUS, status);
}
long dtendMillis = dtstartMillis;
if (durationStr == null) {
if (!entries.isNull(dtendColumn)) {
dtendMillis = entries.getLong(dtendColumn);
}
} else {
dtendMillis = duration.addTo(dtstartMillis);
}
// this non-recurring event might be a recurrence exception that doesn't
// actually fall within our expansion window, but instead was selected
// so we can correctly cancel expanded recurrence instances below. do not
// add events to the instances map if they don't actually fall within our
// expansion window.
if ((dtendMillis < begin) || (dtstartMillis > end)) {
if (originalEvent != null && originalInstanceTimeMillis != -1) {
initialValues.put(Events.STATUS, Events.STATUS_CANCELED);
} else {
Log.w(TAG, "Unexpected event outside window: " + syncId);
continue;
}
}
initialValues.put(Instances.EVENT_ID, eventId);
initialValues.put(Instances.BEGIN, dtstartMillis);
initialValues.put(Instances.END, dtendMillis);
if (allDay) {
eventTime.timezone = Time.TIMEZONE_UTC;
} else {
eventTime.timezone = localTimezone;
}
computeTimezoneDependentFields(dtstartMillis, dtendMillis,
eventTime, initialValues);
instancesMap.add(syncId, initialValues);
}
} catch (DateException e) {
Log.w(TAG, "RecurrenceProcessor error ", e);
} catch (TimeFormatException e) {
Log.w(TAG, "RecurrenceProcessor error ", e);
}
}
// Invariant: instancesMap contains all instances that affect the
// window, indexed by original sync id. It consists of:
// a) Individual events that fall in the window. They have:
// EVENT_ID, BEGIN, END
// b) Instances of recurrences that fall in the window. They may
// be subject to exceptions. They have:
// EVENT_ID, BEGIN, END
// c) Exceptions that fall in the window. They have:
// ORIGINAL_EVENT, ORIGINAL_INSTANCE_TIME, STATUS (since they can
// be a modification or cancellation), EVENT_ID, BEGIN, END
// d) Recurrence exceptions that modify an instance inside the
// window but fall outside the window. They have:
// ORIGINAL_EVENT, ORIGINAL_INSTANCE_TIME, STATUS =
// STATUS_CANCELED, EVENT_ID, BEGIN, END
// First, delete the original instances corresponding to recurrence
// exceptions. We do this by iterating over the list and for each
// recurrence exception, we search the list for an instance with a
// matching "original instance time". If we find such an instance,
// we remove it from the list. If we don't find such an instance
// then we cancel the recurrence exception.
Set<String> keys = instancesMap.keySet();
for (String syncId : keys) {
InstancesList list = instancesMap.get(syncId);
for (ContentValues values : list) {
// If this instance is not a recurrence exception, then
// skip it.
if (!values.containsKey(Events.ORIGINAL_EVENT)) {
continue;
}
String originalEvent = values.getAsString(Events.ORIGINAL_EVENT);
long originalTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
InstancesList originalList = instancesMap.get(originalEvent);
if (originalList == null) {
// The original recurrence is not present, so don't try canceling it.
continue;
}
// Search the original event for a matching original
// instance time. If there is a matching one, then remove
// the original one. We do this both for exceptions that
// change the original instance as well as for exceptions
// that delete the original instance.
for (int num = originalList.size() - 1; num >= 0; num--) {
ContentValues originalValues = originalList.get(num);
long beginTime = originalValues.getAsLong(Instances.BEGIN);
if (beginTime == originalTime) {
// We found the original instance, so remove it.
originalList.remove(num);
}
}
}
}
// Invariant: instancesMap contains filtered instances.
// It consists of:
// a) Individual events that fall in the window.
// b) Instances of recurrences that fall in the window and have not
// been subject to exceptions.
// c) Exceptions that fall in the window. They will have
// STATUS_CANCELED if they are cancellations.
// d) Recurrence exceptions that modify an instance inside the
// window but fall outside the window. These are STATUS_CANCELED.
// Now do the inserts. Since the db lock is held when this method is executed,
// this will be done in a transaction.
// NOTE: if there is lock contention (e.g., a sync is trying to merge into the db
// while the calendar app is trying to query the db (expanding instances)), we will
// not be "polite" and yield the lock until we're done. This will favor local query
// operations over sync/write operations.
for (String syncId : keys) {
InstancesList list = instancesMap.get(syncId);
for (ContentValues values : list) {
// If this instance was cancelled then don't create a new
// instance.
Integer status = values.getAsInteger(Events.STATUS);
if (status != null && status == Events.STATUS_CANCELED) {
continue;
}
// Remove these fields before inserting a new instance
values.remove(Events.ORIGINAL_EVENT);
values.remove(Events.ORIGINAL_INSTANCE_TIME);
values.remove(Events.STATUS);
mInstancesInserter.replace(values);
}
}
}
/**
* Computes the timezone-dependent fields of an instance of an event and
* updates the "values" map to contain those fields.
*
* @param begin the start time of the instance (in UTC milliseconds)
* @param end the end time of the instance (in UTC milliseconds)
* @param local a Time object with the timezone set to the local timezone
* @param values a map that will contain the timezone-dependent fields
*/
private void computeTimezoneDependentFields(long begin, long end,
Time local, ContentValues values) {
local.set(begin);
int startDay = Time.getJulianDay(begin, local.gmtoff);
int startMinute = local.hour * 60 + local.minute;
local.set(end);
int endDay = Time.getJulianDay(end, local.gmtoff);
int endMinute = local.hour * 60 + local.minute;
// Special case for midnight, which has endMinute == 0. Change
// that to +24 hours on the previous day to make everything simpler.
// Exception: if start and end minute are both 0 on the same day,
// then leave endMinute alone.
if (endMinute == 0 && endDay > startDay) {
endMinute = 24 * 60;
endDay -= 1;
}
values.put(Instances.START_DAY, startDay);
values.put(Instances.END_DAY, endDay);
values.put(Instances.START_MINUTE, startMinute);
values.put(Instances.END_MINUTE, endMinute);
}
private void fillBusyBits(int minDay, int startDay, int endDay, int startMinute,
int endMinute, boolean allDay, int[] busybits, int[] allDayCounts) {
// The startDay can be less than the minDay if we have an event
// that starts earlier than the time range we are interested in.
// In that case, we ignore the time range that falls outside the
// the range we are interested in.
if (startDay < minDay) {
startDay = minDay;
startMinute = 0;
}
// Likewise, truncate the event's end day so that it doesn't go past
// the expected range.
int numDays = busybits.length;
int stopDay = endDay;
if (stopDay > minDay + numDays - 1) {
stopDay = minDay + numDays - 1;
}
int dayIndex = startDay - minDay;
if (allDay) {
for (int day = startDay; day <= stopDay; day++, dayIndex++) {
allDayCounts[dayIndex] += 1;
}
return;
}
for (int day = startDay; day <= stopDay; day++, dayIndex++) {
int endTime = endMinute;
// If the event ends on a future day, then show it extending to
// the end of this day.
if (endDay > day) {
endTime = 24 * 60;
}
int startBit = startMinute / BUSYBIT_INTERVAL ;
int endBit = (endTime + BUSYBIT_INTERVAL - 1) / BUSYBIT_INTERVAL;
int len = endBit - startBit;
if (len == 0) {
len = 1;
}
if (len < 0 || len > 24) {
Log.e("Cal", "fillBusyBits() error: len " + len
+ " startMinute,endTime " + startMinute + " , " + endTime
+ " startDay,endDay " + startDay + " , " + endDay);
} else {
int oneBits = BIT_MASKS[len];
busybits[dayIndex] |= oneBits << startBit;
}
// Set the start minute to the beginning of the day, in
// case this event spans multiple days.
startMinute = 0;
}
}
private void mergeBusyBits(int startDay, int endDay, int[] busybits, int[] allDayCounts) {
mDb.beginTransaction();
try {
mergeBusyBitsLocked(startDay, endDay, busybits, allDayCounts);
mDb.setTransactionSuccessful();
} finally {
mDb.endTransaction();
}
}
private void mergeBusyBitsLocked(int startDay, int endDay, int[] busybits,
int[] allDayCounts) {
final SQLiteDatabase db = getDatabase();
Cursor cursor = null;
try {
String selection = "day>=" + startDay + " AND day<=" + endDay;
cursor = db.query("BusyBits", sBusyBitProjection, selection, null, null, null, null);
if (cursor == null) {
return;
}
while (cursor.moveToNext()) {
int day = cursor.getInt(BUSYBIT_INDEX_DAY);
int busy = cursor.getInt(BUSYBIT_INDEX_BUSYBITS);
int allDayCount = cursor.getInt(BUSYBIT_INDEX_ALL_DAY_COUNT);
int dayIndex = day - startDay;
busybits[dayIndex] |= busy;
allDayCounts[dayIndex] += allDayCount;
}
} finally {
if (cursor != null) {
cursor.close();
}
}
// Allocate a map that we can reuse
ContentValues values = new ContentValues();
// Write the busy bits to the database
int len = busybits.length;
for (int dayIndex = 0; dayIndex < len; dayIndex++) {
int busy = busybits[dayIndex];
int allDayCount = allDayCounts[dayIndex];
if (busy == 0 && allDayCount == 0) {
continue;
}
int day = startDay + dayIndex;
values.clear();
values.put(BusyBits.DAY, day);
values.put(BusyBits.BUSYBITS, busy);
values.put(BusyBits.ALL_DAY_COUNT, allDayCount);
db.replace("BusyBits", null, values);
}
}
/**
* Updates the BusyBit table when a new event is inserted into the Events
* table. This is called after the event has been entered into the Events
* table. If the event time is not within the date range of the current
* BusyBits table, then the busy bits are not updated. The BusyBits
* table is not automatically expanded to include this event.
*
* @param eventId the id of the newly created event
* @param values the ContentValues for the new event
*/
private void insertBusyBitsLocked(long eventId, ContentValues values) {
MetaData.Fields fields = mMetaData.getFieldsLocked();
if (fields.maxBusyBit == 0) {
return;
}
// If this is a recurrence event, then the expanded Instances range
// should be 0 because this is called after updateInstancesLocked().
// But for now check this condition and report an error if it occurs.
// In the future, we could even support recurring events by
// expanding them here and updating the busy bits for each instance.
if (isRecurrenceEvent(values)) {
Log.e(TAG, "insertBusyBitsLocked(): unexpected recurrence event\n");
return;
}
long dtstartMillis = values.getAsLong(Events.DTSTART);
Long dtendMillis = values.getAsLong(Events.DTEND);
if (dtendMillis == null) {
dtendMillis = dtstartMillis;
}
boolean allDay = false;
Integer allDayInteger = values.getAsInteger(Events.ALL_DAY);
if (allDayInteger != null) {
allDay = allDayInteger != 0;
}
Time time = new Time();
if (allDay) {
time.timezone = Time.TIMEZONE_UTC;
}
ContentValues busyValues = new ContentValues();
computeTimezoneDependentFields(dtstartMillis, dtendMillis, time, busyValues);
int startDay = busyValues.getAsInteger(Instances.START_DAY);
int endDay = busyValues.getAsInteger(Instances.END_DAY);
// If the event time is not in the expanded BusyBits range,
// then return.
if (startDay > fields.maxBusyBit || endDay < fields.minBusyBit) {
return;
}
// Allocate space for the busy bits, one 32-bit integer for each day,
// plus 24 bytes for the count of events that occur in each time slot.
int numDays = endDay - startDay + 1;
int[] busybits = new int[numDays];
int[] allDayCounts = new int[numDays];
int startMinute = busyValues.getAsInteger(Instances.START_MINUTE);
int endMinute = busyValues.getAsInteger(Instances.END_MINUTE);
fillBusyBits(startDay, startDay, endDay, startMinute, endMinute,
allDay, busybits, allDayCounts);
mergeBusyBits(startDay, endDay, busybits, allDayCounts);
}
/**
* Updates the busy bits for an event that is being updated. This is
* called before the event is updated in the Events table because we need
* to know the time of the event before it was changed.
*
* @param eventId the id of the event being updated
* @param values the ContentValues for the updated event
*/
private void updateBusyBitsLocked(long eventId, ContentValues values) {
MetaData.Fields fields = mMetaData.getFieldsLocked();
if (fields.maxBusyBit == 0) {
return;
}
// If this is a recurring event, then clear the BusyBits table.
if (isRecurrenceEvent(values)) {
mMetaData.writeLocked(fields.timezone, fields.minInstance, fields.maxInstance,
0 /* startDay */, 0 /* endDay */);
return;
}
// If the event fields being updated don't contain the start or end
// time, then we don't need to bother updating the BusyBits table.
Long dtstartLong = values.getAsLong(Events.DTSTART);
Long dtendLong = values.getAsLong(Events.DTEND);
if (dtstartLong == null && dtendLong == null) {
return;
}
// If the timezone has changed, then clear the busy bits table
// and return.
String dbTimezone = fields.timezone;
String localTimezone = TimeZone.getDefault().getID();
boolean timezoneChanged = (dbTimezone == null) || !dbTimezone.equals(localTimezone);
if (timezoneChanged) {
mMetaData.writeLocked(fields.timezone, fields.minInstance, fields.maxInstance,
0 /* startDay */, 0 /* endDay */);
return;
}
// Read the existing event start and end times from the Events table.
TimeRange eventRange = readEventStartEnd(eventId);
// Fill in the new start time (if missing) or the new end time (if
// missing) from the existing event start and end times.
long dtstartMillis;
if (dtstartLong != null) {
dtstartMillis = dtstartLong;
} else {
dtstartMillis = eventRange.begin;
}
long dtendMillis;
if (dtendLong != null) {
dtendMillis = dtendLong;
} else {
dtendMillis = eventRange.end;
}
// Compute the start and end Julian days for the event.
Time time = new Time();
if (eventRange.allDay) {
time.timezone = Time.TIMEZONE_UTC;
}
ContentValues busyValues = new ContentValues();
computeTimezoneDependentFields(eventRange.begin, eventRange.end, time, busyValues);
int oldStartDay = busyValues.getAsInteger(Instances.START_DAY);
int oldEndDay = busyValues.getAsInteger(Instances.END_DAY);
boolean allDay = false;
Integer allDayInteger = values.getAsInteger(Events.ALL_DAY);
if (allDayInteger != null) {
allDay = allDayInteger != 0;
}
if (allDay) {
time.timezone = Time.TIMEZONE_UTC;
} else {
time.timezone = TimeZone.getDefault().getID();
}
computeTimezoneDependentFields(dtstartMillis, dtendMillis, time, busyValues);
int newStartDay = busyValues.getAsInteger(Instances.START_DAY);
int newEndDay = busyValues.getAsInteger(Instances.END_DAY);
// If both the old and new event times are outside the expanded
// BusyBits table, then return.
if ((oldStartDay > fields.maxBusyBit || oldEndDay < fields.minBusyBit)
&& (newStartDay > fields.maxBusyBit || newEndDay < fields.minBusyBit)) {
return;
}
// If the old event time is within the expanded Instances range,
// then clear the BusyBits table and return.
if (oldStartDay <= fields.maxBusyBit && oldEndDay >= fields.minBusyBit) {
// We could recompute the busy bits for the days containing the
// old event time. For now, just clear the BusyBits table.
mMetaData.writeLocked(fields.timezone, fields.minInstance, fields.maxInstance,
0 /* startDay */, 0 /* endDay */);
return;
}
// The new event time is within the expanded Instances range.
// So insert the busy bits for that day (or days).
// Allocate space for the busy bits, one 32-bit integer for each day,
// plus 24 bytes for the count of events that occur in each time slot.
int numDays = newEndDay - newStartDay + 1;
int[] busybits = new int[numDays];
int[] allDayCounts = new int[numDays];
int startMinute = busyValues.getAsInteger(Instances.START_MINUTE);
int endMinute = busyValues.getAsInteger(Instances.END_MINUTE);
fillBusyBits(newStartDay, newStartDay, newEndDay, startMinute, endMinute,
allDay, busybits, allDayCounts);
mergeBusyBits(newStartDay, newEndDay, busybits, allDayCounts);
}
/**
* This method is called just before an event is deleted.
*
* @param eventId
*/
private void deleteBusyBitsLocked(long eventId) {
MetaData.Fields fields = mMetaData.getFieldsLocked();
if (fields.maxBusyBit == 0) {
return;
}
// TODO: if the event being deleted is not a recurring event and the
// start and end time are outside the BusyBit range, then we could
// avoid clearing the BusyBits table. For now, always clear the
// BusyBits table because deleting events is relatively rare.
mMetaData.writeLocked(fields.timezone, fields.minInstance, fields.maxInstance,
0 /* startDay */, 0 /* endDay */);
}
// Read the start and end time for an event from the Events table.
// Also read the "all-day" indicator.
private TimeRange readEventStartEnd(long eventId) {
Cursor cursor = null;
TimeRange range = new TimeRange();
try {
cursor = query(ContentUris.withAppendedId(Events.CONTENT_URI, eventId),
new String[] { Events.DTSTART, Events.DTEND, Events.ALL_DAY },
null /* selection */,
null /* selectionArgs */,
null /* sort */);
if (cursor == null || !cursor.moveToFirst()) {
Log.d(TAG, "Couldn't find " + eventId + " in Events table");
return null;
}
range.begin = cursor.getLong(0);
range.end = cursor.getLong(1);
range.allDay = cursor.getInt(2) != 0;
} finally {
if (cursor != null) {
cursor.close();
}
}
return range;
}
@Override
public String getType(Uri url) {
int match = sURLMatcher.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:
return "vnd.android.cursor.dir/event-instance";
case BUSYBITS:
return "vnd.android.cursor.dir/busybits";
default:
throw new IllegalArgumentException("Unknown URL " + url);
}
}
public static boolean isRecurrenceEvent(ContentValues values) {
return (!TextUtils.isEmpty(values.getAsString(Events.RRULE))||
!TextUtils.isEmpty(values.getAsString(Events.RDATE))||
!TextUtils.isEmpty(values.getAsString(Events.ORIGINAL_EVENT)));
}
@Override
public Uri insertInternal(Uri url, ContentValues initialValues) {
final SQLiteDatabase db = getDatabase();
long rowID;
int match = sURLMatcher.match(url);
switch (match) {
case EVENTS:
if (!isTemporary()) {
initialValues.put(Events._SYNC_DIRTY, 1);
if (!initialValues.containsKey(Events.DTSTART)) {
throw new RuntimeException("DTSTART field missing from event");
}
}
// TODO: avoid the call to updateBundleFromEvent if this is just finding local
// changes. or avoid for temp providers altogether, if we can compute this
// during a merge.
// TODO: do we really need to make a copy?
ContentValues updatedValues = updateContentValuesFromEvent(initialValues);
if (updatedValues == null) {
throw new RuntimeException("Could not insert event.");
// return null;
}
String owner = null;
if (updatedValues.containsKey(Events.CALENDAR_ID) &&
!updatedValues.containsKey(Events.ORGANIZER)) {
owner = getOwner(updatedValues.getAsLong(Events.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);
}
}
long rowId = mEventsInserter.insert(updatedValues);
Uri uri = Uri.parse("content://" + url.getAuthority() + "/events/" + rowId);
if (!isTemporary() && rowId != -1) {
updateEventRawTimesLocked(rowId, updatedValues);
updateInstancesLocked(updatedValues, rowId, true /* new event */, db);
insertBusyBitsLocked(rowId, updatedValues);
// If we inserted a new event that specified the self-attendee
// status, then we need to add an entry to the attendees table.
if (initialValues.containsKey(Events.SELF_ATTENDEE_STATUS)) {
int status = initialValues.getAsInteger(Events.SELF_ATTENDEE_STATUS);
if (owner == null) {
owner = getOwner(updatedValues.getAsLong(Events.CALENDAR_ID));
}
createAttendeeEntry(rowId, status, owner);
}
triggerAppWidgetUpdate(rowId);
}
return uri;
case CALENDARS:
if (!isTemporary()) {
Integer syncEvents = initialValues.getAsInteger(Calendars.SYNC_EVENTS);
if (syncEvents != null && syncEvents == 1) {
String accountName = initialValues.getAsString(Calendars._SYNC_ACCOUNT);
String accountType = initialValues.getAsString(
Calendars._SYNC_ACCOUNT_TYPE);
final Account account = new Account(accountName, accountType);
String calendarUrl = initialValues.getAsString(Calendars.URL);
scheduleSync(account, false /* two-way sync */, calendarUrl);
}
}
rowID = mCalendarsInserter.insert(initialValues);
return ContentUris.withAppendedId(Calendars.CONTENT_URI, rowID);
case ATTENDEES:
if (!initialValues.containsKey(Attendees.EVENT_ID)) {
throw new IllegalArgumentException("Attendees values must "
+ "contain an event_id");
}
rowID = mAttendeesInserter.insert(initialValues);
// Copy the attendee status value to the Events table.
updateEventAttendeeStatus(db, initialValues);
return ContentUris.withAppendedId(Calendar.Attendees.CONTENT_URI, rowID);
case REMINDERS:
if (!initialValues.containsKey(Reminders.EVENT_ID)) {
throw new IllegalArgumentException("Reminders values must "
+ "contain an event_id");
}
rowID = mRemindersInserter.insert(initialValues);
if (!isTemporary()) {
// Schedule another event alarm, if necessary
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "insertInternal() changing reminder");
}
scheduleNextAlarm(false /* do not remove alarms */);
}
return ContentUris.withAppendedId(Calendar.Reminders.CONTENT_URI, rowID);
case CALENDAR_ALERTS:
if (!initialValues.containsKey(CalendarAlerts.EVENT_ID)) {
throw new IllegalArgumentException("CalendarAlerts values must "
+ "contain an event_id");
}
rowID = mCalendarAlertsInserter.insert(initialValues);
return Uri.parse(CalendarAlerts.CONTENT_URI + "/" + rowID);
case EXTENDED_PROPERTIES:
if (!initialValues.containsKey(Calendar.ExtendedProperties.EVENT_ID)) {
throw new IllegalArgumentException("ExtendedProperties values must "
+ "contain an event_id");
}
rowID = mExtendedPropertiesInserter.insert(initialValues);
return ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI, rowID);
case DELETED_EVENTS:
if (isTemporary()) {
rowID = mDeletedEventsInserter.insert(initialValues);
return ContentUris.withAppendedId(Calendar.Events.DELETED_CONTENT_URI, rowID);
}
// fallthrough
case EVENTS_ID:
case REMINDERS_ID:
case CALENDAR_ALERTS_ID:
case EXTENDED_PROPERTIES_ID:
case INSTANCES:
case INSTANCES_BY_DAY:
throw new UnsupportedOperationException("Cannot insert into that URL");
default:
throw new IllegalArgumentException("Unknown URL " + url);
}
}
/**
* Gets the calendar's owner for an event.
* @param calId
* @return email of owner or null
*/
private String getOwner(long calId) {
// 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()) {
Log.d(TAG, "Couldn't find " + calId + " in Calendars table");
return null;
}
emailAddress = cursor.getString(0);
} finally {
if (cursor != null) {
cursor.close();
}
}
return emailAddress;
}
/**
* 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.
mAttendeesInserter.insert(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 eventId = attendeeValues.getAsLong(Attendees.EVENT_ID);
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()) {
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()) {
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;
}
}
int status = Attendees.ATTENDEE_STATUS_NONE;
if (attendeeValues.containsKey(Attendees.ATTENDEE_RELATIONSHIP)) {
int rel = attendeeValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP);
if (rel == Attendees.RELATIONSHIP_ORGANIZER) {
status = Attendees.ATTENDEE_STATUS_ACCEPTED;
}
}
if (attendeeValues.containsKey(Attendees.ATTENDEE_STATUS)) {
status = attendeeValues.getAsInteger(Attendees.ATTENDEE_STATUS);
}
ContentValues values = new ContentValues();
values.put(Events.SELF_ATTENDEE_STATUS, status);
db.update("Events", values, "_id="+eventId, null);
}
/**
* Updates the instances table when an event is added or updated.
* @param values The new values of the event.
* @param rowId The database row id of the event.
* @param newEvent true if the event is new.
* @param db The database
*/
private void updateInstancesLocked(ContentValues values,
long rowId,
boolean newEvent,
SQLiteDatabase db) {
// If there are no expanded Instances, then return.
MetaData.Fields fields = mMetaData.getFieldsLocked();
if (fields.maxInstance == 0) {
return;
}
Long dtstartMillis = values.getAsLong(Events.DTSTART);
if (dtstartMillis == null) {
if (newEvent) {
// must be present for a new event.
throw new RuntimeException("DTSTART missing.");
}
if (Config.LOGV) Log.v(TAG, "Missing DTSTART. "
+ "No need to update instance.");
return;
}
Long lastDateMillis = values.getAsLong(Events.LAST_DATE);
Long originalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
if (!newEvent) {
// Want to do this for regular event, recurrence, or exception.
// For recurrence or exception, more deletion may happen below if we
// do an instance expansion. This deletion will suffice if the exception
// is moved outside the window, for instance.
db.delete("Instances", "event_id=" + rowId, null /* selectionArgs */);
}
if (isRecurrenceEvent(values)) {
// The recurrence or exception needs to be (re-)expanded if:
// a) Exception or recurrence that falls inside window
boolean insideWindow = dtstartMillis <= fields.maxInstance &&
(lastDateMillis == null || lastDateMillis >= fields.minInstance);
// b) Exception that affects instance inside window
// These conditions match the query in getEntries
// See getEntries comment for explanation of subtracting 1 week.
boolean affectsWindow = originalInstanceTime != null &&
originalInstanceTime <= fields.maxInstance &&
originalInstanceTime >= fields.minInstance - MAX_ASSUMED_DURATION;
if (insideWindow || affectsWindow) {
updateRecurrenceInstancesLocked(values, rowId, db);
}
// TODO: an exception creation or update could be optimized by
// updating just the affected instances, instead of regenerating
// the recurrence.
return;
}
Long dtendMillis = values.getAsLong(Events.DTEND);
if (dtendMillis == null) {
dtendMillis = dtstartMillis;
}
// if the event is in the expanded range, insert
// into the instances table.
// TODO: deal with durations. currently, durations are only used in
// recurrences.
if (dtstartMillis <= fields.maxInstance && dtendMillis >= fields.minInstance) {
ContentValues instanceValues = new ContentValues();
instanceValues.put(Instances.EVENT_ID, rowId);
instanceValues.put(Instances.BEGIN, dtstartMillis);
instanceValues.put(Instances.END, dtendMillis);
boolean allDay = false;
Integer allDayInteger = values.getAsInteger(Events.ALL_DAY);
if (allDayInteger != null) {
allDay = allDayInteger != 0;
}
// Update the timezone-dependent fields.
Time local = new Time();
if (allDay) {
local.timezone = Time.TIMEZONE_UTC;
} else {
local.timezone = fields.timezone;
}
computeTimezoneDependentFields(dtstartMillis, dtendMillis, local, instanceValues);
mInstancesInserter.insert(instanceValues);
}
}
/**
* Determines the recurrence entries associated with a particular recurrence.
* This set is the base recurrence and any exception.
*
* Normally the entries are indicated by the sync id of the base recurrence
* (which is the originalEvent in the exceptions).
* However, a complication is that a recurrence may not yet have a sync id.
* In that case, the recurrence is specified by the rowId.
*
* @param recurrenceSyncId The sync id of the base recurrence, or null.
* @param rowId The row id of the base recurrence.
* @return the relevant entries.
*/
private Cursor getRelevantRecurrenceEntries(String recurrenceSyncId, long rowId) {
final SQLiteDatabase db = getDatabase();
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables("Events INNER JOIN Calendars ON (calendar_id = Calendars._id)");
qb.setProjectionMap(sEventsProjectionMap);
if (recurrenceSyncId == null) {
String where = "Events._id = " + rowId;
qb.appendWhere(where);
} else {
String where = "Events._sync_id = \"" + recurrenceSyncId + "\""
+ " OR Events.originalEvent = \"" + recurrenceSyncId + "\"";
qb.appendWhere(where);
}
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Retrieving events to expand: " + qb.toString());
}
return qb.query(db, EXPAND_COLUMNS, null /* selection */, null /* selectionArgs */, null /* groupBy */, null /* having */, null /* sortOrder */);
}
/**
* Do incremental Instances update of a recurrence or recurrence exception.
*
* This method does performInstanceExpansion on just the modified recurrence,
* to avoid the overhead of recomputing the entire instance table.
*
* @param values The new values of the event.
* @param rowId The database row id of the event.
* @param db The database
*/
private void updateRecurrenceInstancesLocked(ContentValues values,
long rowId,
SQLiteDatabase db) {
MetaData.Fields fields = mMetaData.getFieldsLocked();
String originalEvent = values.getAsString(Events.ORIGINAL_EVENT);
String recurrenceSyncId = null;
if (originalEvent != null) {
recurrenceSyncId = originalEvent;
} else {
// Get the recurrence's sync id from the database
recurrenceSyncId = DatabaseUtils.stringForQuery(db, "SELECT _sync_id FROM Events"
+ " WHERE _id = " + rowId, null /* selection args */);
}
// recurrenceSyncId is the _sync_id of the underlying recurrence
// If the recurrence hasn't gone to the server, it will be null.
// Need to clear out old instances
if (recurrenceSyncId == null) {
// Creating updating a recurrence that hasn't gone to the server.
// Need to delete based on row id
String where = "_id IN (SELECT Instances._id as _id"
+ " FROM Instances INNER JOIN Events"
+ " ON (Events._id = Instances.event_id)"
+ " WHERE Events._id =?)";
db.delete("Instances", where, new String[]{"" + rowId});
} else {
// Creating or modifying a recurrence or exception.
// Delete instances for recurrence (_sync_id = recurrenceSyncId)
// and all exceptions (originalEvent = recurrenceSyncId)
String where = "_id IN (SELECT Instances._id as _id"
+ " FROM Instances INNER JOIN Events"
+ " ON (Events._id = Instances.event_id)"
+ " WHERE Events._sync_id =?"
+ " OR Events.originalEvent =?)";
db.delete("Instances", where, new String[]{recurrenceSyncId, recurrenceSyncId});
}
// Now do instance expansion
Cursor entries = getRelevantRecurrenceEntries(recurrenceSyncId, rowId);
try {
performInstanceExpansion(fields.minInstance, fields.maxInstance, fields.timezone, entries);
} finally {
if (entries != null) {
entries.close();
}
}
// Clear busy bits
mMetaData.writeLocked(fields.timezone, fields.minInstance, fields.maxInstance,
0 /* startDay */, 0 /* endDay */);
}
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 = new RecurrenceSet(values);
if (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) {
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;
}
private ContentValues updateContentValuesFromEvent(ContentValues initialValues) {
try {
ContentValues values = new ContentValues(initialValues);
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
Log.w(TAG, "Could not calculate last date.", e);
return null;
}
}
private void updateEventRawTimesLocked(long eventId, ContentValues values) {
ContentValues rawValues = new ContentValues();
rawValues.put("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("dtstart2445", time.format2445());
}
Long dtendMillis = values.getAsLong(Events.DTEND);
if (dtendMillis != null) {
time.set(dtendMillis);
rawValues.put("dtend2445", 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("originalInstanceTime2445", time.format2445());
}
Long lastDateMillis = values.getAsLong(Events.LAST_DATE);
if (lastDateMillis != null) {
time.allDay = allDay;
time.set(lastDateMillis);
rawValues.put("lastDate2445", time.format2445());
}
mEventsRawTimesInserter.replace(rawValues);
}
@Override
public int deleteInternal(Uri url, String where, String[] whereArgs) {
final SQLiteDatabase db = getDatabase();
int match = sURLMatcher.match(url);
switch (match)
{
case EVENTS_ID:
{
String id = url.getLastPathSegment();
if (where != null) {
throw new UnsupportedOperationException("CalendarProvider "
+ "doesn't support where based deletion for type "
+ match);
}
if (!isTemporary()) {
deleteBusyBitsLocked(Integer.parseInt(id));
// Query this event to get the fields needed for inserting
// a new row in the DeletedEvents table.
Cursor cursor = db.query("Events", EVENTS_PROJECTION,
"_id=" + id, null, null, null, null);
try {
if (cursor.moveToNext()) {
String syncId = cursor.getString(EVENTS_SYNC_ID_INDEX);
if (!TextUtils.isEmpty(syncId)) {
String syncVersion = cursor.getString(EVENTS_SYNC_VERSION_INDEX);
String syncAccountName =
cursor.getString(EVENTS_SYNC_ACCOUNT_NAME_INDEX);
String syncAccountType =
cursor.getString(EVENTS_SYNC_ACCOUNT_TYPE_INDEX);
Long calId = cursor.getLong(EVENTS_CALENDAR_ID_INDEX);
ContentValues values = new ContentValues();
values.put(Events._SYNC_ID, syncId);
values.put(Events._SYNC_VERSION, syncVersion);
values.put(Events._SYNC_ACCOUNT, syncAccountName);
values.put(Events._SYNC_ACCOUNT_TYPE, syncAccountType);
values.put(Events.CALENDAR_ID, calId);
mDeletedEventsInserter.insert(values);
// TODO: we may also want to delete exception
// events for this event (in case this was a
// recurring event). We can do that with the
// following code:
// db.delete("Events", "originalEvent=?", new String[] {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 origEvent = cursor.getString(EVENTS_ORIGINAL_EVENT_INDEX);
if (!TextUtils.isEmpty(rrule) || !TextUtils.isEmpty(rdate)
|| !TextUtils.isEmpty(origEvent)) {
mMetaData.clearInstanceRange();
}
}
} finally {
cursor.close();
cursor = null;
}
triggerAppWidgetUpdate(-1);
}
// There is a delete trigger that will cause all instances
// matching this event id to get deleted as well. In fact, all
// of the following tables will remove entries matching this
// event id: Instances, EventsRawTimes, Attendees, Reminders,
// CalendarAlerts, and ExtendedProperties.
int result = db.delete("Events", "_id=" + id, null);
return result;
}
case ATTENDEES:
{
int result = db.delete("Attendees", where, whereArgs);
return result;
}
case ATTENDEES_ID:
{
// we currently don't support deletions to the attendees list.
// TODO: remove this restriction when we handle the full attendees
// feed. we'll need to put in some logic to check that the
// modification will be allowed by the server.
throw new IllegalArgumentException("Cannot delete attendees.");
// String id = url.getPathSegments().get(1);
// int result = db.delete("Attendees", "_id="+id, null);
// return result;
}
case REMINDERS:
{
int result = db.delete("Reminders", where, whereArgs);
return result;
}
case REMINDERS_ID:
{
String id = url.getLastPathSegment();
int result = db.delete("Reminders", "_id="+id, null);
return result;
}
case CALENDAR_ALERTS:
{
int result = db.delete("CalendarAlerts", where, whereArgs);
return result;
}
case CALENDAR_ALERTS_ID:
{
String id = url.getLastPathSegment();
int result = db.delete("CalendarAlerts", "_id="+id, null);
return result;
}
case DELETED_EVENTS:
case EVENTS:
throw new UnsupportedOperationException("Cannot delete that URL");
case CALENDARS_ID:
StringBuilder whereSb = new StringBuilder("_id=");
whereSb.append(url.getPathSegments().get(1));
if (!TextUtils.isEmpty(where)) {
whereSb.append(" AND (");
whereSb.append(where);
whereSb.append(')');
}
where = whereSb.toString();
// fall through to CALENDARS for the actual delete
case CALENDARS:
return deleteMatchingCalendars(where);
case INSTANCES:
case INSTANCES_BY_DAY:
throw new UnsupportedOperationException("Cannot delete that URL");
default:
throw new IllegalArgumentException("Unknown URL " + url);
}
}
private int deleteMatchingCalendars(String where) {
// query to find all the calendars that match, for each
// - delete calendar subscription
// - delete calendar
int numDeleted = 0;
final SQLiteDatabase db = getDatabase();
Cursor c = db.query("Calendars", sCalendarsIdProjection, where, null,
null, null, null);
if (c == null) {
return 0;
}
try {
while (c.moveToNext()) {
long id = c.getLong(CALENDARS_INDEX_ID);
if (!isTemporary()) {
modifyCalendarSubscription(id, false /* not selected */);
}
c.deleteRow();
numDeleted++;
}
} finally {
c.close();
}
return numDeleted;
}
// TODO: call calculateLastDate()!
@Override
public int updateInternal(Uri url, ContentValues values,
String where, String[] selectionArgs) {
int match = sURLMatcher.match(url);
// TODO: remove this restriction
if (!TextUtils.isEmpty(where) && match != CALENDAR_ALERTS) {
throw new IllegalArgumentException(
"WHERE based updates not supported");
}
final SQLiteDatabase db = getDatabase();
switch (match) {
case CALENDARS_ID:
{
long id = ContentUris.parseId(url);
Integer syncEvents = values.getAsInteger(Calendars.SYNC_EVENTS);
if (syncEvents != null && !isTemporary()) {
modifyCalendarSubscription(id, syncEvents == 1);
}
int result = db.update("Calendars", values, "_id="+ id, null);
if (!isTemporary()) {
// When we change the display status of a Calendar
// we need to update the busy bits.
if (values.containsKey(Calendars.SELECTED) || (syncEvents != null)) {
// Clear the BusyBits table.
mMetaData.clearBusyBitRange();
}
}
return result;
}
case EVENTS_ID:
{
long id = ContentUris.parseId(url);
if (!isTemporary()) {
values.put(Events._SYNC_DIRTY, 1);
// 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 (values.containsKey(Events.SELF_ATTENDEE_STATUS)) {
throw new IllegalArgumentException("Updating "
+ Events.SELF_ATTENDEE_STATUS
+ " in Events table is not allowed.");
}
if (values.containsKey(Events.HTML_URI)) {
throw new IllegalArgumentException("Updating "
+ Events.HTML_URI
+ " in Events table is not allowed.");
}
updateBusyBitsLocked(id, values);
}
ContentValues updatedValues = updateContentValuesFromEvent(values);
if (updatedValues == null) {
Log.w(TAG, "Could not update event.");
return 0;
}
int result = db.update("Events", updatedValues, "_id="+id, null);
if (!isTemporary()) {
if (result > 0) {
updateEventRawTimesLocked(id, updatedValues);
updateInstancesLocked(updatedValues, id, false /* not a new event */, db);
if (values.containsKey(Events.DTSTART)) {
// The start time of the event changed, so run the
// event alarm scheduler.
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "updateInternal() changing event");
}
scheduleNextAlarm(false /* do not remove alarms */);
triggerAppWidgetUpdate(id);
}
}
}
return result;
}
case ATTENDEES_ID:
{
// Copy the attendee status value to the Events table.
updateEventAttendeeStatus(db, values);
long id = ContentUris.parseId(url);
return db.update("Attendees", values, "_id="+id, null);
}
case CALENDAR_ALERTS_ID:
{
long id = ContentUris.parseId(url);
return db.update("CalendarAlerts", values, "_id="+id, null);
}
case CALENDAR_ALERTS:
{
return db.update("CalendarAlerts", values, where, null);
}
case REMINDERS_ID:
{
long id = ContentUris.parseId(url);
int result = db.update("Reminders", values, "_id="+id, null);
if (!isTemporary()) {
// Reschedule the event alarms because the
// "minutes" field may have changed.
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "updateInternal() changing reminder");
}
scheduleNextAlarm(false /* do not remove alarms */);
}
return result;
}
case EXTENDED_PROPERTIES_ID:
{
long id = ContentUris.parseId(url);
return db.update("ExtendedProperties", values, "_id="+id, null);
}
default:
throw new IllegalArgumentException("Unknown URL " + url);
}
}
/**
* Schedule a calendar sync for the account.
* @param account the account for which to schedule a sync
* @param uploadChangesOnly if set, specify that the sync should only send
* up local changes
* @param url the url feed for the calendar to sync (may be null)
*/
private void scheduleSync(Account account, boolean uploadChangesOnly, String url) {
Bundle extras = new Bundle();
extras.putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, uploadChangesOnly);
if (url != null) {
extras.putString("feed", url);
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
}
ContentResolver.requestSync(account, Calendars.CONTENT_URI.getAuthority(), extras);
}
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._SYNC_ACCOUNT, Calendars._SYNC_ACCOUNT_TYPE,
Calendars.URL, Calendars.SYNC_EVENTS},
null /* selection */,
null /* selectionArgs */,
null /* sort */);
Account account = null;
String calendarUrl = null;
boolean oldSyncEvents = false;
if (cursor != null && cursor.moveToFirst()) {
try {
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 {
cursor.close();
}
}
if (account == null || TextUtils.isEmpty(calendarUrl)) {
// should not happen?
Log.w(TAG, "Cannot update subscription because account "
+ "or calendar url empty -- should not happen.");
return;
}
if (oldSyncEvents == syncEvents) {
// nothing to do
return;
}
// If we are no longer syncing a calendar then make sure that the
// old calendar sync data is cleared. Then if we later add this
// calendar back, we will sync all the events.
if (!syncEvents) {
byte[] data = readSyncDataBytes(account);
GDataSyncData syncData = AbstractGDataSyncAdapter.newGDataSyncDataFromBytes(data);
if (syncData != null) {
syncData.feedData.remove(calendarUrl);
data = AbstractGDataSyncAdapter.newBytesFromGDataSyncData(syncData);
writeSyncDataBytes(account, data);
}
// Delete all of the events in this calendar to save space.
// This is the closest we can come to deleting a calendar.
// Clients should never actually delete a calendar. That won't
// work. We need to keep the calendar entry in the Calendars table
// in order to know not to sync the events for that calendar from
// the server.
final SQLiteDatabase db = getDatabase();
String[] args = new String[] {Long.toString(id)};
db.delete("Events", CALENDAR_ID_SELECTION, args);
// Note that we do not delete the matching entries
// in the DeletedEvents table. We will let those
// deleted events propagate to the server.
// TODO: cancel any pending/ongoing syncs for this calendar.
// TODO: there is a corner case to deal with here: namely, if
// we edit or delete an event on the phone and then remove
// (that is, stop syncing) a calendar, and if we also make a
// change on the server to that event at about the same time,
// then we will never propagate the changes from the phone to
// the server.
}
// If the calendar is not selected for syncing, then don't download
// events.
scheduleSync(account, !syncEvents, calendarUrl);
}
@Override
public void onSyncStop(SyncContext context, boolean success) {
super.onSyncStop(context, success);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onSyncStop() success: " + success);
}
scheduleNextAlarm(false /* do not remove alarms */);
triggerAppWidgetUpdate(-1);
}
@Override
protected Iterable<EventMerger> getMergers() {
return Collections.singletonList(new EventMerger());
}
/**
* Update any existing widgets with the changed events.
*
* @param changedEventId Specific event known to be changed, otherwise -1.
* If present, we use it to decide if an update is necessary.
*/
private synchronized void triggerAppWidgetUpdate(long changedEventId) {
Context context = getContext();
if (context != null) {
mAppWidgetProvider.providerUpdated(context, changedEventId);
}
}
void bootCompleted() {
// Remove alarms from the CalendarAlerts table that have been marked
// as "scheduled" but not fired yet. We do this because the
// AlarmManagerService loses all information about alarms when the
// power turns off but we store the information in a database table
// that persists across reboots. See the documentation for
// scheduleNextAlarmLocked() for more information.
scheduleNextAlarm(true /* remove alarms */);
}
/* Retrieve and cache the alarm manager */
private AlarmManager getAlarmManager() {
synchronized(mAlarmLock) {
if (mAlarmManager == null) {
Context context = getContext();
if (context == null) {
Log.e(TAG, "getAlarmManager() cannot get Context");
return null;
}
Object service = context.getSystemService(Context.ALARM_SERVICE);
mAlarmManager = (AlarmManager) service;
}
return mAlarmManager;
}
}
void scheduleNextAlarmCheck(long triggerTime) {
AlarmManager manager = getAlarmManager();
if (manager == null) {
Log.e(TAG, "scheduleNextAlarmCheck() cannot get AlarmManager");
return;
}
Context context = getContext();
Intent intent = new Intent(CalendarReceiver.SCHEDULE);
intent.setClass(context, CalendarReceiver.class);
PendingIntent pending = PendingIntent.getBroadcast(context,
0, intent, PendingIntent.FLAG_NO_CREATE);
if (pending != null) {
// Cancel any previous alarms that do the same thing.
manager.cancel(pending);
}
pending = PendingIntent.getBroadcast(context,
0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Time time = new Time();
time.set(triggerTime);
String timeStr = time.format(" %a, %b %d, %Y %I:%M%P");
Log.d(TAG, "scheduleNextAlarmCheck at: " + triggerTime + timeStr);
}
manager.set(AlarmManager.RTC_WAKEUP, triggerTime, pending);
}
/*
* This method runs the alarm scheduler in a background thread.
*/
void scheduleNextAlarm(boolean removeAlarms) {
Thread thread = new AlarmScheduler(removeAlarms);
thread.start();
}
/**
* This method runs in a background thread and schedules an alarm for
* the next calendar event, if necessary.
*/
private void runScheduleNextAlarm(boolean removeAlarms) {
// Do not schedule any alarms if this is a temporary database.
if (isTemporary()) {
return;
}
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
db.beginTransaction();
try {
if (removeAlarms) {
removeScheduledAlarmsLocked(db);
}
scheduleNextAlarmLocked(db);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* This method looks at the 24-hour window from now for any events that it
* needs to schedule. This method runs within a database transaction. It
* also runs in a background thread.
*
* The CalendarProvider keeps track of which alarms it has already scheduled
* to avoid scheduling them more than once and for debugging problems with
* alarms. It stores this knowledge in a database table called CalendarAlerts
* which persists across reboots. But the actual alarm list is in memory
* and disappears if the phone loses power. To avoid missing an alarm, we
* clear the entries in the CalendarAlerts table when we start up the
* CalendarProvider.
*
* Scheduling an alarm multiple times is not tragic -- we filter out the
* extra ones when we receive them. But we still need to keep track of the
* scheduled alarms. The main reason is that we need to prevent multiple
* notifications for the same alarm (on the receive side) in case we
* accidentally schedule the same alarm multiple times. We don't have
* visibility into the system's alarm list so we can never know for sure if
* we have already scheduled an alarm and it's better to err on scheduling
* an alarm twice rather than missing an alarm. Another reason we keep
* track of scheduled alarms in a database table is that it makes it easy to
* run an SQL query to find the next reminder that we haven't scheduled.
*
* @param db the database
*/
private void scheduleNextAlarmLocked(SQLiteDatabase db) {
AlarmManager alarmManager = getAlarmManager();
if (alarmManager == null) {
Log.e(TAG, "Failed to find the AlarmManager. Could not schedule the next alarm!");
return;
}
final long currentMillis = System.currentTimeMillis();
final long start = currentMillis - SCHEDULE_ALARM_SLACK;
final long end = start + (24 * 60 * 60 * 1000);
ContentResolver cr = getContext().getContentResolver();
if (Log.isLoggable(TAG, Log.DEBUG)) {
Time time = new Time();
time.set(start);
String startTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
Log.d(TAG, "runScheduleNextAlarm() start search: " + startTimeStr);
}
// Clear old alarms but keep alarms around for a while to prevent
// multiple alerts for the same reminder. The "clearUpToTime'
// should be further in the past than the point in time where
// we start searching for events (the "start" variable defined above).
long clearUpToTime = currentMillis - CLEAR_OLD_ALARM_THRESHOLD;
db.delete("CalendarAlerts", CalendarAlerts.ALARM_TIME + "<" + clearUpToTime, null);
long nextAlarmTime = end;
long alarmTime = CalendarAlerts.findNextAlarmTime(cr, currentMillis);
if (alarmTime != -1 && alarmTime < nextAlarmTime) {
nextAlarmTime = alarmTime;
}
// Extract events from the database sorted by alarm time. The
// alarm times are computed from Instances.begin (whose units
// are milliseconds) and Reminders.minutes (whose units are
// minutes).
//
// Also, ignore events whose end time is already in the past.
// Also, ignore events alarms that we have already scheduled.
//
// Note 1: we can add support for the case where Reminders.minutes
// equals -1 to mean use Calendars.minutes by adding a UNION for
// that case where the two halves restrict the WHERE clause on
// Reminders.minutes != -1 and Reminders.minutes = 1, respectively.
//
// Note 2: we have to name "myAlarmTime" different from the
// "alarmTime" column in CalendarAlerts because otherwise the
// query won't find multiple alarms for the same event.
String query = "SELECT begin-(minutes*60000) AS myAlarmTime,"
+ " Instances.event_id AS eventId, begin, end,"
+ " title, allDay, method, minutes"
+ " FROM Instances INNER JOIN Events"
+ " ON (Events._id = Instances.event_id)"
+ " INNER JOIN Reminders"
+ " ON (Instances.event_id = Reminders.event_id)"
+ " WHERE method=" + Reminders.METHOD_ALERT
+ " AND myAlarmTime>=" + start
+ " AND myAlarmTime<=" + nextAlarmTime
+ " AND end>=" + currentMillis
+ " AND 0=(SELECT count(*) from CalendarAlerts CA"
+ " where CA.event_id=Instances.event_id AND CA.begin=Instances.begin"
+ " AND CA.alarmTime=myAlarmTime)"
+ " ORDER BY myAlarmTime,begin,title";
acquireInstanceRangeLocked(start, end, false /* don't use minimum expansion windows */);
Cursor cursor = null;
try {
cursor = db.rawQuery(query, null);
int beginIndex = cursor.getColumnIndex(Instances.BEGIN);
int endIndex = cursor.getColumnIndex(Instances.END);
int eventIdIndex = cursor.getColumnIndex("eventId");
int alarmTimeIndex = cursor.getColumnIndex("myAlarmTime");
int minutesIndex = cursor.getColumnIndex(Reminders.MINUTES);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Time time = new Time();
time.set(nextAlarmTime);
String alarmTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
Log.d(TAG, "nextAlarmTime: " + alarmTimeStr
+ " cursor results: " + cursor.getCount()
+ " query: " + query);
}
while (cursor.moveToNext()) {
// Schedule all alarms whose alarm time is as early as any
// scheduled alarm. For example, if the earliest alarm is at
// 1pm, then we will schedule all alarms that occur at 1pm
// but no alarms that occur later than 1pm.
// Actually, we allow alarms up to a minute later to also
// be scheduled so that we don't have to check immediately
// again after an event alarm goes off.
alarmTime = cursor.getLong(alarmTimeIndex);
long eventId = cursor.getLong(eventIdIndex);
int minutes = cursor.getInt(minutesIndex);
long startTime = cursor.getLong(beginIndex);
if (Log.isLoggable(TAG, Log.DEBUG)) {
int titleIndex = cursor.getColumnIndex(Events.TITLE);
String title = cursor.getString(titleIndex);
Time time = new Time();
time.set(alarmTime);
String schedTime = time.format(" %a, %b %d, %Y %I:%M%P");
time.set(startTime);
String startTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
long endTime = cursor.getLong(endIndex);
time.set(endTime);
String endTimeStr = time.format(" - %a, %b %d, %Y %I:%M%P");
time.set(currentMillis);
String currentTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
Log.d(TAG, " looking at id: " + eventId + " " + title
+ " " + startTime
+ startTimeStr + endTimeStr + " alarm: "
+ alarmTime + schedTime
+ " currentTime: " + currentTimeStr);
}
if (alarmTime < nextAlarmTime) {
nextAlarmTime = alarmTime;
} else if (alarmTime > nextAlarmTime + android.text.format.DateUtils.MINUTE_IN_MILLIS) {
// This event alarm (and all later ones) will be scheduled
// later.
break;
}
// Avoid an SQLiteContraintException by checking if this alarm
// already exists in the table.
if (CalendarAlerts.alarmExists(cr, eventId, startTime, alarmTime)) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
int titleIndex = cursor.getColumnIndex(Events.TITLE);
String title = cursor.getString(titleIndex);
Log.d(TAG, " alarm exists for id: " + eventId + " " + title);
}
continue;
}
// Insert this alarm into the CalendarAlerts table
long endTime = cursor.getLong(endIndex);
Uri uri = CalendarAlerts.insert(cr, eventId, startTime,
endTime, alarmTime, minutes);
if (uri == null) {
Log.e(TAG, "runScheduleNextAlarm() insert into CalendarAlerts table failed");
continue;
}
Intent intent = new Intent(android.provider.Calendar.EVENT_REMINDER_ACTION);
intent.setData(uri);
// Also include the begin and end time of this event, because
// we cannot determine that from the Events database table.
intent.putExtra(android.provider.Calendar.EVENT_BEGIN_TIME, startTime);
intent.putExtra(android.provider.Calendar.EVENT_END_TIME, endTime);
if (Log.isLoggable(TAG, Log.DEBUG)) {
int titleIndex = cursor.getColumnIndex(Events.TITLE);
String title = cursor.getString(titleIndex);
Time time = new Time();
time.set(alarmTime);
String schedTime = time.format(" %a, %b %d, %Y %I:%M%P");
time.set(startTime);
String startTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
time.set(endTime);
String endTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
time.set(currentMillis);
String currentTimeStr = time.format(" %a, %b %d, %Y %I:%M%P");
Log.d(TAG, " scheduling " + title
+ startTimeStr + " - " + endTimeStr + " alarm: " + schedTime
+ " currentTime: " + currentTimeStr
+ " uri: " + uri);
}
PendingIntent sender = PendingIntent.getBroadcast(getContext(),
0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
alarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime, sender);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
// If we scheduled an event alarm, then schedule the next alarm check
// for one minute past that alarm. Otherwise, if there were no
// event alarms scheduled, then check again in 24 hours. If a new
// event is inserted before the next alarm check, then this method
// will be run again when the new event is inserted.
if (nextAlarmTime != Long.MAX_VALUE) {
scheduleNextAlarmCheck(nextAlarmTime + android.text.format.DateUtils.MINUTE_IN_MILLIS);
} else {
scheduleNextAlarmCheck(currentMillis + android.text.format.DateUtils.DAY_IN_MILLIS);
}
}
/**
* Removes the entries in the CalendarAlerts table for alarms that we have
* scheduled but that have not fired yet. We do this to ensure that we
* don't miss an alarm. The CalendarAlerts table keeps track of the
* alarms that we have scheduled but the actual alarm list is in memory
* and will be cleared if the phone reboots.
*
* We don't need to remove entries that have already fired, and in fact
* we should not remove them because we need to display the notifications
* until the user dismisses them.
*
* We could remove entries that have fired and been dismissed, but we leave
* them around for a while because it makes it easier to debug problems.
* Entries that are old enough will be cleaned up later when we schedule
* new alarms.
*/
private void removeScheduledAlarmsLocked(SQLiteDatabase db) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "removing scheduled alarms");
}
db.delete(CalendarAlerts.TABLE_NAME,
CalendarAlerts.STATE + "=" + CalendarAlerts.SCHEDULED, null /* whereArgs */);
}
private static String sEventsTable = "Events";
private static String sDeletedEventsTable = "DeletedEvents";
private static String sAttendeesTable = "Attendees";
private static String sRemindersTable = "Reminders";
private static String sCalendarAlertsTable = "CalendarAlerts";
private static String sExtendedPropertiesTable = "ExtendedProperties";
private class EventMerger extends AbstractTableMerger {
private ContentValues mValues = new ContentValues();
EventMerger() {
super(getDatabase(), sEventsTable, Calendar.Events.CONTENT_URI,
sDeletedEventsTable, Calendar.Events.DELETED_CONTENT_URI);
}
@Override
protected void notifyChanges() {
getContext().getContentResolver().notifyChange(Events.CONTENT_URI,
null /* observer */, false /* do not sync to network */);
}
@Override
protected void cursorRowToContentValues(Cursor cursor, ContentValues map) {
rowToContentValues(cursor, map);
}
@Override
public void insertRow(ContentProvider diffs, Cursor diffsCursor) {
rowToContentValues(diffsCursor, mValues);
final SQLiteDatabase db = getDatabase();
long rowId = mEventsInserter.insert(mValues);
if (rowId <= 0) {
Log.e(TAG, "Unable to insert values into calendar db: " + mValues);
return;
}
long diffsRowId = diffsCursor.getLong(
diffsCursor.getColumnIndex(Events._ID));
insertAttendees(diffs, diffsRowId, rowId, db);
insertRemindersIfNecessary(diffs, diffsRowId, rowId, db);
insertExtendedPropertiesIfNecessary(diffs, diffsRowId, rowId, db);
updateEventRawTimesLocked(rowId, mValues);
updateInstancesLocked(mValues, rowId, true /* new event */, db);
insertBusyBitsLocked(rowId, mValues);
// Update the _SYNC_DIRTY flag of the event. We have to do this
// after inserting since the update of the reminders and extended properties
// methods will fire a sql trigger that will cause this flag to
// be set.
clearSyncDirtyFlag(db, rowId);
}
private void clearSyncDirtyFlag(SQLiteDatabase db, long rowId) {
mValues.clear();
mValues.put(Events._SYNC_DIRTY, 0);
db.update(mTable, mValues, Events._ID + '=' + rowId, null);
}
private void insertAttendees(ContentProvider diffs,
long diffsRowId,
long rowId,
SQLiteDatabase db) {
// query attendees in diffs
Cursor attendeesCursor =
diffs.query(Attendees.CONTENT_URI, null,
"event_id=" + diffsRowId, null, null);
ContentValues attendeesValues = new ContentValues();
try {
while (attendeesCursor.moveToNext()) {
attendeesValues.clear();
DatabaseUtils.cursorStringToContentValues(attendeesCursor,
Attendees.ATTENDEE_NAME,
attendeesValues);
DatabaseUtils.cursorStringToContentValues(attendeesCursor,
Attendees.ATTENDEE_EMAIL,
attendeesValues);
DatabaseUtils.cursorIntToContentValues(attendeesCursor,
Attendees.ATTENDEE_STATUS,
attendeesValues);
DatabaseUtils.cursorIntToContentValues(attendeesCursor,
Attendees.ATTENDEE_TYPE,
attendeesValues);
DatabaseUtils.cursorIntToContentValues(attendeesCursor,
Attendees.ATTENDEE_RELATIONSHIP,
attendeesValues);
attendeesValues.put(Attendees.EVENT_ID, rowId);
mAttendeesInserter.insert(attendeesValues);
}
} finally {
if (attendeesCursor != null) {
attendeesCursor.close();
}
}
}
private void insertRemindersIfNecessary(ContentProvider diffs,
long diffsRowId,
long rowId,
SQLiteDatabase db) {
// insert reminders, if necessary.
Integer hasAlarm = mValues.getAsInteger(Events.HAS_ALARM);
if (hasAlarm != null && hasAlarm.intValue() == 1) {
// query reminders in diffs
Cursor reminderCursor =
diffs.query(Reminders.CONTENT_URI, null,
"event_id=" + diffsRowId, null, null);
ContentValues reminderValues = new ContentValues();
try {
while (reminderCursor.moveToNext()) {
reminderValues.clear();
DatabaseUtils.cursorIntToContentValues(reminderCursor,
Reminders.METHOD,
reminderValues);
DatabaseUtils.cursorIntToContentValues(reminderCursor,
Reminders.MINUTES,
reminderValues);
reminderValues.put(Reminders.EVENT_ID, rowId);
mRemindersInserter.insert(reminderValues);
}
} finally {
if (reminderCursor != null) {
reminderCursor.close();
}
}
}
}
private void insertExtendedPropertiesIfNecessary(ContentProvider diffs,
long diffsRowId,
long rowId,
SQLiteDatabase db) {
// insert extended properties, if necessary.
Integer hasExtendedProperties = mValues.getAsInteger(Events.HAS_EXTENDED_PROPERTIES);
if (hasExtendedProperties != null && hasExtendedProperties.intValue() != 0) {
// query reminders in diffs
Cursor extendedPropertiesCursor =
diffs.query(Calendar.ExtendedProperties.CONTENT_URI, null,
"event_id=" + diffsRowId, null, null);
ContentValues extendedPropertiesValues = new ContentValues();
try {
while (extendedPropertiesCursor.moveToNext()) {
extendedPropertiesValues.clear();
DatabaseUtils.cursorStringToContentValues(extendedPropertiesCursor,
Calendar.ExtendedProperties.NAME, extendedPropertiesValues);
DatabaseUtils.cursorStringToContentValues(extendedPropertiesCursor,
Calendar.ExtendedProperties.VALUE, extendedPropertiesValues);
extendedPropertiesValues.put(ExtendedProperties.EVENT_ID, rowId);
mExtendedPropertiesInserter.insert(extendedPropertiesValues);
}
} finally {
if (extendedPropertiesCursor != null) {
extendedPropertiesCursor.close();
}
}
}
}
@Override
public void updateRow(long localId, ContentProvider diffs,
Cursor diffsCursor) {
rowToContentValues(diffsCursor, mValues);
final SQLiteDatabase db = getDatabase();
updateBusyBitsLocked(localId, mValues);
int numRows = db.update(mTable, mValues, "_id=" + localId, null /* selectionArgs */);
if (numRows <= 0) {
Log.e(TAG, "Unable to update calendar db: " + mValues);
return;
}
long diffsRowId = diffsCursor.getLong(
diffsCursor.getColumnIndex(Events._ID));
// TODO: only update the attendees, reminders, and extended properties if they have
// changed?
// delete the existing attendees, reminders, and extended properties
db.delete(sAttendeesTable, "event_id=" + localId, null /* selectionArgs */);
db.delete(sRemindersTable, "event_id=" + localId, null /* selectionArgs */);
db.delete(sExtendedPropertiesTable, "event_id=" + localId,
null /* selectionArgs */);
// process attendees sent by the server.
insertAttendees(diffs, diffsRowId, localId, db);
// process reminders sent by the server.
insertRemindersIfNecessary(diffs, diffsRowId, localId, db);
// process extended properties sent by the server.
insertExtendedPropertiesIfNecessary(diffs, diffsRowId, localId, db);
updateEventRawTimesLocked(localId, mValues);
updateInstancesLocked(mValues, localId, false /* not a new event */, db);
// Update the _SYNC_DIRTY flag of the event. We have to do this
// after updating since the update of the reminders and extended properties
// methods will fire a sql trigger that will cause this flag to
// be set.
clearSyncDirtyFlag(db, localId);
}
@Override
public void resolveRow(long localId, String syncId,
ContentProvider diffs, Cursor diffsCursor) {
// server wins
updateRow(localId, diffs, diffsCursor);
}
@Override
public void deleteRow(Cursor localCursor) {
long localId = localCursor.getLong(localCursor.getColumnIndexOrThrow(Events._ID));
deleteBusyBitsLocked(localId);
// we have to read this row from the DB since the projection that is used
// by cursor doesn't necessarily contain the columns we need
Cursor c = getDatabase().query(sEventsTable,
new String[]{Events.RRULE, Events.RDATE, Events.ORIGINAL_EVENT},
"_id=" + localId, null, null, null, null);
try {
c.moveToNext();
// If this was a recurring event or a recurrence exception, then
// force a recalculation of the instances.
// We can get a tombstoned recurrence exception
// that doesn't have a rrule, rdate, or originalEvent, and the
// check below wouldn't catch that. However, in practice we also
// get a different event with a rrule in that case, so the
// instances get cleared by that rule.
// This should be re-evaluated when calendar supports gd:deleted.
String rrule = c.getString(c.getColumnIndexOrThrow(Events.RRULE));
String rdate = c.getString(c.getColumnIndexOrThrow(Events.RDATE));
String origEvent = c.getString(c.getColumnIndexOrThrow(Events.ORIGINAL_EVENT));
if (!TextUtils.isEmpty(rrule) || !TextUtils.isEmpty(rdate)
|| !TextUtils.isEmpty(origEvent)) {
mMetaData.clearInstanceRange();
}
} finally {
c.close();
}
super.deleteRow(localCursor);
}
private void rowToContentValues(Cursor diffsCursor, ContentValues values) {
values.clear();
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events._SYNC_ID, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events._SYNC_TIME, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events._SYNC_VERSION, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events._SYNC_DIRTY, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events._SYNC_ACCOUNT, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor,
Events._SYNC_ACCOUNT_TYPE, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.HTML_URI, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.TITLE, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.EVENT_LOCATION, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.DESCRIPTION, values);
DatabaseUtils.cursorLongToContentValues(diffsCursor, Events.STATUS, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.SELF_ATTENDEE_STATUS,
values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.COMMENTS_URI, values);
DatabaseUtils.cursorLongToContentValues(diffsCursor, Events.DTSTART, values);
DatabaseUtils.cursorLongToContentValues(diffsCursor, Events.DTEND, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.EVENT_TIMEZONE, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.DURATION, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.ALL_DAY, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.VISIBILITY, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.TRANSPARENCY, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.HAS_ALARM, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.HAS_EXTENDED_PROPERTIES,
values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.RRULE, values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.ORIGINAL_EVENT, values);
DatabaseUtils.cursorLongToContentValues(diffsCursor, Events.ORIGINAL_INSTANCE_TIME,
values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.ORIGINAL_ALL_DAY,
values);
DatabaseUtils.cursorLongToContentValues(diffsCursor, Events.LAST_DATE, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.HAS_ATTENDEE_DATA, values);
DatabaseUtils.cursorLongToContentValues(diffsCursor, Events.CALENDAR_ID, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.GUESTS_CAN_INVITE_OTHERS,
values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.GUESTS_CAN_MODIFY, values);
DatabaseUtils.cursorIntToContentValues(diffsCursor, Events.GUESTS_CAN_SEE_GUESTS,
values);
DatabaseUtils.cursorStringToContentValues(diffsCursor, Events.ORGANIZER, values);
}
}
private static final int EVENTS = 1;
private static final int EVENTS_ID = 2;
private static final int INSTANCES = 3;
private static final int DELETED_EVENTS = 4;
private static final int CALENDARS = 5;
private static final int CALENDARS_ID = 6;
private static final int ATTENDEES = 7;
private static final int ATTENDEES_ID = 8;
private static final int REMINDERS = 9;
private static final int REMINDERS_ID = 10;
private static final int EXTENDED_PROPERTIES = 11;
private static final int EXTENDED_PROPERTIES_ID = 12;
private static final int CALENDAR_ALERTS = 13;
private static final int CALENDAR_ALERTS_ID = 14;
private static final int CALENDAR_ALERTS_BY_INSTANCE = 15;
private static final int BUSYBITS = 16;
private static final int INSTANCES_BY_DAY = 17;
private static final UriMatcher sURLMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private static final HashMap<String, String> sInstancesProjectionMap;
private static final HashMap<String, String> sEventsProjectionMap;
private static final HashMap<String, String> sAttendeesProjectionMap;
private static final HashMap<String, String> sRemindersProjectionMap;
private static final HashMap<String, String> sCalendarAlertsProjectionMap;
private static final HashMap<String, String> sBusyBitsProjectionMap;
static {
sURLMatcher.addURI("calendar", "instances/when/*/*", INSTANCES);
sURLMatcher.addURI("calendar", "instances/whenbyday/*/*", INSTANCES_BY_DAY);
sURLMatcher.addURI("calendar", "events", EVENTS);
sURLMatcher.addURI("calendar", "events/#", EVENTS_ID);
sURLMatcher.addURI("calendar", "calendars", CALENDARS);
sURLMatcher.addURI("calendar", "calendars/#", CALENDARS_ID);
sURLMatcher.addURI("calendar", "deleted_events", DELETED_EVENTS);
sURLMatcher.addURI("calendar", "attendees", ATTENDEES);
sURLMatcher.addURI("calendar", "attendees/#", ATTENDEES_ID);
sURLMatcher.addURI("calendar", "reminders", REMINDERS);
sURLMatcher.addURI("calendar", "reminders/#", REMINDERS_ID);
sURLMatcher.addURI("calendar", "extendedproperties", EXTENDED_PROPERTIES);
sURLMatcher.addURI("calendar", "extendedproperties/#", EXTENDED_PROPERTIES_ID);
sURLMatcher.addURI("calendar", "calendar_alerts", CALENDAR_ALERTS);
sURLMatcher.addURI("calendar", "calendar_alerts/#", CALENDAR_ALERTS_ID);
sURLMatcher.addURI("calendar", "calendar_alerts/by_instance", CALENDAR_ALERTS_BY_INSTANCE);
sURLMatcher.addURI("calendar", "busybits/when/*/*", BUSYBITS);
sEventsProjectionMap = new HashMap<String, String>();
// Events columns
sEventsProjectionMap.put(Events.HTML_URI, "htmlUri");
sEventsProjectionMap.put(Events.TITLE, "title");
sEventsProjectionMap.put(Events.EVENT_LOCATION, "eventLocation");
sEventsProjectionMap.put(Events.DESCRIPTION, "description");
sEventsProjectionMap.put(Events.STATUS, "eventStatus");
sEventsProjectionMap.put(Events.SELF_ATTENDEE_STATUS, "selfAttendeeStatus");
sEventsProjectionMap.put(Events.COMMENTS_URI, "commentsUri");
sEventsProjectionMap.put(Events.DTSTART, "dtstart");
sEventsProjectionMap.put(Events.DTEND, "dtend");
sEventsProjectionMap.put(Events.EVENT_TIMEZONE, "eventTimezone");
sEventsProjectionMap.put(Events.DURATION, "duration");
sEventsProjectionMap.put(Events.ALL_DAY, "allDay");
sEventsProjectionMap.put(Events.VISIBILITY, "visibility");
sEventsProjectionMap.put(Events.TRANSPARENCY, "transparency");
sEventsProjectionMap.put(Events.HAS_ALARM, "hasAlarm");
sEventsProjectionMap.put(Events.HAS_EXTENDED_PROPERTIES, "hasExtendedProperties");
sEventsProjectionMap.put(Events.RRULE, "rrule");
sEventsProjectionMap.put(Events.RDATE, "rdate");
sEventsProjectionMap.put(Events.EXRULE, "exrule");
sEventsProjectionMap.put(Events.EXDATE, "exdate");
sEventsProjectionMap.put(Events.ORIGINAL_EVENT, "originalEvent");
sEventsProjectionMap.put(Events.ORIGINAL_INSTANCE_TIME, "originalInstanceTime");
sEventsProjectionMap.put(Events.ORIGINAL_ALL_DAY, "originalAllDay");
sEventsProjectionMap.put(Events.LAST_DATE, "lastDate");
sEventsProjectionMap.put(Events.HAS_ATTENDEE_DATA, "hasAttendeeData");
sEventsProjectionMap.put(Events.CALENDAR_ID, "calendar_id");
sEventsProjectionMap.put(Events.GUESTS_CAN_INVITE_OTHERS, "guestsCanInviteOthers");
sEventsProjectionMap.put(Events.GUESTS_CAN_MODIFY, "guestsCanModify");
sEventsProjectionMap.put(Events.GUESTS_CAN_SEE_GUESTS, "guestsCanSeeGuests");
sEventsProjectionMap.put(Events.ORGANIZER, "organizer");
// Calendar columns
sEventsProjectionMap.put(Events.COLOR, "color");
sEventsProjectionMap.put(Events.ACCESS_LEVEL, "access_level");
sEventsProjectionMap.put(Events.SELECTED, "selected");
sEventsProjectionMap.put(Calendars.URL, "url");
sEventsProjectionMap.put(Calendars.TIMEZONE, "timezone");
sEventsProjectionMap.put(Calendars.OWNER_ACCOUNT, "ownerAccount");
// Put the shared items into the Instances projection map
sInstancesProjectionMap = new HashMap<String, String>(sEventsProjectionMap);
sAttendeesProjectionMap = new HashMap<String, String>(sEventsProjectionMap);
sRemindersProjectionMap = new HashMap<String, String>(sEventsProjectionMap);
sCalendarAlertsProjectionMap = new HashMap<String, String>(sEventsProjectionMap);
sEventsProjectionMap.put(Events._ID, "Events._id AS _id");
sEventsProjectionMap.put(Events._SYNC_ID, "Events._sync_id AS _sync_id");
sEventsProjectionMap.put(Events._SYNC_VERSION, "Events._sync_version AS _sync_version");
sEventsProjectionMap.put(Events._SYNC_TIME, "Events._sync_time AS _sync_time");
sEventsProjectionMap.put(Events._SYNC_LOCAL_ID, "Events._sync_local_id AS _sync_local_id");
sEventsProjectionMap.put(Events._SYNC_DIRTY, "Events._sync_dirty AS _sync_dirty");
sEventsProjectionMap.put(Events._SYNC_ACCOUNT, "Events._sync_account AS _sync_account");
sEventsProjectionMap.put(Events._SYNC_ACCOUNT_TYPE,
"Events._sync_account_type AS _sync_account_type");
// Instances columns
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");
// BusyBits columns
sBusyBitsProjectionMap = new HashMap<String, String>();
sBusyBitsProjectionMap.put(BusyBits.DAY, "day");
sBusyBitsProjectionMap.put(BusyBits.BUSYBITS, "busyBits");
sBusyBitsProjectionMap.put(BusyBits.ALL_DAY_COUNT, "allDayCount");
// 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");
// 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");
// 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.STATE, "state");
sCalendarAlertsProjectionMap.put(CalendarAlerts.MINUTES, "minutes");
}
/**
* An implementation of EntityIterator that builds the Entity for a calendar event.
*/
private static class CalendarEntityIterator implements EntityIterator {
private final Cursor mEntityCursor;
private volatile boolean mIsClosed;
private final SQLiteDatabase mDb;
private static final String[] EVENTS_PROJECTION = new String[]{
Calendar.Events._ID,
Calendar.Events.HTML_URI,
Calendar.Events.TITLE,
Calendar.Events.DESCRIPTION,
Calendar.Events.EVENT_LOCATION,
Calendar.Events.STATUS,
Calendar.Events.SELF_ATTENDEE_STATUS,
Calendar.Events.COMMENTS_URI,
Calendar.Events.DTSTART,
Calendar.Events.DTEND,
Calendar.Events.DURATION,
Calendar.Events.EVENT_TIMEZONE,
Calendar.Events.ALL_DAY,
Calendar.Events.VISIBILITY,
Calendar.Events.TRANSPARENCY,
Calendar.Events.HAS_ALARM,
Calendar.Events.HAS_EXTENDED_PROPERTIES,
Calendar.Events.RRULE,
Calendar.Events.RDATE,
Calendar.Events.EXRULE,
Calendar.Events.EXDATE,
Calendar.Events.ORIGINAL_EVENT,
Calendar.Events.ORIGINAL_INSTANCE_TIME,
Calendar.Events.ORIGINAL_ALL_DAY,
Calendar.Events.LAST_DATE,
Calendar.Events.HAS_ATTENDEE_DATA,
Calendar.Events.CALENDAR_ID,
Calendar.Events.GUESTS_CAN_INVITE_OTHERS,
Calendar.Events.GUESTS_CAN_MODIFY,
Calendar.Events.GUESTS_CAN_SEE_GUESTS,
Calendar.Events.ORGANIZER,
};
private static final int COLUMN_ID = 0;
private static final int COLUMN_HTML_URI = 1;
private static final int COLUMN_TITLE = 2;
private static final int COLUMN_DESCRIPTION = 3;
private static final int COLUMN_EVENT_LOCATION = 4;
private static final int COLUMN_STATUS = 5;
private static final int COLUMN_SELF_ATTENDEE_STATUS = 6;
private static final int COLUMN_COMMENTS_URI = 7;
private static final int COLUMN_DTSTART = 8;
private static final int COLUMN_DTEND = 9;
private static final int COLUMN_DURATION = 10;
private static final int COLUMN_EVENT_TIMEZONE = 11;
private static final int COLUMN_ALL_DAY = 12;
private static final int COLUMN_VISIBILITY = 13;
private static final int COLUMN_TRANSPARENCY = 14;
private static final int COLUMN_HAS_ALARM = 15;
private static final int COLUMN_HAS_EXTENDED_PROPERTIES = 16;
private static final int COLUMN_RRULE = 17;
private static final int COLUMN_RDATE = 18;
private static final int COLUMN_EXRULE = 19;
private static final int COLUMN_EXDATE = 20;
private static final int COLUMN_ORIGINAL_EVENT = 21;
private static final int COLUMN_ORIGINAL_INSTANCE_TIME = 22;
private static final int COLUMN_ORIGINAL_ALL_DAY = 23;
private static final int COLUMN_LAST_DATE = 24;
private static final int COLUMN_HAS_ATTENDEE_DATA = 25;
private static final int COLUMN_CALENDAR_ID = 26;
private static final int COLUMN_GUESTS_CAN_INVITE_OTHERS = 27;
private static final int COLUMN_GUESTS_CAN_MODIFY = 28;
private static final int COLUMN_GUESTS_CAN_SEE_GUESTS = 29;
private static final int COLUMN_ORGANIZER = 30;
private static final String[] REMINDERS_PROJECTION = new String[] {
Calendar.Reminders.MINUTES,
Calendar.Reminders.METHOD,
};
private static final int COLUMN_MINUTES = 0;
private static final int COLUMN_METHOD = 1;
private static final String[] ATTENDEES_PROJECTION = new String[] {
Calendar.Attendees.ATTENDEE_NAME,
Calendar.Attendees.ATTENDEE_EMAIL,
Calendar.Attendees.ATTENDEE_RELATIONSHIP,
Calendar.Attendees.ATTENDEE_TYPE,
Calendar.Attendees.ATTENDEE_STATUS,
};
private static final int COLUMN_ATTENDEE_NAME = 0;
private static final int COLUMN_ATTENDEE_EMAIL = 1;
private static final int COLUMN_ATTENDEE_RELATIONSHIP = 2;
private static final int COLUMN_ATTENDEE_TYPE = 3;
private static final int COLUMN_ATTENDEE_STATUS = 4;
private static final String[] EXTENDED_PROJECTION = new String[] {
Calendar.ExtendedProperties.NAME,
Calendar.ExtendedProperties.VALUE,
};
private static final int COLUMN_NAME = 0;
private static final int COLUMN_VALUE = 1;
public CalendarEntityIterator(CalendarProvider provider, String eventIdString, Uri uri,
String selection, String[] selectionArgs, String sortOrder) {
mIsClosed = false;
mDb = provider.mOpenHelper.getReadableDatabase();
final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables(sEventsTable);
if (eventIdString != null) {
qb.appendWhere(Calendar.Events._ID + "=" + eventIdString);
}
mEntityCursor = qb.query(mDb, EVENTS_PROJECTION, selection, selectionArgs,
null, null, sortOrder);
mEntityCursor.moveToFirst();
}
public void close() {
if (mIsClosed) {
throw new IllegalStateException("closing when already closed");
}
mIsClosed = true;
mEntityCursor.close();
}
public boolean hasNext() throws RemoteException {
if (mIsClosed) {
throw new IllegalStateException("calling hasNext() when the iterator is closed");
}
return !mEntityCursor.isAfterLast();
}
public void reset() throws RemoteException {
if (mIsClosed) {
throw new IllegalStateException("calling next() when the iterator is closed");
}
mEntityCursor.moveToFirst();
}
public Entity next() throws RemoteException {
if (mIsClosed) {
throw new IllegalStateException("calling next() when the iterator is closed");
}
if (!hasNext()) {
throw new IllegalStateException("you may only call next() if hasNext() is true");
}
final SQLiteCursor c = (SQLiteCursor) mEntityCursor;
final long eventId = c.getLong(COLUMN_ID);
// we expect the cursor is already at the row we need to read from
ContentValues entityValues = new ContentValues();
entityValues.put(Calendar.Events._ID, eventId);
entityValues.put(Calendar.Events.CALENDAR_ID, c.getInt(COLUMN_CALENDAR_ID));
entityValues.put(Calendar.Events.HTML_URI, c.getString(COLUMN_HTML_URI));
entityValues.put(Calendar.Events.TITLE, c.getString(COLUMN_TITLE));
entityValues.put(Calendar.Events.DESCRIPTION, c.getString(COLUMN_DESCRIPTION));
entityValues.put(Calendar.Events.EVENT_LOCATION, c.getString(COLUMN_EVENT_LOCATION));
entityValues.put(Calendar.Events.STATUS, c.getInt(COLUMN_STATUS));
entityValues.put(Calendar.Events.SELF_ATTENDEE_STATUS,
c.getInt(COLUMN_SELF_ATTENDEE_STATUS));
entityValues.put(Calendar.Events.COMMENTS_URI, c.getString(COLUMN_COMMENTS_URI));
entityValues.put(Calendar.Events.DTSTART, c.getLong(COLUMN_DTSTART));
entityValues.put(Calendar.Events.DTEND, c.getLong(COLUMN_DTEND));
entityValues.put(Calendar.Events.DURATION, c.getString(COLUMN_DURATION));
entityValues.put(Calendar.Events.EVENT_TIMEZONE, c.getString(COLUMN_EVENT_TIMEZONE));
entityValues.put(Calendar.Events.ALL_DAY, c.getString(COLUMN_ALL_DAY));
entityValues.put(Calendar.Events.VISIBILITY, c.getInt(COLUMN_VISIBILITY));
entityValues.put(Calendar.Events.TRANSPARENCY, c.getInt(COLUMN_TRANSPARENCY));
entityValues.put(Calendar.Events.HAS_ALARM, c.getString(COLUMN_HAS_ALARM));
entityValues.put(Calendar.Events.HAS_EXTENDED_PROPERTIES,
c.getString(COLUMN_HAS_EXTENDED_PROPERTIES));
entityValues.put(Calendar.Events.RRULE, c.getString(COLUMN_RRULE));
entityValues.put(Calendar.Events.RDATE, c.getString(COLUMN_RDATE));
entityValues.put(Calendar.Events.EXRULE, c.getString(COLUMN_EXRULE));
entityValues.put(Calendar.Events.EXDATE, c.getString(COLUMN_EXDATE));
entityValues.put(Calendar.Events.ORIGINAL_EVENT, c.getString(COLUMN_ORIGINAL_EVENT));
entityValues.put(Calendar.Events.ORIGINAL_INSTANCE_TIME,
c.getLong(COLUMN_ORIGINAL_INSTANCE_TIME));
entityValues.put(Calendar.Events.ORIGINAL_ALL_DAY, c.getInt(COLUMN_ORIGINAL_ALL_DAY));
entityValues.put(Calendar.Events.LAST_DATE, c.getLong(COLUMN_LAST_DATE));
entityValues.put(Calendar.Events.HAS_ATTENDEE_DATA,
c.getInt(COLUMN_HAS_ATTENDEE_DATA));
entityValues.put(Calendar.Events.GUESTS_CAN_INVITE_OTHERS,
c.getInt(COLUMN_GUESTS_CAN_INVITE_OTHERS));
entityValues.put(Calendar.Events.GUESTS_CAN_MODIFY,
c.getInt(COLUMN_GUESTS_CAN_MODIFY));
entityValues.put(Calendar.Events.GUESTS_CAN_SEE_GUESTS,
c.getInt(COLUMN_GUESTS_CAN_SEE_GUESTS));
entityValues.put(Calendar.Events.ORGANIZER, c.getString(COLUMN_ORGANIZER));
Entity entity = new Entity(entityValues);
Cursor cursor = null;
try {
cursor = mDb.query(sRemindersTable, REMINDERS_PROJECTION, "event_id=" + eventId,
null, null, null, null);
while (cursor.moveToNext()) {
ContentValues reminderValues = new ContentValues();
reminderValues.put(Calendar.Reminders.MINUTES, cursor.getInt(COLUMN_MINUTES));
reminderValues.put(Calendar.Reminders.METHOD, cursor.getInt(COLUMN_METHOD));
entity.addSubValue(Calendar.Reminders.CONTENT_URI, reminderValues);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
cursor = null;
try {
cursor = mDb.query(sAttendeesTable, ATTENDEES_PROJECTION, "event_id=" + eventId,
null, null, null, null);
while (cursor.moveToNext()) {
ContentValues attendeeValues = new ContentValues();
attendeeValues.put(Calendar.Attendees.ATTENDEE_NAME,
cursor.getString(COLUMN_ATTENDEE_NAME));
attendeeValues.put(Calendar.Attendees.ATTENDEE_EMAIL,
cursor.getString(COLUMN_ATTENDEE_EMAIL));
attendeeValues.put(Calendar.Attendees.ATTENDEE_RELATIONSHIP,
cursor.getInt(COLUMN_ATTENDEE_RELATIONSHIP));
attendeeValues.put(Calendar.Attendees.ATTENDEE_TYPE,
cursor.getInt(COLUMN_ATTENDEE_TYPE));
attendeeValues.put(Calendar.Attendees.ATTENDEE_STATUS,
cursor.getInt(COLUMN_ATTENDEE_STATUS));
entity.addSubValue(Calendar.Attendees.CONTENT_URI, attendeeValues);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
cursor = null;
try {
cursor = mDb.query(sExtendedPropertiesTable, EXTENDED_PROJECTION,
"event_id=" + eventId, null, null, null, null);
while (cursor.moveToNext()) {
ContentValues extendedValues = new ContentValues();
extendedValues.put(Calendar.ExtendedProperties.NAME, c.getString(COLUMN_NAME));
extendedValues.put(Calendar.ExtendedProperties.VALUE,
c.getString(COLUMN_VALUE));
entity.addSubValue(Calendar.ExtendedProperties.CONTENT_URI, extendedValues);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
mEntityCursor.moveToNext();
// add the data to the contact
return entity;
}
}
@Override
public EntityIterator queryEntities(Uri uri, String selection, String[] selectionArgs,
String sortOrder) {
final int match = sURLMatcher.match(uri);
switch (match) {
case EVENTS:
case EVENTS_ID:
String calendarId = null;
if (match == EVENTS_ID) {
calendarId = uri.getPathSegments().get(1);
}
return new CalendarEntityIterator(this, calendarId,
uri, selection, selectionArgs, sortOrder);
default:
throw new UnsupportedOperationException("Unknown uri: " + uri);
}
}
@Override
public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
throws OperationApplicationException {
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentProviderResult[] results = super.applyBatch(operations);
db.setTransactionSuccessful();
return results;
} finally {
db.endTransaction();
}
}
}