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