CalendarAppWidgetService.java revision 82a8afab75ee998fcc90a4bcbc62f4912bc582ad
1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.calendar.widget;
18
19import com.android.calendar.R;
20import com.android.calendar.Utils;
21import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo;
22import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo;
23import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo;
24
25import android.app.AlarmManager;
26import android.app.PendingIntent;
27import android.appwidget.AppWidgetManager;
28import android.content.BroadcastReceiver;
29import android.content.ContentResolver;
30import android.content.Context;
31import android.content.CursorLoader;
32import android.content.Intent;
33import android.content.IntentFilter;
34import android.content.IntentFilter.MalformedMimeTypeException;
35import android.content.Loader;
36import android.content.res.Resources;
37import android.database.Cursor;
38import android.database.MatrixCursor;
39import android.net.Uri;
40import android.os.Handler;
41import android.provider.CalendarContract;
42import android.provider.CalendarContract.Attendees;
43import android.provider.CalendarContract.Calendars;
44import android.provider.CalendarContract.Instances;
45import android.text.format.DateUtils;
46import android.text.format.Time;
47import android.util.Log;
48import android.view.View;
49import android.widget.RemoteViews;
50import android.widget.RemoteViewsService;
51
52
53public class CalendarAppWidgetService extends RemoteViewsService {
54    private static final String TAG = "CalendarWidget";
55
56    static final int EVENT_MIN_COUNT = 20;
57    static final int EVENT_MAX_COUNT = 503;
58    // Minimum delay between queries on the database for widget updates in ms
59    static final int WIDGET_UPDATE_THROTTLE = 500;
60
61    private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, "
62            + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, "
63            + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT;
64
65    private static final String EVENT_SELECTION = Calendars.VISIBLE + "=1";
66    private static final String EVENT_SELECTION_HIDE_DECLINED = Calendars.VISIBLE + "=1 AND "
67            + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED;
68
69    static final String[] EVENT_PROJECTION = new String[] {
70        Instances.ALL_DAY,
71        Instances.BEGIN,
72        Instances.END,
73        Instances.TITLE,
74        Instances.EVENT_LOCATION,
75        Instances.EVENT_ID,
76        Instances.START_DAY,
77        Instances.END_DAY,
78        Instances.CALENDAR_COLOR,
79        Instances.SELF_ATTENDEE_STATUS,
80    };
81
82    static final int INDEX_ALL_DAY = 0;
83    static final int INDEX_BEGIN = 1;
84    static final int INDEX_END = 2;
85    static final int INDEX_TITLE = 3;
86    static final int INDEX_EVENT_LOCATION = 4;
87    static final int INDEX_EVENT_ID = 5;
88    static final int INDEX_START_DAY = 6;
89    static final int INDEX_END_DAY = 7;
90    static final int INDEX_COLOR = 8;
91    static final int INDEX_SELF_ATTENDEE_STATUS = 9;
92
93    static final int MAX_DAYS = 7;
94
95    private static final long SEARCH_DURATION = MAX_DAYS * DateUtils.DAY_IN_MILLIS;
96
97    /**
98     * Update interval used when no next-update calculated, or bad trigger time in past.
99     * Unit: milliseconds.
100     */
101    private static final long UPDATE_TIME_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6;
102
103    @Override
104    public RemoteViewsFactory onGetViewFactory(Intent intent) {
105        return new CalendarFactory(getApplicationContext(), intent);
106    }
107
108    public static class CalendarFactory extends BroadcastReceiver implements
109            RemoteViewsService.RemoteViewsFactory, Loader.OnLoadCompleteListener<Cursor> {
110        private static final boolean LOGD = false;
111        private static final int DECLINED_EVENT_ALPHA = 0x66000000;
112
113        // Suppress unnecessary logging about update time. Need to be static as this object is
114        // re-instanciated frequently.
115        // TODO: It seems loadData() is called via onCreate() four times, which should mean
116        // unnecessary CalendarFactory object is created and dropped. It is not efficient.
117        private static long sLastUpdateTime = UPDATE_TIME_NO_EVENTS;
118
119        private Context mContext;
120        private Resources mResources;
121        private static CalendarAppWidgetModel mModel;
122        private static Cursor mCursor;
123        private static volatile Integer mLock = new Integer(0);
124        private int mLastLock;
125        private CursorLoader mLoader;
126        private Handler mHandler = new Handler();
127        private int mAppWidgetId;
128        private int mDeclinedColor;
129        private int mStandardColor;
130
131        private Runnable mTimezoneChanged = new Runnable() {
132            @Override
133            public void run() {
134                if (mLoader != null) {
135                    mLoader.forceLoad();
136                }
137            }
138        };
139
140        private Runnable mUpdateLoader = new Runnable() {
141            @Override
142            public void run() {
143                if (mLoader != null) {
144                    Uri uri = createLoaderUri();
145                    mLoader.setUri(uri);
146                    String selection = Utils.getHideDeclinedEvents(mContext) ?
147                            EVENT_SELECTION_HIDE_DECLINED : EVENT_SELECTION;
148                    mLoader.setSelection(selection);
149                    synchronized (mLock) {
150                        mLastLock = ++mLock;
151                    }
152                    mLoader.forceLoad();
153                }
154            }
155        };
156
157        protected CalendarFactory(Context context, Intent intent) {
158            mContext = context;
159            mResources = context.getResources();
160            mAppWidgetId = intent.getIntExtra(
161                    AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
162
163            mDeclinedColor = mResources.getColor(R.color.agenda_item_declined_color);
164            mStandardColor = mResources.getColor(R.color.agenda_item_standard_color);
165        }
166
167        public CalendarFactory() {
168            // This is being created as part of onReceive
169
170        }
171
172        @Override
173        public void onCreate() {
174            initLoader();
175        }
176
177        @Override
178        public void onDataSetChanged() {
179        }
180
181        @Override
182        public void onDestroy() {
183            if (mCursor != null) {
184                mCursor.close();
185            }
186            if (mLoader != null) {
187                mLoader.reset();
188            }
189            mContext.unregisterReceiver(this);
190        }
191
192        @Override
193        public RemoteViews getLoadingView() {
194            RemoteViews views = new RemoteViews(mContext.getPackageName(),
195                    R.layout.appwidget_loading);
196            return views;
197        }
198
199        @Override
200        public RemoteViews getViewAt(int position) {
201            // we use getCount here so that it doesn't return null when empty
202            if (position < 0 || position >= getCount()) {
203                return null;
204            }
205
206            if (mModel == null) {
207                RemoteViews views = new RemoteViews(mContext.getPackageName(),
208                        R.layout.appwidget_loading);
209                final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
210                        0, 0);
211                views.setOnClickFillInIntent(R.id.appwidget_loading, intent);
212                return views;
213
214            }
215            if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) {
216                RemoteViews views = new RemoteViews(mContext.getPackageName(),
217                        R.layout.appwidget_no_events);
218                final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
219                        0, 0);
220                views.setOnClickFillInIntent(R.id.appwidget_no_events, intent);
221                return views;
222            }
223
224            RowInfo rowInfo = mModel.mRowInfos.get(position);
225            if (rowInfo.mType == RowInfo.TYPE_DAY) {
226                RemoteViews views = new RemoteViews(mContext.getPackageName(),
227                        R.layout.appwidget_day);
228                DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex);
229                updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel);
230                return views;
231            } else {
232                RemoteViews views;
233                final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
234                if (eventInfo.allDay) {
235                    views = new RemoteViews(mContext.getPackageName(),
236                            R.layout.widget_all_day_item);
237                } else {
238                    views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
239                }
240                int displayColor = Utils.getDisplayColorFromColor(eventInfo.color);
241
242                final long now = System.currentTimeMillis();
243                if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) {
244                    views.setInt(R.id.widget_row, "setBackgroundColor",
245                            mResources.getColor(R.color.appwidget_row_in_progress));
246                } else {
247                    views.setInt(R.id.widget_row, "setBackgroundColor", 0);
248                }
249
250                updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when);
251                updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where);
252                updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title);
253
254                views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE);
255
256                int selfAttendeeStatus = eventInfo.selfAttendeeStatus;
257                if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
258                    views.setInt(R.id.title, "setTextColor", mDeclinedColor);
259                    views.setInt(R.id.when, "setTextColor", mDeclinedColor);
260                    views.setInt(R.id.where, "setTextColor", mDeclinedColor);
261                    // views.setInt(R.id.agenda_item_color, "setDrawStyle",
262                    // ColorChipView.DRAW_CROSS_HATCHED);
263                    views.setInt(R.id.agenda_item_color, "setImageResource",
264                            R.drawable.widget_chip_declined_bg);
265                    // 40% opacity
266                    views.setInt(R.id.agenda_item_color, "setColorFilter",
267                            (displayColor & 0x00FFFFFF) | DECLINED_EVENT_ALPHA);
268                } else {
269                    views.setInt(R.id.title, "setTextColor", mStandardColor);
270                    views.setInt(R.id.when, "setTextColor", mStandardColor);
271                    views.setInt(R.id.where, "setTextColor", mStandardColor);
272                    if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
273                        views.setInt(R.id.agenda_item_color, "setImageResource",
274                                R.drawable.widget_chip_not_responded_bg);
275                    } else {
276                        views.setInt(R.id.agenda_item_color, "setImageResource",
277                                R.drawable.widget_chip_responded_bg);
278                    }
279                    views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
280                }
281
282                long start = eventInfo.start;
283                long end = eventInfo.end;
284                // An element in ListView.
285                if (eventInfo.allDay) {
286                    String tz = Utils.getTimeZone(mContext, null);
287                    Time recycle = new Time();
288                    start = Utils.convertAlldayLocalToUTC(recycle, start, tz);
289                    end = Utils.convertAlldayLocalToUTC(recycle, end, tz);
290                }
291                final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent(
292                        mContext, eventInfo.id, start, end);
293                views.setOnClickFillInIntent(R.id.widget_row, fillInIntent);
294                return views;
295            }
296        }
297
298        @Override
299        public int getViewTypeCount() {
300            return 4;
301        }
302
303        @Override
304        public int getCount() {
305            // if there are no events, we still return 1 to represent the "no
306            // events" view
307            if (mModel == null) {
308                return 1;
309            }
310            return Math.max(1, mModel.mRowInfos.size());
311        }
312
313        @Override
314        public long getItemId(int position) {
315            if (mModel == null ||  mModel.mRowInfos.isEmpty()) {
316                return 0;
317            }
318            RowInfo rowInfo = mModel.mRowInfos.get(position);
319            if (rowInfo.mType == RowInfo.TYPE_DAY) {
320                return rowInfo.mIndex;
321            }
322            EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
323            long prime = 31;
324            long result = 1;
325            result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32));
326            result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32));
327            return result;
328        }
329
330        @Override
331        public boolean hasStableIds() {
332            return true;
333        }
334
335        /**
336         * Query across all calendars for upcoming event instances from now
337         * until some time in the future. Widen the time range that we query by
338         * one day on each end so that we can catch all-day events. All-day
339         * events are stored starting at midnight in UTC but should be included
340         * in the list of events starting at midnight local time. This may fetch
341         * more events than we actually want, so we filter them out later.
342         *
343         * @param resolver {@link ContentResolver} to use when querying
344         *            {@link Instances#CONTENT_URI}.
345         * @param searchDuration Distance into the future to look for event
346         *            instances, in milliseconds.
347         * @param now Current system time to use for this update, possibly from
348         *            {@link System#currentTimeMillis()}.
349         */
350        public void initLoader() {
351            if (LOGD)
352                Log.d(TAG, "Querying for widget events...");
353
354            // Search for events from now until some time in the future
355            Uri uri = createLoaderUri();
356            String selection = Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED
357                    : EVENT_SELECTION;
358            mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null,
359                    EVENT_SORT_ORDER);
360            mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE);
361            synchronized (mLock) {
362                mLastLock = ++mLock;
363            }
364            mLoader.startLoading();
365            mLoader.registerListener(mAppWidgetId, this);
366
367        }
368
369        /**
370         * @return The uri for the loader
371         */
372        private Uri createLoaderUri() {
373            long now = System.currentTimeMillis();
374            // Add a day on either side to catch all-day events
375            long begin = now - DateUtils.DAY_IN_MILLIS;
376            long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS;
377
378            Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end);
379            return uri;
380        }
381
382        /* @VisibleForTesting */
383        protected static CalendarAppWidgetModel buildAppWidgetModel(
384                Context context, Cursor cursor, String timeZone) {
385            CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone);
386            model.buildFromCursor(cursor, timeZone);
387            return model;
388        }
389
390        /**
391         * Calculates and returns the next time we should push widget updates.
392         */
393        private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) {
394            // Make sure an update happens at midnight or earlier
395            long minUpdateTime = getNextMidnightTimeMillis(timeZone);
396            for (EventInfo event : model.mEventInfos) {
397                final long start;
398                final long end;
399                start = event.start;
400                end = event.end;
401
402                // We want to update widget when we enter/exit time range of an event.
403                if (now < start) {
404                    minUpdateTime = Math.min(minUpdateTime, start);
405                } else if (now < end) {
406                    minUpdateTime = Math.min(minUpdateTime, end);
407                }
408            }
409            return minUpdateTime;
410        }
411
412        private static long getNextMidnightTimeMillis(String timezone) {
413            Time time = new Time();
414            time.setToNow();
415            time.monthDay++;
416            time.hour = 0;
417            time.minute = 0;
418            time.second = 0;
419            long midnightDeviceTz = time.normalize(true);
420
421            time.timezone = timezone;
422            time.setToNow();
423            time.monthDay++;
424            time.hour = 0;
425            time.minute = 0;
426            time.second = 0;
427            long midnightHomeTz = time.normalize(true);
428
429            return Math.min(midnightDeviceTz, midnightHomeTz);
430        }
431
432        static void updateTextView(RemoteViews views, int id, int visibility, String string) {
433            views.setViewVisibility(id, visibility);
434            if (visibility == View.VISIBLE) {
435                views.setTextViewText(id, string);
436            }
437        }
438
439        /*
440         * (non-Javadoc)
441         * @see
442         * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android
443         * .content.Loader, java.lang.Object)
444         */
445        @Override
446        public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
447            if (cursor == null) {
448                return;
449            }
450            // If a newer update has happened since we started clean up and
451            // return
452            synchronized (mLock) {
453                if (mLastLock != mLock) {
454                    cursor.close();
455                    return;
456                }
457                // Copy it to a local static cursor.
458                MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor);
459                cursor.close();
460
461                final long now = System.currentTimeMillis();
462                if (mCursor != null) {
463                    mCursor.close();
464                }
465                mCursor = matrixCursor;
466                String tz = Utils.getTimeZone(mContext, mTimezoneChanged);
467                mModel = buildAppWidgetModel(mContext, mCursor, tz);
468
469                // Schedule an alarm to wake ourselves up for the next update.
470                // We also cancel
471                // all existing wake-ups because PendingIntents don't match
472                // against extras.
473                long triggerTime = calculateUpdateTime(mModel, now, tz);
474
475                // If no next-update calculated, or bad trigger time in past,
476                // schedule
477                // update about six hours from now.
478                if (triggerTime < now) {
479                    Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now));
480                    triggerTime = now + UPDATE_TIME_NO_EVENTS;
481                }
482
483                final AlarmManager alertManager = (AlarmManager) mContext
484                        .getSystemService(Context.ALARM_SERVICE);
485                final PendingIntent pendingUpdate = CalendarAppWidgetProvider
486                        .getUpdateIntent(mContext);
487
488                alertManager.cancel(pendingUpdate);
489                alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate);
490                Time time = new Time(Utils.getTimeZone(mContext, null));
491                time.setToNow();
492
493                if (time.normalize(true) != sLastUpdateTime) {
494                    Time time2 = new Time(Utils.getTimeZone(mContext, null));
495                    time2.set(sLastUpdateTime);
496                    time2.normalize(true);
497                    if (time.year != time2.year || time.yearDay != time2.yearDay) {
498                        final Intent updateIntent = new Intent(
499                                Utils.getWidgetUpdateAction(mContext));
500                        mContext.sendBroadcast(updateIntent);
501                    }
502
503                    sLastUpdateTime = time.toMillis(true);
504                }
505
506                AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext);
507                if (mAppWidgetId == -1) {
508                    int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider
509                            .getComponentName(mContext));
510
511                    widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list);
512                } else {
513                    widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list);
514                }
515            }
516        }
517
518        @Override
519        public void onReceive(Context context, Intent intent) {
520            if (LOGD)
521                Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString());
522            mContext = context;
523            if (mLoader == null) {
524                mAppWidgetId = -1;
525                initLoader();
526            } else {
527                mHandler.removeCallbacks(mUpdateLoader);
528                mHandler.post(mUpdateLoader);
529            }
530        }
531    }
532
533    /**
534     * Format given time for debugging output.
535     *
536     * @param unixTime Target time to report.
537     * @param now Current system time from {@link System#currentTimeMillis()}
538     *            for calculating time difference.
539     */
540    static String formatDebugTime(long unixTime, long now) {
541        Time time = new Time();
542        time.set(unixTime);
543
544        long delta = unixTime - now;
545        if (delta > DateUtils.MINUTE_IN_MILLIS) {
546            delta /= DateUtils.MINUTE_IN_MILLIS;
547            return String.format("[%d] %s (%+d mins)", unixTime,
548                    time.format("%H:%M:%S"), delta);
549        } else {
550            delta /= DateUtils.SECOND_IN_MILLIS;
551            return String.format("[%d] %s (%+d secs)", unixTime,
552                    time.format("%H:%M:%S"), delta);
553        }
554    }
555}
556