CalendarAppWidgetService.java revision f9df037f350fad73659307ba05f230d2db69051a
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.ContentResolver;
24import android.content.Context;
25import android.content.Intent;
26import android.content.IntentFilter;
27import android.database.ContentObserver;
28import android.database.Cursor;
29import android.database.MatrixCursor;
30import android.net.Uri;
31import android.os.Handler;
32import android.provider.Calendar;
33import android.provider.Calendar.Attendees;
34import android.provider.Calendar.Calendars;
35import android.provider.Calendar.Events;
36import android.provider.Calendar.Instances;
37import android.text.TextUtils;
38import android.text.format.DateFormat;
39import android.text.format.DateUtils;
40import android.text.format.Time;
41import android.util.Log;
42import android.view.View;
43import android.widget.RemoteViews;
44import android.widget.RemoteViewsService;
45
46import com.google.common.annotations.VisibleForTesting;
47
48import com.android.calendar.R;
49import com.android.calendar.Utils;
50import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo;
51
52import java.util.ArrayList;
53import java.util.List;
54import java.util.TimeZone;
55
56
57public class CalendarAppWidgetService extends RemoteViewsService {
58    private static final String TAG = "CalendarAppWidgetService";
59    private static final boolean LOGD = false;
60
61    private static final int EVENT_MAX_COUNT = 10;
62
63    private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, "
64            + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, "
65            + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT;
66
67    // TODO can't use parameter here because provider is dropping them
68    private static final String EVENT_SELECTION = Calendars.SELECTED + "=1 AND "
69            + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED;
70
71    static final String[] EVENT_PROJECTION = new String[] {
72        Instances.ALL_DAY,
73        Instances.BEGIN,
74        Instances.END,
75        Instances.TITLE,
76        Instances.EVENT_LOCATION,
77        Instances.EVENT_ID,
78    };
79
80    static final int INDEX_ALL_DAY = 0;
81    static final int INDEX_BEGIN = 1;
82    static final int INDEX_END = 2;
83    static final int INDEX_TITLE = 3;
84    static final int INDEX_EVENT_LOCATION = 4;
85    static final int INDEX_EVENT_ID = 5;
86
87    private static final long SEARCH_DURATION = DateUtils.WEEK_IN_MILLIS;
88
89    // If no next-update calculated, or bad trigger time in past, schedule
90    // update about six hours from now.
91    private static final long UPDATE_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6;
92
93    @Override
94    public RemoteViewsFactory onGetViewFactory(Intent intent) {
95        return new CalendarFactory(getApplicationContext(), intent);
96    }
97
98    protected static class MarkedEvents {
99
100        /**
101         * The row IDs of all events marked for display
102         */
103        List<Integer> markedIds = new ArrayList<Integer>(10);
104
105        /**
106         * The start time of the first marked event
107         */
108        long firstTime = -1;
109
110        /** The number of events currently in progress */
111        int inProgressCount = 0; // Number of events with same start time as the primary evt.
112
113        /** The start time of the next upcoming event */
114        long primaryTime = -1;
115
116        /**
117         * The number of events that share the same start time as the next
118         * upcoming event
119         */
120        int primaryCount = 0; // Number of events with same start time as the secondary evt.
121
122        /** The start time of the next next upcoming event */
123        long secondaryTime = 1;
124
125        /**
126         * The number of events that share the same start time as the next next
127         * upcoming event.
128         */
129        int secondaryCount = 0;
130    }
131
132    protected static class CalendarFactory implements RemoteViewsService.RemoteViewsFactory {
133
134        private static final String TAG = CalendarFactory.class.getSimpleName();
135
136        private static final boolean LOGD = false;
137
138        private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {
139            @Override
140            public void onReceive(Context context, Intent intent) {
141                String action = intent.getAction();
142                if (action.equals(CalendarAppWidgetProvider.ACTION_CALENDAR_APPWIDGET_UPDATE)
143                        || action.equals(Intent.ACTION_TIMEZONE_CHANGED)
144                        || action.equals(Intent.ACTION_TIME_CHANGED)
145                        || action.equals(Intent.ACTION_DATE_CHANGED)
146                        || (action.equals(Intent.ACTION_PROVIDER_CHANGED)
147                                && intent.getData().equals(Calendar.CONTENT_URI))) {
148                    loadData();
149                }
150            }
151        };
152
153        private final ContentObserver mContentObserver = new ContentObserver(new Handler()) {
154            @Override
155            public boolean deliverSelfNotifications() {
156                return true;
157            }
158
159            @Override
160            public void onChange(boolean selfChange) {
161                loadData();
162            }
163        };
164
165        private final int mAppWidgetId;
166
167        private Context mContext;
168
169        private CalendarAppWidgetModel mModel;
170
171        private Cursor mCursor;
172
173        protected CalendarFactory(Context context, Intent intent) {
174            mContext = context;
175            mAppWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
176                    AppWidgetManager.INVALID_APPWIDGET_ID);
177        }
178
179        @Override
180        public void onCreate() {
181            loadData();
182            IntentFilter filter = new IntentFilter();
183            filter.addAction(CalendarAppWidgetProvider.ACTION_CALENDAR_APPWIDGET_UPDATE);
184            filter.addAction(Intent.ACTION_TIME_CHANGED);
185            filter.addAction(Intent.ACTION_DATE_CHANGED);
186            filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);
187            filter.addAction(Intent.ACTION_PROVIDER_CHANGED);
188            mContext.registerReceiver(mIntentReceiver, filter);
189
190            mContext.getContentResolver().registerContentObserver(
191                    Events.CONTENT_URI, true, mContentObserver);
192        }
193
194        @Override
195        public void onDestroy() {
196            mCursor.close();
197            mContext.unregisterReceiver(mIntentReceiver);
198            mContext.getContentResolver().unregisterContentObserver(mContentObserver);
199        }
200
201
202        @Override
203        public RemoteViews getLoadingView() {
204            RemoteViews views = new RemoteViews(mContext.getPackageName(),
205                    R.layout.appwidget_loading);
206            return views;
207        }
208
209        @Override
210        public RemoteViews getViewAt(int position) {
211            // we use getCount here so that it doesn't return null when empty
212            if (position < 0 || position >= getCount()) {
213                return null;
214            }
215
216            if (mModel.eventInfos.length > 0) {
217                RemoteViews views = new RemoteViews(mContext.getPackageName(),
218                        R.layout.appwidget_row);
219
220                EventInfo e = mModel.eventInfos[position];
221
222                updateTextView(views, R.id.when, e.visibWhen, e.when);
223                updateTextView(views, R.id.where, e.visibWhere, e.where);
224                updateTextView(views, R.id.title, e.visibTitle, e.title);
225
226                PendingIntent launchIntent =
227                    CalendarAppWidgetProvider.getLaunchPendingIntent(
228                            mContext, e.start);
229                views.setOnClickPendingIntent(R.id.appwidget_row, launchIntent);
230                return views;
231            } else {
232                RemoteViews views = new RemoteViews(mContext.getPackageName(),
233                        R.layout.appwidget_no_events);
234                PendingIntent launchIntent =
235                    CalendarAppWidgetProvider.getLaunchPendingIntent(
236                            mContext, 0);
237                views.setOnClickPendingIntent(R.id.appwidget_no_events, launchIntent);
238                return views;
239            }
240        }
241
242        @Override
243        public int getViewTypeCount() {
244            return 3;
245        }
246
247        @Override
248        public int getCount() {
249            // if there are no events, we still return 1 to represent the "no
250            // events" view
251            return Math.max(1, mModel.eventInfos.length);
252        }
253
254        @Override
255        public long getItemId(int position) {
256            return position;
257        }
258
259        @Override
260        public boolean hasStableIds() {
261            return true;
262        }
263
264        private void loadData() {
265            long now = System.currentTimeMillis();
266            if (LOGD) Log.d(TAG, "Querying for widget events...");
267            if (mCursor != null) {
268                mCursor.close();
269            }
270
271            mCursor = getUpcomingInstancesCursor(
272                    mContext.getContentResolver(), SEARCH_DURATION, now);
273            MarkedEvents markedEvents = buildMarkedEvents(mCursor, now);
274            mModel = buildAppWidgetModel(mContext, mCursor, markedEvents, now);
275            long triggerTime = calculateUpdateTime(mCursor, markedEvents);
276            // Schedule an alarm to wake ourselves up for the next update.  We also cancel
277            // all existing wake-ups because PendingIntents don't match against extras.
278
279            // If no next-update calculated, or bad trigger time in past, schedule
280            // update about six hours from now.
281            if (triggerTime == -1 || triggerTime < now) {
282                if (LOGD) Log.w(TAG, "Encountered bad trigger time " +
283                        formatDebugTime(triggerTime, now));
284                triggerTime = now + UPDATE_NO_EVENTS;
285            }
286
287            AlarmManager am = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
288            PendingIntent pendingUpdate = CalendarAppWidgetProvider.getUpdateIntent(mContext);
289
290            am.cancel(pendingUpdate);
291            am.set(AlarmManager.RTC, triggerTime, pendingUpdate);
292            if (LOGD) Log.d(TAG, "Scheduled next update at " + formatDebugTime(triggerTime, now));
293        }
294
295        /**
296         * Query across all calendars for upcoming event instances from now until
297         * some time in the future.
298         *
299         * Widen the time range that we query by one day on each end so that we can
300         * catch all-day events. All-day events are stored starting at midnight in
301         * UTC but should be included in the list of events starting at midnight
302         * local time. This may fetch more events than we actually want, so we
303         * filter them out later.
304         *
305         * @param resolver {@link ContentResolver} to use when querying
306         *            {@link Instances#CONTENT_URI}.
307         * @param searchDuration Distance into the future to look for event
308         *            instances, in milliseconds.
309         * @param now Current system time to use for this update, possibly from
310         *            {@link System#currentTimeMillis()}.
311         */
312        private Cursor getUpcomingInstancesCursor(ContentResolver resolver,
313                long searchDuration, long now) {
314            // Search for events from now until some time in the future
315
316            // Add a day on either side to catch all-day events
317            long begin = now - DateUtils.DAY_IN_MILLIS;
318            long end = now + searchDuration + DateUtils.DAY_IN_MILLIS;
319
320            Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI,
321                    String.format("%d/%d", begin, end));
322
323            Cursor cursor = resolver.query(uri, EVENT_PROJECTION,
324                    EVENT_SELECTION, null, EVENT_SORT_ORDER);
325
326            // Start managing the cursor ourselves
327            MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor);
328            cursor.close();
329
330            return matrixCursor;
331        }
332
333        /**
334         * Walk the given instances cursor and build a list of marked events to be
335         * used when updating the widget. This structure is also used to check if
336         * updates are needed.
337         *
338         * @param cursor Valid cursor across {@link Instances#CONTENT_URI}.
339         * @param watchEventIds Specific events to watch for, setting
340         *            {@link MarkedEvents#watchFound} if found during marking.
341         * @param now Current system time to use for this update, possibly from
342         *            {@link System#currentTimeMillis()}
343         */
344        @VisibleForTesting
345        protected static MarkedEvents buildMarkedEvents(Cursor cursor, long now) {
346            MarkedEvents events = new MarkedEvents();
347            final Time recycle = new Time();
348
349            cursor.moveToPosition(-1);
350            while (cursor.moveToNext()) {
351                int row = cursor.getPosition();
352                long eventId = cursor.getLong(INDEX_EVENT_ID);
353                long start = cursor.getLong(INDEX_BEGIN);
354                long end = cursor.getLong(INDEX_END);
355
356                boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0;
357
358                if (LOGD) {
359                    Log.d(TAG, "Row #" + row + " allDay:" + allDay + " start:" + start
360                            + " end:" + end + " eventId:" + eventId);
361                }
362
363                // Adjust all-day times into local timezone
364                if (allDay) {
365                    start = convertUtcToLocal(recycle, start);
366                    end = convertUtcToLocal(recycle, end);
367                }
368
369                if (end < now) {
370                    // we might get some extra events when querying, in order to
371                    // deal with all-day events
372                    continue;
373                }
374
375                boolean inProgress = now < end && now > start;
376
377                // Skip events that have already passed their flip times
378                long eventFlip = getEventFlip(cursor, start, end, allDay);
379                if (LOGD) Log.d(TAG, "Calculated flip time " + formatDebugTime(eventFlip, now));
380                if (eventFlip < now) {
381                    continue;
382                }
383
384//                /* Scan through the events with the following logic:
385//                 *   Rule #1 Show A) all the events that are in progress including
386//                 *     all day events and B) the next upcoming event and any events
387//                 *     with the same start time.
388//                 *
389//                 *   Rule #2 If there are no events in progress, show A) the next
390//                 *     upcoming event and B) any events with the same start time.
391//                 *
392//                 *   Rule #3 If no events start at the same time at A in rule 2,
393//                 *     show A) the next upcoming event and B) the following upcoming
394//                 *     event + any events with the same start time.
395//                 */
396//                if (inProgress) {
397//                    // events for part A of Rule #1
398//                    events.markedIds.add(row);
399//                    events.inProgressCount++;
400//                    if (events.firstTime == -1) {
401//                        events.firstTime = start;
402//                    }
403//                } else {
404//                    if (events.primaryCount == 0) {
405//                        // first upcoming event
406//                        events.markedIds.add(row);
407//                        events.primaryTime = start;
408//                        events.primaryCount++;
409//                        if (events.firstTime == -1) {
410//                            events.firstTime = start;
411//                        }
412//                    } else if (events.primaryTime == start) {
413//                        // any events with same start time as first upcoming event
414//                        events.markedIds.add(row);
415//                        events.primaryCount++;
416//                    } else if (events.markedIds.size() == 1) {
417//                        // only one upcoming event, so we take the next upcoming
418//                        events.markedIds.add(row);
419//                        events.secondaryTime = start;
420//                        events.secondaryCount++;
421//                    } else if (events.secondaryCount > 0
422//                            && events.secondaryTime == start) {
423//                        // any events with same start time as next upcoming
424//                        events.markedIds.add(row);
425//                        events.secondaryCount++;
426//                    } else {
427//                        // looks like we're done
428//                        break;
429//                    }
430//                }
431
432                events.markedIds.add(row);
433            }
434            return events;
435        }
436
437        @VisibleForTesting
438        protected static CalendarAppWidgetModel buildAppWidgetModel(
439                Context context, Cursor cursor, MarkedEvents events, long currentTime) {
440            int eventCount = events.markedIds.size();
441            CalendarAppWidgetModel model = new CalendarAppWidgetModel(eventCount);
442            Time time = new Time();
443            time.set(currentTime);
444            time.monthDay++;
445            time.hour = 0;
446            time.minute = 0;
447            time.second = 0;
448            long startOfNextDay = time.normalize(true);
449
450            time.set(currentTime);
451
452            // Calendar header
453            String dayOfWeek = DateUtils.getDayOfWeekString(
454                    time.weekDay + 1, DateUtils.LENGTH_MEDIUM).toUpperCase();
455
456            model.dayOfWeek = dayOfWeek;
457            model.dayOfMonth = Integer.toString(time.monthDay);
458
459            int i = 0;
460            for (Integer id : events.markedIds) {
461                populateEvent(context, cursor, id, model, time, i, true,
462                        startOfNextDay, currentTime);
463                i++;
464            }
465
466            return model;
467        }
468
469        /**
470         * Figure out the next time we should push widget updates, usually the time
471         * calculated by {@link #getEventFlip(Cursor, long, long, boolean)}.
472         *
473         * @param cursor Valid cursor on {@link Instances#CONTENT_URI}
474         * @param events {@link MarkedEvents} parsed from the cursor
475         */
476        private long calculateUpdateTime(Cursor cursor, MarkedEvents events) {
477            long result = -1;
478            if (!events.markedIds.isEmpty()) {
479                cursor.moveToPosition(events.markedIds.get(0));
480                long start = cursor.getLong(INDEX_BEGIN);
481                long end = cursor.getLong(INDEX_END);
482                boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0;
483
484                // Adjust all-day times into local timezone
485                if (allDay) {
486                    final Time recycle = new Time();
487                    start = convertUtcToLocal(recycle, start);
488                    end = convertUtcToLocal(recycle, end);
489                }
490
491                result = getEventFlip(cursor, start, end, allDay);
492
493                // Make sure an update happens at midnight or earlier
494                long midnight = getNextMidnightTimeMillis();
495                result = Math.min(midnight, result);
496            }
497            return result;
498        }
499
500        private static long getNextMidnightTimeMillis() {
501            Time time = new Time();
502            time.setToNow();
503            time.monthDay++;
504            time.hour = 0;
505            time.minute = 0;
506            time.second = 0;
507            long midnight = time.normalize(true);
508            return midnight;
509        }
510
511        /**
512         * Format given time for debugging output.
513         *
514         * @param unixTime Target time to report.
515         * @param now Current system time from {@link System#currentTimeMillis()}
516         *            for calculating time difference.
517         */
518        static private String formatDebugTime(long unixTime, long now) {
519            Time time = new Time();
520            time.set(unixTime);
521
522            long delta = unixTime - now;
523            if (delta > DateUtils.MINUTE_IN_MILLIS) {
524                delta /= DateUtils.MINUTE_IN_MILLIS;
525                return String.format("[%d] %s (%+d mins)", unixTime,
526                        time.format("%H:%M:%S"), delta);
527            } else {
528                delta /= DateUtils.SECOND_IN_MILLIS;
529                return String.format("[%d] %s (%+d secs)", unixTime,
530                        time.format("%H:%M:%S"), delta);
531            }
532        }
533
534        /**
535         * Convert given UTC time into current local time.
536         *
537         * @param recycle Time object to recycle, otherwise null.
538         * @param utcTime Time to convert, in UTC.
539         */
540        static private long convertUtcToLocal(Time recycle, long utcTime) {
541            if (recycle == null) {
542                recycle = new Time();
543            }
544            recycle.timezone = Time.TIMEZONE_UTC;
545            recycle.set(utcTime);
546            recycle.timezone = TimeZone.getDefault().getID();
547            return recycle.normalize(true);
548        }
549
550        /**
551         * Calculate flipping point for the given event; when we should hide this
552         * event and show the next one. This is defined as the end time of the
553         * event.
554         *
555         * @param start Event start time in local timezone.
556         * @param end Event end time in local timezone.
557         */
558        static private long getEventFlip(Cursor cursor, long start, long end, boolean allDay) {
559            return end;
560        }
561
562        static void updateTextView(RemoteViews views, int id, int visibility, String string) {
563            views.setViewVisibility(id, visibility);
564            if (visibility == View.VISIBLE) {
565                views.setTextViewText(id, string);
566            }
567        }
568
569        /**
570         * Pulls the information for a single event from the cursor and populates
571         * the corresponding model object with the data.
572         *
573         * @param context a Context to use for accessing resources
574         * @param cursor the cursor to retrieve the data from
575         * @param rowId the ID of the row to retrieve
576         * @param model the model object to populate
577         * @param recycle a Time instance to recycle
578         * @param eventIndex which event index in the model to populate
579         * @param showTitleLocation whether or not to show the title and location
580         * @param startOfNextDay the beginning of the next day
581         * @param currentTime the current time
582         */
583        static private void populateEvent(Context context, Cursor cursor, int rowId,
584                CalendarAppWidgetModel model, Time recycle, int eventIndex,
585                boolean showTitleLocation, long startOfNextDay, long currentTime) {
586            cursor.moveToPosition(rowId);
587
588            // When
589            boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0;
590            long start = cursor.getLong(INDEX_BEGIN);
591            long end = cursor.getLong(INDEX_END);
592            if (allDay) {
593                start = convertUtcToLocal(recycle, start);
594                end = convertUtcToLocal(recycle, end);
595            }
596
597            boolean eventIsInProgress = start <= currentTime && end > currentTime;
598            boolean eventIsToday = start < startOfNextDay;
599            boolean eventIsTomorrow = !eventIsToday && !eventIsInProgress
600                    && (start < (startOfNextDay + DateUtils.DAY_IN_MILLIS));
601
602            // Compute a human-readable string for the start time of the event
603            String whenString;
604            if (eventIsInProgress && allDay) {
605                // All day events for the current day display as just "Today"
606                whenString = context.getString(R.string.today);
607            } else if (eventIsTomorrow && allDay) {
608                // All day events for the next day display as just "Tomorrow"
609                whenString = context.getString(R.string.tomorrow);
610            } else {
611                int flags = DateUtils.FORMAT_ABBREV_ALL;
612                if (allDay) {
613                    flags |= DateUtils.FORMAT_UTC;
614                } else {
615                    flags |= DateUtils.FORMAT_SHOW_TIME;
616                    if (DateFormat.is24HourFormat(context)) {
617                        flags |= DateUtils.FORMAT_24HOUR;
618                    }
619                }
620                // Show day of the week if not today or tomorrow
621                if (!eventIsTomorrow && !eventIsToday) {
622                    flags |= DateUtils.FORMAT_SHOW_WEEKDAY;
623                }
624                whenString = DateUtils.formatDateRange(context, start, start, flags);
625                // TODO better i18n formatting
626                if (eventIsTomorrow) {
627                    whenString += (", ");
628                    whenString += context.getString(R.string.tomorrow);
629                } else if (eventIsInProgress) {
630                    whenString += " (";
631                    whenString += context.getString(R.string.in_progress);
632                    whenString += ")";
633                }
634            }
635
636            model.eventInfos[eventIndex].start = start;
637            model.eventInfos[eventIndex].when = whenString;
638            model.eventInfos[eventIndex].visibWhen = View.VISIBLE;
639
640            if (showTitleLocation) {
641                // What
642                String titleString = cursor.getString(INDEX_TITLE);
643                if (TextUtils.isEmpty(titleString)) {
644                    titleString = context.getString(R.string.no_title_label);
645                }
646                model.eventInfos[eventIndex].title = titleString;
647                model.eventInfos[eventIndex].visibTitle = View.VISIBLE;
648
649                // Where
650                String whereString = cursor.getString(INDEX_EVENT_LOCATION);
651                if (!TextUtils.isEmpty(whereString)) {
652                    model.eventInfos[eventIndex].visibWhere = View.VISIBLE;
653                    model.eventInfos[eventIndex].where = whereString;
654                } else {
655                    model.eventInfos[eventIndex].visibWhere = View.GONE;
656                }
657                if (LOGD) Log.d(TAG, " Title:" + titleString + " Where:" + whereString);
658            }
659        }
660    }
661}
662