| /* |
| * 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, "setDrawStyle", |
| // ColorChipView.DRAW_CROSS_HATCHED); |
| 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 (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); |
| } |
| } |
| } |