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