summaryrefslogtreecommitdiff
path: root/src/com/android/calendar/widget/CalendarAppWidgetService.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/calendar/widget/CalendarAppWidgetService.java')
-rw-r--r--src/com/android/calendar/widget/CalendarAppWidgetService.java626
1 files changed, 626 insertions, 0 deletions
diff --git a/src/com/android/calendar/widget/CalendarAppWidgetService.java b/src/com/android/calendar/widget/CalendarAppWidgetService.java
new file mode 100644
index 00000000..ec702c7c
--- /dev/null
+++ b/src/com/android/calendar/widget/CalendarAppWidgetService.java
@@ -0,0 +1,626 @@
+/*
+ * Copyright (C) 2009 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,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.calendar.widget;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.Intent;
+import android.content.Loader;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.CalendarContract.Attendees;
+import android.provider.CalendarContract.Calendars;
+import android.provider.CalendarContract.Instances;
+import android.text.format.DateUtils;
+import android.text.format.Time;
+import android.util.Log;
+import android.view.View;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import com.android.calendar.R;
+import com.android.calendar.Utils;
+import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo;
+import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo;
+import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+
+public class CalendarAppWidgetService extends RemoteViewsService {
+ private static final String TAG = "CalendarWidget";
+
+ static final int EVENT_MIN_COUNT = 20;
+ static final int EVENT_MAX_COUNT = 100;
+ // Minimum delay between queries on the database for widget updates in ms
+ static final int WIDGET_UPDATE_THROTTLE = 500;
+
+ private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, "
+ + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, "
+ + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT;
+
+ private static final String EVENT_SELECTION = Calendars.VISIBLE + "=1";
+ private static final String EVENT_SELECTION_HIDE_DECLINED = Calendars.VISIBLE + "=1 AND "
+ + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED;
+
+ static final String[] EVENT_PROJECTION = new String[] {
+ Instances.ALL_DAY,
+ Instances.BEGIN,
+ Instances.END,
+ Instances.TITLE,
+ Instances.EVENT_LOCATION,
+ Instances.EVENT_ID,
+ Instances.START_DAY,
+ Instances.END_DAY,
+ Instances.DISPLAY_COLOR, // If SDK < 16, set to Instances.CALENDAR_COLOR.
+ Instances.SELF_ATTENDEE_STATUS,
+ };
+
+ static final int INDEX_ALL_DAY = 0;
+ static final int INDEX_BEGIN = 1;
+ static final int INDEX_END = 2;
+ static final int INDEX_TITLE = 3;
+ static final int INDEX_EVENT_LOCATION = 4;
+ static final int INDEX_EVENT_ID = 5;
+ static final int INDEX_START_DAY = 6;
+ static final int INDEX_END_DAY = 7;
+ static final int INDEX_COLOR = 8;
+ static final int INDEX_SELF_ATTENDEE_STATUS = 9;
+
+ static {
+ if (!Utils.isJellybeanOrLater()) {
+ EVENT_PROJECTION[INDEX_COLOR] = Instances.CALENDAR_COLOR;
+ }
+ }
+ static final int MAX_DAYS = 7;
+
+ private static final long SEARCH_DURATION = MAX_DAYS * DateUtils.DAY_IN_MILLIS;
+
+ /**
+ * Update interval used when no next-update calculated, or bad trigger time in past.
+ * Unit: milliseconds.
+ */
+ private static final long UPDATE_TIME_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6;
+
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ return new CalendarFactory(getApplicationContext(), intent);
+ }
+
+ public static class CalendarFactory extends BroadcastReceiver implements
+ RemoteViewsService.RemoteViewsFactory, Loader.OnLoadCompleteListener<Cursor> {
+ private static final boolean LOGD = false;
+
+ // Suppress unnecessary logging about update time. Need to be static as this object is
+ // re-instanciated frequently.
+ // TODO: It seems loadData() is called via onCreate() four times, which should mean
+ // unnecessary CalendarFactory object is created and dropped. It is not efficient.
+ private static long sLastUpdateTime = UPDATE_TIME_NO_EVENTS;
+
+ private Context mContext;
+ private Resources mResources;
+ private static CalendarAppWidgetModel mModel;
+ private static Object mLock = new Object();
+ private static volatile int mSerialNum = 0;
+ private int mLastSerialNum = -1;
+ private CursorLoader mLoader;
+ private final Handler mHandler = new Handler();
+ private static final AtomicInteger currentVersion = new AtomicInteger(0);
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private int mAppWidgetId;
+ private int mDeclinedColor;
+ private int mStandardColor;
+ private int mAllDayColor;
+
+ private final Runnable mTimezoneChanged = new Runnable() {
+ @Override
+ public void run() {
+ if (mLoader != null) {
+ mLoader.forceLoad();
+ }
+ }
+ };
+
+ private Runnable createUpdateLoaderRunnable(final String selection,
+ final PendingResult result, final int version) {
+ return new Runnable() {
+ @Override
+ public void run() {
+ // If there is a newer load request in the queue, skip loading.
+ if (mLoader != null && version >= currentVersion.get()) {
+ Uri uri = createLoaderUri();
+ mLoader.setUri(uri);
+ mLoader.setSelection(selection);
+ synchronized (mLock) {
+ mLastSerialNum = ++mSerialNum;
+ }
+ mLoader.forceLoad();
+ }
+ result.finish();
+ }
+ };
+ }
+
+ protected CalendarFactory(Context context, Intent intent) {
+ mContext = context;
+ mResources = context.getResources();
+ mAppWidgetId = intent.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+
+ mDeclinedColor = mResources.getColor(R.color.appwidget_item_declined_color);
+ mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color);
+ mAllDayColor = mResources.getColor(R.color.appwidget_item_allday_color);
+ }
+
+ public CalendarFactory() {
+ // This is being created as part of onReceive
+
+ }
+
+ @Override
+ public void onCreate() {
+ String selection = queryForSelection();
+ initLoader(selection);
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mLoader != null) {
+ mLoader.reset();
+ }
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ RemoteViews views = new RemoteViews(mContext.getPackageName(),
+ R.layout.appwidget_loading);
+ return views;
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ // we use getCount here so that it doesn't return null when empty
+ if (position < 0 || position >= getCount()) {
+ return null;
+ }
+
+ if (mModel == null) {
+ RemoteViews views = new RemoteViews(mContext.getPackageName(),
+ R.layout.appwidget_loading);
+ final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
+ 0, 0, false);
+ views.setOnClickFillInIntent(R.id.appwidget_loading, intent);
+ return views;
+
+ }
+ if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) {
+ RemoteViews views = new RemoteViews(mContext.getPackageName(),
+ R.layout.appwidget_no_events);
+ final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
+ 0, 0, false);
+ views.setOnClickFillInIntent(R.id.appwidget_no_events, intent);
+ return views;
+ }
+
+ RowInfo rowInfo = mModel.mRowInfos.get(position);
+ if (rowInfo.mType == RowInfo.TYPE_DAY) {
+ RemoteViews views = new RemoteViews(mContext.getPackageName(),
+ R.layout.appwidget_day);
+ DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex);
+ updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel);
+ return views;
+ } else {
+ RemoteViews views;
+ final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
+ if (eventInfo.allDay) {
+ views = new RemoteViews(mContext.getPackageName(),
+ R.layout.widget_all_day_item);
+ } else {
+ views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
+ }
+ int displayColor = Utils.getDisplayColorFromColor(eventInfo.color);
+
+ final long now = System.currentTimeMillis();
+ if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) {
+ views.setInt(R.id.widget_row, "setBackgroundResource",
+ R.drawable.agenda_item_bg_secondary);
+ } else {
+ views.setInt(R.id.widget_row, "setBackgroundResource",
+ R.drawable.agenda_item_bg_primary);
+ }
+
+ if (!eventInfo.allDay) {
+ updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when);
+ updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where);
+ }
+ updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title);
+
+ views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE);
+
+ int selfAttendeeStatus = eventInfo.selfAttendeeStatus;
+ if (eventInfo.allDay) {
+ if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
+ views.setInt(R.id.agenda_item_color, "setImageResource",
+ R.drawable.widget_chip_not_responded_bg);
+ views.setInt(R.id.title, "setTextColor", displayColor);
+ } else {
+ views.setInt(R.id.agenda_item_color, "setImageResource",
+ R.drawable.widget_chip_responded_bg);
+ views.setInt(R.id.title, "setTextColor", mAllDayColor);
+ }
+ if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
+ // 40% opacity
+ views.setInt(R.id.agenda_item_color, "setColorFilter",
+ Utils.getDeclinedColorFromColor(displayColor));
+ } else {
+ views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
+ }
+ } else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
+ views.setInt(R.id.title, "setTextColor", mDeclinedColor);
+ views.setInt(R.id.when, "setTextColor", mDeclinedColor);
+ views.setInt(R.id.where, "setTextColor", mDeclinedColor);
+ views.setInt(R.id.agenda_item_color, "setImageResource",
+ R.drawable.widget_chip_responded_bg);
+ // 40% opacity
+ views.setInt(R.id.agenda_item_color, "setColorFilter",
+ Utils.getDeclinedColorFromColor(displayColor));
+ } else {
+ views.setInt(R.id.title, "setTextColor", mStandardColor);
+ views.setInt(R.id.when, "setTextColor", mStandardColor);
+ views.setInt(R.id.where, "setTextColor", mStandardColor);
+ if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
+ views.setInt(R.id.agenda_item_color, "setImageResource",
+ R.drawable.widget_chip_not_responded_bg);
+ } else {
+ views.setInt(R.id.agenda_item_color, "setImageResource",
+ R.drawable.widget_chip_responded_bg);
+ }
+ views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
+ }
+
+ long start = eventInfo.start;
+ long end = eventInfo.end;
+ // An element in ListView.
+ if (eventInfo.allDay) {
+ String tz = Utils.getTimeZone(mContext, null);
+ Time recycle = new Time();
+ start = Utils.convertAlldayLocalToUTC(recycle, start, tz);
+ end = Utils.convertAlldayLocalToUTC(recycle, end, tz);
+ }
+ final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent(
+ mContext, eventInfo.id, start, end, eventInfo.allDay);
+ views.setOnClickFillInIntent(R.id.widget_row, fillInIntent);
+ return views;
+ }
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 5;
+ }
+
+ @Override
+ public int getCount() {
+ // if there are no events, we still return 1 to represent the "no
+ // events" view
+ if (mModel == null) {
+ return 1;
+ }
+ return Math.max(1, mModel.mRowInfos.size());
+ }
+
+ @Override
+ public long getItemId(int position) {
+ if (mModel == null || mModel.mRowInfos.isEmpty() || position >= getCount()) {
+ return 0;
+ }
+ RowInfo rowInfo = mModel.mRowInfos.get(position);
+ if (rowInfo.mType == RowInfo.TYPE_DAY) {
+ return rowInfo.mIndex;
+ }
+ EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
+ long prime = 31;
+ long result = 1;
+ result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32));
+ result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32));
+ return result;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ /**
+ * Query across all calendars for upcoming event instances from now
+ * until some time in the future. Widen the time range that we query by
+ * one day on each end so that we can catch all-day events. All-day
+ * events are stored starting at midnight in UTC but should be included
+ * in the list of events starting at midnight local time. This may fetch
+ * more events than we actually want, so we filter them out later.
+ *
+ * @param selection The selection string for the loader to filter the query with.
+ */
+ public void initLoader(String selection) {
+ if (LOGD)
+ Log.d(TAG, "Querying for widget events...");
+
+ // Search for events from now until some time in the future
+ Uri uri = createLoaderUri();
+ mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null,
+ EVENT_SORT_ORDER);
+ mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE);
+ synchronized (mLock) {
+ mLastSerialNum = ++mSerialNum;
+ }
+ mLoader.registerListener(mAppWidgetId, this);
+ mLoader.startLoading();
+
+ }
+
+ /**
+ * This gets the selection string for the loader. This ends up doing a query in the
+ * shared preferences.
+ */
+ private String queryForSelection() {
+ return Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED
+ : EVENT_SELECTION;
+ }
+
+ /**
+ * @return The uri for the loader
+ */
+ private Uri createLoaderUri() {
+ long now = System.currentTimeMillis();
+ // Add a day on either side to catch all-day events
+ long begin = now - DateUtils.DAY_IN_MILLIS;
+ long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS;
+
+ Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end);
+ return uri;
+ }
+
+ /* @VisibleForTesting */
+ protected static CalendarAppWidgetModel buildAppWidgetModel(
+ Context context, Cursor cursor, String timeZone) {
+ CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone);
+ model.buildFromCursor(cursor, timeZone);
+ return model;
+ }
+
+ /**
+ * Calculates and returns the next time we should push widget updates.
+ */
+ private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) {
+ // Make sure an update happens at midnight or earlier
+ long minUpdateTime = getNextMidnightTimeMillis(timeZone);
+ for (EventInfo event : model.mEventInfos) {
+ final long start;
+ final long end;
+ start = event.start;
+ end = event.end;
+
+ // We want to update widget when we enter/exit time range of an event.
+ if (now < start) {
+ minUpdateTime = Math.min(minUpdateTime, start);
+ } else if (now < end) {
+ minUpdateTime = Math.min(minUpdateTime, end);
+ }
+ }
+ return minUpdateTime;
+ }
+
+ private static long getNextMidnightTimeMillis(String timezone) {
+ Time time = new Time();
+ time.setToNow();
+ time.monthDay++;
+ time.hour = 0;
+ time.minute = 0;
+ time.second = 0;
+ long midnightDeviceTz = time.normalize(true);
+
+ time.timezone = timezone;
+ time.setToNow();
+ time.monthDay++;
+ time.hour = 0;
+ time.minute = 0;
+ time.second = 0;
+ long midnightHomeTz = time.normalize(true);
+
+ return Math.min(midnightDeviceTz, midnightHomeTz);
+ }
+
+ static void updateTextView(RemoteViews views, int id, int visibility, String string) {
+ views.setViewVisibility(id, visibility);
+ if (visibility == View.VISIBLE) {
+ views.setTextViewText(id, string);
+ }
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see
+ * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android
+ * .content.Loader, java.lang.Object)
+ */
+ @Override
+ public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
+ if (cursor == null) {
+ return;
+ }
+ // If a newer update has happened since we started clean up and
+ // return
+ synchronized (mLock) {
+ if (cursor.isClosed()) {
+ Log.wtf(TAG, "Got a closed cursor from onLoadComplete");
+ return;
+ }
+
+ if (mLastSerialNum != mSerialNum) {
+ return;
+ }
+
+ final long now = System.currentTimeMillis();
+ String tz = Utils.getTimeZone(mContext, mTimezoneChanged);
+
+ // Copy it to a local static cursor.
+ MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor);
+ try {
+ mModel = buildAppWidgetModel(mContext, matrixCursor, tz);
+ } finally {
+ if (matrixCursor != null) {
+ matrixCursor.close();
+ }
+
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ // Schedule an alarm to wake ourselves up for the next update.
+ // We also cancel
+ // all existing wake-ups because PendingIntents don't match
+ // against extras.
+ long triggerTime = calculateUpdateTime(mModel, now, tz);
+
+ // If no next-update calculated, or bad trigger time in past,
+ // schedule
+ // update about six hours from now.
+ if (triggerTime < now) {
+ Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now));
+ triggerTime = now + UPDATE_TIME_NO_EVENTS;
+ }
+
+ final AlarmManager alertManager = (AlarmManager) mContext
+ .getSystemService(Context.ALARM_SERVICE);
+ final PendingIntent pendingUpdate = CalendarAppWidgetProvider
+ .getUpdateIntent(mContext);
+
+ alertManager.cancel(pendingUpdate);
+ alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate);
+ Time time = new Time(Utils.getTimeZone(mContext, null));
+ time.setToNow();
+
+ if (time.normalize(true) != sLastUpdateTime) {
+ Time time2 = new Time(Utils.getTimeZone(mContext, null));
+ time2.set(sLastUpdateTime);
+ time2.normalize(true);
+ if (time.year != time2.year || time.yearDay != time2.yearDay) {
+ final Intent updateIntent = new Intent(
+ Utils.getWidgetUpdateAction(mContext));
+ mContext.sendBroadcast(updateIntent);
+ }
+
+ sLastUpdateTime = time.toMillis(true);
+ }
+
+ AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext);
+ if (widgetManager == null) {
+ return;
+ }
+ if (mAppWidgetId == -1) {
+ int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider
+ .getComponentName(mContext));
+
+ widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list);
+ } else {
+ widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list);
+ }
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (LOGD)
+ Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString());
+ mContext = context;
+
+ // We cannot do any queries from the UI thread, so push the 'selection' query
+ // to a background thread. However the implementation of the latter query
+ // (cursor loading) uses CursorLoader which must be initiated from the UI thread,
+ // so there is some convoluted handshaking here.
+ //
+ // Note that as currently implemented, this must run in a single threaded executor
+ // or else the loads may be run out of order.
+ //
+ // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously
+ // in the background thread. All the handshaking going on here between the UI and
+ // background thread with using goAsync, mHandler, and CursorLoader is confusing.
+ final PendingResult result = goAsync();
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ // We always complete queryForSelection() even if the load task ends up being
+ // canceled because of a more recent one. Optimizing this to allow
+ // canceling would require keeping track of all the PendingResults
+ // (from goAsync) to abort them. Defer this until it becomes a problem.
+ final String selection = queryForSelection();
+
+ if (mLoader == null) {
+ mAppWidgetId = -1;
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ initLoader(selection);
+ result.finish();
+ }
+ });
+ } else {
+ mHandler.post(createUpdateLoaderRunnable(selection, result,
+ currentVersion.incrementAndGet()));
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Format given time for debugging output.
+ *
+ * @param unixTime Target time to report.
+ * @param now Current system time from {@link System#currentTimeMillis()}
+ * for calculating time difference.
+ */
+ static String formatDebugTime(long unixTime, long now) {
+ Time time = new Time();
+ time.set(unixTime);
+
+ long delta = unixTime - now;
+ if (delta > DateUtils.MINUTE_IN_MILLIS) {
+ delta /= DateUtils.MINUTE_IN_MILLIS;
+ return String.format("[%d] %s (%+d mins)", unixTime,
+ time.format("%H:%M:%S"), delta);
+ } else {
+ delta /= DateUtils.SECOND_IN_MILLIS;
+ return String.format("[%d] %s (%+d secs)", unixTime,
+ time.format("%H:%M:%S"), delta);
+ }
+ }
+}