CalendarAppWidgetService.java revision 9e196e46b238e6bca1ef86c7d673807b78ab7016
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 android.app.AlarmManager;
20import android.app.PendingIntent;
21import android.appwidget.AppWidgetManager;
22import android.content.BroadcastReceiver;
23import android.content.Context;
24import android.content.CursorLoader;
25import android.content.Intent;
26import android.content.Loader;
27import android.content.res.Resources;
28import android.database.Cursor;
29import android.database.MatrixCursor;
30import android.net.Uri;
31import android.os.Handler;
32import android.provider.CalendarContract.Attendees;
33import android.provider.CalendarContract.Calendars;
34import android.provider.CalendarContract.Instances;
35import android.text.format.DateUtils;
36import android.text.format.Time;
37import android.util.Log;
38import android.view.View;
39import android.widget.RemoteViews;
40import android.widget.RemoteViewsService;
41
42import com.android.calendar.R;
43import com.android.calendar.Utils;
44import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo;
45import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo;
46import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo;
47
48import java.util.concurrent.ExecutorService;
49import java.util.concurrent.Executors;
50import java.util.concurrent.atomic.AtomicInteger;
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 = 100;
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.DISPLAY_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
112        // Suppress unnecessary logging about update time. Need to be static as this object is
113        // re-instanciated frequently.
114        // TODO: It seems loadData() is called via onCreate() four times, which should mean
115        // unnecessary CalendarFactory object is created and dropped. It is not efficient.
116        private static long sLastUpdateTime = UPDATE_TIME_NO_EVENTS;
117
118        private Context mContext;
119        private Resources mResources;
120        private static CalendarAppWidgetModel mModel;
121        private static Cursor mCursor;
122        private static volatile Integer mLock = new Integer(0);
123        private int mLastLock;
124        private CursorLoader mLoader;
125        private final Handler mHandler = new Handler();
126        private static final AtomicInteger currentVersion = new AtomicInteger(0);
127        private final ExecutorService executor = Executors.newSingleThreadExecutor();
128        private int mAppWidgetId;
129        private int mDeclinedColor;
130        private int mStandardColor;
131        private int mAllDayColor;
132
133        private final Runnable mTimezoneChanged = new Runnable() {
134            @Override
135            public void run() {
136                if (mLoader != null) {
137                    mLoader.forceLoad();
138                }
139            }
140        };
141
142        private Runnable createUpdateLoaderRunnable(final String selection,
143                final PendingResult result, final int version) {
144            return new Runnable() {
145                @Override
146                public void run() {
147                    // If there is a newer load request in the queue, skip loading.
148                    if (mLoader != null && version >= currentVersion.get()) {
149                        Uri uri = createLoaderUri();
150                        mLoader.setUri(uri);
151                        mLoader.setSelection(selection);
152                        synchronized (mLock) {
153                            mLastLock = ++mLock;
154                        }
155                        mLoader.forceLoad();
156                    }
157                    result.finish();
158                }
159            };
160        }
161
162        protected CalendarFactory(Context context, Intent intent) {
163            mContext = context;
164            mResources = context.getResources();
165            mAppWidgetId = intent.getIntExtra(
166                    AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
167
168            mDeclinedColor = mResources.getColor(R.color.appwidget_item_declined_color);
169            mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color);
170            mAllDayColor = mResources.getColor(R.color.appwidget_item_allday_color);
171        }
172
173        public CalendarFactory() {
174            // This is being created as part of onReceive
175
176        }
177
178        @Override
179        public void onCreate() {
180            String selection = queryForSelection();
181            initLoader(selection);
182        }
183
184        @Override
185        public void onDataSetChanged() {
186        }
187
188        @Override
189        public void onDestroy() {
190            if (mCursor != null) {
191                mCursor.close();
192            }
193            if (mLoader != null) {
194                mLoader.reset();
195            }
196        }
197
198        @Override
199        public RemoteViews getLoadingView() {
200            RemoteViews views = new RemoteViews(mContext.getPackageName(),
201                    R.layout.appwidget_loading);
202            return views;
203        }
204
205        @Override
206        public RemoteViews getViewAt(int position) {
207            // we use getCount here so that it doesn't return null when empty
208            if (position < 0 || position >= getCount()) {
209                return null;
210            }
211
212            if (mModel == null) {
213                RemoteViews views = new RemoteViews(mContext.getPackageName(),
214                        R.layout.appwidget_loading);
215                final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
216                        0, 0, false);
217                views.setOnClickFillInIntent(R.id.appwidget_loading, intent);
218                return views;
219
220            }
221            if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) {
222                RemoteViews views = new RemoteViews(mContext.getPackageName(),
223                        R.layout.appwidget_no_events);
224                final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
225                        0, 0, false);
226                views.setOnClickFillInIntent(R.id.appwidget_no_events, intent);
227                return views;
228            }
229
230            RowInfo rowInfo = mModel.mRowInfos.get(position);
231            if (rowInfo.mType == RowInfo.TYPE_DAY) {
232                RemoteViews views = new RemoteViews(mContext.getPackageName(),
233                        R.layout.appwidget_day);
234                DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex);
235                updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel);
236                return views;
237            } else {
238                RemoteViews views;
239                final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
240                if (eventInfo.allDay) {
241                    views = new RemoteViews(mContext.getPackageName(),
242                            R.layout.widget_all_day_item);
243                } else {
244                    views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
245                }
246                int displayColor = Utils.getDisplayColorFromColor(eventInfo.color);
247
248                final long now = System.currentTimeMillis();
249                if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) {
250                    views.setInt(R.id.widget_row, "setBackgroundResource",
251                            R.drawable.agenda_item_bg_secondary);
252                } else {
253                    views.setInt(R.id.widget_row, "setBackgroundResource",
254                            R.drawable.agenda_item_bg_primary);
255                }
256
257                if (!eventInfo.allDay) {
258                    updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when);
259                    updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where);
260                }
261                updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title);
262
263                views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE);
264
265                int selfAttendeeStatus = eventInfo.selfAttendeeStatus;
266                if (eventInfo.allDay) {
267                    if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
268                        views.setInt(R.id.agenda_item_color, "setImageResource",
269                                R.drawable.widget_chip_not_responded_bg);
270                        views.setInt(R.id.title, "setTextColor", displayColor);
271                    } else {
272                        views.setInt(R.id.agenda_item_color, "setImageResource",
273                                R.drawable.widget_chip_responded_bg);
274                        views.setInt(R.id.title, "setTextColor", mAllDayColor);
275                    }
276                    if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
277                        // 40% opacity
278                        views.setInt(R.id.agenda_item_color, "setColorFilter",
279                                Utils.getDeclinedColorFromColor(displayColor));
280                    } else {
281                        views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
282                    }
283                } else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
284                    views.setInt(R.id.title, "setTextColor", mDeclinedColor);
285                    views.setInt(R.id.when, "setTextColor", mDeclinedColor);
286                    views.setInt(R.id.where, "setTextColor", mDeclinedColor);
287                    // views.setInt(R.id.agenda_item_color, "setDrawStyle",
288                    // ColorChipView.DRAW_CROSS_HATCHED);
289                    views.setInt(R.id.agenda_item_color, "setImageResource",
290                            R.drawable.widget_chip_responded_bg);
291                    // 40% opacity
292                    views.setInt(R.id.agenda_item_color, "setColorFilter",
293                            Utils.getDeclinedColorFromColor(displayColor));
294                } else {
295                    views.setInt(R.id.title, "setTextColor", mStandardColor);
296                    views.setInt(R.id.when, "setTextColor", mStandardColor);
297                    views.setInt(R.id.where, "setTextColor", mStandardColor);
298                    if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
299                        views.setInt(R.id.agenda_item_color, "setImageResource",
300                                R.drawable.widget_chip_not_responded_bg);
301                    } else {
302                        views.setInt(R.id.agenda_item_color, "setImageResource",
303                                R.drawable.widget_chip_responded_bg);
304                    }
305                    views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
306                }
307
308                long start = eventInfo.start;
309                long end = eventInfo.end;
310                // An element in ListView.
311                if (eventInfo.allDay) {
312                    String tz = Utils.getTimeZone(mContext, null);
313                    Time recycle = new Time();
314                    start = Utils.convertAlldayLocalToUTC(recycle, start, tz);
315                    end = Utils.convertAlldayLocalToUTC(recycle, end, tz);
316                }
317                final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent(
318                        mContext, eventInfo.id, start, end, eventInfo.allDay);
319                views.setOnClickFillInIntent(R.id.widget_row, fillInIntent);
320                return views;
321            }
322        }
323
324        @Override
325        public int getViewTypeCount() {
326            return 4;
327        }
328
329        @Override
330        public int getCount() {
331            // if there are no events, we still return 1 to represent the "no
332            // events" view
333            if (mModel == null) {
334                return 1;
335            }
336            return Math.max(1, mModel.mRowInfos.size());
337        }
338
339        @Override
340        public long getItemId(int position) {
341            if (mModel == null ||  mModel.mRowInfos.isEmpty() || position >= getCount()) {
342                return 0;
343            }
344            RowInfo rowInfo = mModel.mRowInfos.get(position);
345            if (rowInfo.mType == RowInfo.TYPE_DAY) {
346                return rowInfo.mIndex;
347            }
348            EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
349            long prime = 31;
350            long result = 1;
351            result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32));
352            result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32));
353            return result;
354        }
355
356        @Override
357        public boolean hasStableIds() {
358            return true;
359        }
360
361        /**
362         * Query across all calendars for upcoming event instances from now
363         * until some time in the future. Widen the time range that we query by
364         * one day on each end so that we can catch all-day events. All-day
365         * events are stored starting at midnight in UTC but should be included
366         * in the list of events starting at midnight local time. This may fetch
367         * more events than we actually want, so we filter them out later.
368         *
369         * @param selection The selection string for the loader to filter the query with.
370         */
371        public void initLoader(String selection) {
372            if (LOGD)
373                Log.d(TAG, "Querying for widget events...");
374
375            // Search for events from now until some time in the future
376            Uri uri = createLoaderUri();
377            mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null,
378                    EVENT_SORT_ORDER);
379            mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE);
380            synchronized (mLock) {
381                mLastLock = ++mLock;
382            }
383            mLoader.registerListener(mAppWidgetId, this);
384            mLoader.startLoading();
385
386        }
387
388        /**
389         * This gets the selection string for the loader.  This ends up doing a query in the
390         * shared preferences.
391         */
392        private String queryForSelection() {
393            return Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED
394                    : EVENT_SELECTION;
395        }
396
397        /**
398         * @return The uri for the loader
399         */
400        private Uri createLoaderUri() {
401            long now = System.currentTimeMillis();
402            // Add a day on either side to catch all-day events
403            long begin = now - DateUtils.DAY_IN_MILLIS;
404            long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS;
405
406            Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end);
407            return uri;
408        }
409
410        /* @VisibleForTesting */
411        protected static CalendarAppWidgetModel buildAppWidgetModel(
412                Context context, Cursor cursor, String timeZone) {
413            CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone);
414            model.buildFromCursor(cursor, timeZone);
415            return model;
416        }
417
418        /**
419         * Calculates and returns the next time we should push widget updates.
420         */
421        private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) {
422            // Make sure an update happens at midnight or earlier
423            long minUpdateTime = getNextMidnightTimeMillis(timeZone);
424            for (EventInfo event : model.mEventInfos) {
425                final long start;
426                final long end;
427                start = event.start;
428                end = event.end;
429
430                // We want to update widget when we enter/exit time range of an event.
431                if (now < start) {
432                    minUpdateTime = Math.min(minUpdateTime, start);
433                } else if (now < end) {
434                    minUpdateTime = Math.min(minUpdateTime, end);
435                }
436            }
437            return minUpdateTime;
438        }
439
440        private static long getNextMidnightTimeMillis(String timezone) {
441            Time time = new Time();
442            time.setToNow();
443            time.monthDay++;
444            time.hour = 0;
445            time.minute = 0;
446            time.second = 0;
447            long midnightDeviceTz = time.normalize(true);
448
449            time.timezone = timezone;
450            time.setToNow();
451            time.monthDay++;
452            time.hour = 0;
453            time.minute = 0;
454            time.second = 0;
455            long midnightHomeTz = time.normalize(true);
456
457            return Math.min(midnightDeviceTz, midnightHomeTz);
458        }
459
460        static void updateTextView(RemoteViews views, int id, int visibility, String string) {
461            views.setViewVisibility(id, visibility);
462            if (visibility == View.VISIBLE) {
463                views.setTextViewText(id, string);
464            }
465        }
466
467        /*
468         * (non-Javadoc)
469         * @see
470         * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android
471         * .content.Loader, java.lang.Object)
472         */
473        @Override
474        public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
475            if (cursor == null) {
476                return;
477            }
478            // If a newer update has happened since we started clean up and
479            // return
480            synchronized (mLock) {
481                if (mLastLock != mLock) {
482                    cursor.close();
483                    return;
484                }
485                // Copy it to a local static cursor.
486                MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor);
487                cursor.close();
488
489                final long now = System.currentTimeMillis();
490                if (mCursor != null) {
491                    mCursor.close();
492                }
493                mCursor = matrixCursor;
494                String tz = Utils.getTimeZone(mContext, mTimezoneChanged);
495                mModel = buildAppWidgetModel(mContext, mCursor, tz);
496
497                // Schedule an alarm to wake ourselves up for the next update.
498                // We also cancel
499                // all existing wake-ups because PendingIntents don't match
500                // against extras.
501                long triggerTime = calculateUpdateTime(mModel, now, tz);
502
503                // If no next-update calculated, or bad trigger time in past,
504                // schedule
505                // update about six hours from now.
506                if (triggerTime < now) {
507                    Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now));
508                    triggerTime = now + UPDATE_TIME_NO_EVENTS;
509                }
510
511                final AlarmManager alertManager = (AlarmManager) mContext
512                        .getSystemService(Context.ALARM_SERVICE);
513                final PendingIntent pendingUpdate = CalendarAppWidgetProvider
514                        .getUpdateIntent(mContext);
515
516                alertManager.cancel(pendingUpdate);
517                alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate);
518                Time time = new Time(Utils.getTimeZone(mContext, null));
519                time.setToNow();
520
521                if (time.normalize(true) != sLastUpdateTime) {
522                    Time time2 = new Time(Utils.getTimeZone(mContext, null));
523                    time2.set(sLastUpdateTime);
524                    time2.normalize(true);
525                    if (time.year != time2.year || time.yearDay != time2.yearDay) {
526                        final Intent updateIntent = new Intent(
527                                Utils.getWidgetUpdateAction(mContext));
528                        mContext.sendBroadcast(updateIntent);
529                    }
530
531                    sLastUpdateTime = time.toMillis(true);
532                }
533
534                AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext);
535                if (mAppWidgetId == -1) {
536                    int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider
537                            .getComponentName(mContext));
538
539                    widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list);
540                } else {
541                    widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list);
542                }
543            }
544        }
545
546        @Override
547        public void onReceive(Context context, Intent intent) {
548            if (LOGD)
549                Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString());
550            mContext = context;
551
552            // We cannot do any queries from the UI thread, so push the 'selection' query
553            // to a background thread.  However the implementation of the latter query
554            // (cursor loading) uses CursorLoader which must be initiated from the UI thread,
555            // so there is some convoluted handshaking here.
556            //
557            // Note that as currently implemented, this must run in a single threaded executor
558            // or else the loads may be run out of order.
559            //
560            // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously
561            // in the background thread.  All the handshaking going on here between the UI and
562            // background thread with using goAsync, mHandler, and CursorLoader is confusing.
563            final PendingResult result = goAsync();
564            executor.submit(new Runnable() {
565                @Override
566                public void run() {
567                    // We always complete queryForSelection() even if the load task ends up being
568                    // canceled because of a more recent one.  Optimizing this to allow
569                    // canceling would require keeping track of all the PendingResults
570                    // (from goAsync) to abort them.  Defer this until it becomes a problem.
571                    final String selection = queryForSelection();
572
573                    if (mLoader == null) {
574                        mAppWidgetId = -1;
575                        mHandler.post(new Runnable() {
576                            @Override
577                            public void run() {
578                                initLoader(selection);
579                                result.finish();
580                            }
581                        });
582                    } else {
583                        mHandler.post(createUpdateLoaderRunnable(selection, result,
584                                currentVersion.incrementAndGet()));
585                    }
586                }
587            });
588        }
589    }
590
591    /**
592     * Format given time for debugging output.
593     *
594     * @param unixTime Target time to report.
595     * @param now Current system time from {@link System#currentTimeMillis()}
596     *            for calculating time difference.
597     */
598    static String formatDebugTime(long unixTime, long now) {
599        Time time = new Time();
600        time.set(unixTime);
601
602        long delta = unixTime - now;
603        if (delta > DateUtils.MINUTE_IN_MILLIS) {
604            delta /= DateUtils.MINUTE_IN_MILLIS;
605            return String.format("[%d] %s (%+d mins)", unixTime,
606                    time.format("%H:%M:%S"), delta);
607        } else {
608            delta /= DateUtils.SECOND_IN_MILLIS;
609            return String.format("[%d] %s (%+d secs)", unixTime,
610                    time.format("%H:%M:%S"), delta);
611        }
612    }
613}
614