1/*
2 * Copyright (C) 2008 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.alerts;
18
19import android.app.Notification;
20import android.app.NotificationManager;
21import android.app.Service;
22import android.content.ContentResolver;
23import android.content.ContentUris;
24import android.content.ContentValues;
25import android.content.Context;
26import android.content.Intent;
27import android.content.SharedPreferences;
28import android.database.Cursor;
29import android.net.Uri;
30import android.os.Bundle;
31import android.os.Handler;
32import android.os.HandlerThread;
33import android.os.IBinder;
34import android.os.Looper;
35import android.os.Message;
36import android.os.Process;
37import android.provider.CalendarContract;
38import android.provider.CalendarContract.Attendees;
39import android.provider.CalendarContract.CalendarAlerts;
40import android.text.TextUtils;
41import android.text.format.DateUtils;
42import android.text.format.Time;
43import android.util.Log;
44
45import com.android.calendar.GeneralPreferences;
46import com.android.calendar.OtherPreferences;
47import com.android.calendar.R;
48import com.android.calendar.Utils;
49
50import java.util.ArrayList;
51import java.util.HashMap;
52import java.util.List;
53import java.util.TimeZone;
54
55/**
56 * This service is used to handle calendar event reminders.
57 */
58public class AlertService extends Service {
59    static final boolean DEBUG = true;
60    private static final String TAG = "AlertService";
61
62    private volatile Looper mServiceLooper;
63    private volatile ServiceHandler mServiceHandler;
64
65    static final String[] ALERT_PROJECTION = new String[] {
66        CalendarAlerts._ID,                     // 0
67        CalendarAlerts.EVENT_ID,                // 1
68        CalendarAlerts.STATE,                   // 2
69        CalendarAlerts.TITLE,                   // 3
70        CalendarAlerts.EVENT_LOCATION,          // 4
71        CalendarAlerts.SELF_ATTENDEE_STATUS,    // 5
72        CalendarAlerts.ALL_DAY,                 // 6
73        CalendarAlerts.ALARM_TIME,              // 7
74        CalendarAlerts.MINUTES,                 // 8
75        CalendarAlerts.BEGIN,                   // 9
76        CalendarAlerts.END,                     // 10
77        CalendarAlerts.DESCRIPTION,             // 11
78    };
79
80    private static final int ALERT_INDEX_ID = 0;
81    private static final int ALERT_INDEX_EVENT_ID = 1;
82    private static final int ALERT_INDEX_STATE = 2;
83    private static final int ALERT_INDEX_TITLE = 3;
84    private static final int ALERT_INDEX_EVENT_LOCATION = 4;
85    private static final int ALERT_INDEX_SELF_ATTENDEE_STATUS = 5;
86    private static final int ALERT_INDEX_ALL_DAY = 6;
87    private static final int ALERT_INDEX_ALARM_TIME = 7;
88    private static final int ALERT_INDEX_MINUTES = 8;
89    private static final int ALERT_INDEX_BEGIN = 9;
90    private static final int ALERT_INDEX_END = 10;
91    private static final int ALERT_INDEX_DESCRIPTION = 11;
92
93    private static final String ACTIVE_ALERTS_SELECTION = "(" + CalendarAlerts.STATE + "=? OR "
94            + CalendarAlerts.STATE + "=?) AND " + CalendarAlerts.ALARM_TIME + "<=";
95
96    private static final String[] ACTIVE_ALERTS_SELECTION_ARGS = new String[] {
97            Integer.toString(CalendarAlerts.STATE_FIRED),
98            Integer.toString(CalendarAlerts.STATE_SCHEDULED)
99    };
100
101    private static final String ACTIVE_ALERTS_SORT = "begin DESC, end DESC";
102
103    private static final String DISMISS_OLD_SELECTION = CalendarAlerts.END + "<? AND "
104            + CalendarAlerts.STATE + "=?";
105
106    private static final int MINUTE_MS = 60 * 1000;
107
108    // The grace period before changing a notification's priority bucket.
109    private static final int MIN_DEPRIORITIZE_GRACE_PERIOD_MS = 15 * MINUTE_MS;
110
111    // Hard limit to the number of notifications displayed.
112    public static final int MAX_NOTIFICATIONS = 20;
113
114    // Shared prefs key for storing whether the EVENT_REMINDER event from the provider
115    // was ever received.  Some OEMs modified this provider broadcast, so we had to
116    // do the alarm scheduling here in the app, for the unbundled app's reminders to work.
117    // If the EVENT_REMINDER event was ever received, we know we can skip our secondary
118    // alarm scheduling.
119    private static final String PROVIDER_REMINDER_PREF_KEY =
120            "preference_received_provider_reminder_broadcast";
121    private static Boolean sReceivedProviderReminderBroadcast = null;
122
123    // Added wrapper for testing
124    public static class NotificationWrapper {
125        Notification mNotification;
126        long mEventId;
127        long mBegin;
128        long mEnd;
129        ArrayList<NotificationWrapper> mNw;
130
131        public NotificationWrapper(Notification n, int notificationId, long eventId,
132                long startMillis, long endMillis, boolean doPopup) {
133            mNotification = n;
134            mEventId = eventId;
135            mBegin = startMillis;
136            mEnd = endMillis;
137
138            // popup?
139            // notification id?
140        }
141
142        public NotificationWrapper(Notification n) {
143            mNotification = n;
144        }
145
146        public void add(NotificationWrapper nw) {
147            if (mNw == null) {
148                mNw = new ArrayList<NotificationWrapper>();
149            }
150            mNw.add(nw);
151        }
152    }
153
154    // Added wrapper for testing
155    public static class NotificationMgrWrapper extends NotificationMgr {
156        NotificationManager mNm;
157
158        public NotificationMgrWrapper(NotificationManager nm) {
159            mNm = nm;
160        }
161
162        @Override
163        public void cancel(int id) {
164            mNm.cancel(id);
165        }
166
167        @Override
168        public void notify(int id, NotificationWrapper nw) {
169            mNm.notify(id, nw.mNotification);
170        }
171    }
172
173    void processMessage(Message msg) {
174        Bundle bundle = (Bundle) msg.obj;
175
176        // On reboot, update the notification bar with the contents of the
177        // CalendarAlerts table.
178        String action = bundle.getString("action");
179        if (DEBUG) {
180            Log.d(TAG, bundle.getLong(android.provider.CalendarContract.CalendarAlerts.ALARM_TIME)
181                    + " Action = " + action);
182        }
183
184        // Some OEMs had changed the provider's EVENT_REMINDER broadcast to their own event,
185        // which broke our unbundled app's reminders.  So we added backup alarm scheduling to the
186        // app, but we know we can turn it off if we ever receive the EVENT_REMINDER broadcast.
187        boolean providerReminder = action.equals(
188                android.provider.CalendarContract.ACTION_EVENT_REMINDER);
189        if (providerReminder) {
190            if (sReceivedProviderReminderBroadcast == null) {
191                sReceivedProviderReminderBroadcast = Utils.getSharedPreference(this,
192                        PROVIDER_REMINDER_PREF_KEY, false);
193            }
194
195            if (!sReceivedProviderReminderBroadcast) {
196                sReceivedProviderReminderBroadcast = true;
197                Log.d(TAG, "Setting key " + PROVIDER_REMINDER_PREF_KEY + " to: true");
198                Utils.setSharedPreference(this, PROVIDER_REMINDER_PREF_KEY, true);
199            }
200        }
201
202        if (providerReminder ||
203                action.equals(Intent.ACTION_PROVIDER_CHANGED) ||
204                action.equals(android.provider.CalendarContract.ACTION_EVENT_REMINDER) ||
205                action.equals(AlertReceiver.EVENT_REMINDER_APP_ACTION) ||
206                action.equals(Intent.ACTION_LOCALE_CHANGED)) {
207
208            // b/7652098: Add a delay after the provider-changed event before refreshing
209            // notifications to help issue with the unbundled app installed on HTC having
210            // stale notifications.
211            if (action.equals(Intent.ACTION_PROVIDER_CHANGED)) {
212                try {
213                    Thread.sleep(5000);
214                } catch (Exception e) {
215                    // Ignore.
216                }
217            }
218
219            // If we dismissed a notification for a new event, then we need to sync the cache when
220            // an ACTION_PROVIDER_CHANGED event has been sent. Unfortunately, the data provider
221            // has a delay of CalendarProvider2.SYNC_UPDATE_BROADCAST_TIMEOUT_MILLIS (ie. 30 sec.)
222            // until it notifies us that the sync adapter has finished.
223            // TODO(psliwowski): Find a quicker way to be notified when the data provider has the
224            // syncId for event.
225            GlobalDismissManager.syncSenderDismissCache(this);
226            updateAlertNotification(this);
227        } else if (action.equals(Intent.ACTION_BOOT_COMPLETED)) {
228            // The provider usually initiates this setting up of alarms on startup,
229            // but there was a bug (b/7221716) where a race condition caused this step to be
230            // skipped, resulting in missed alarms.  This is a stopgap to minimize this bug
231            // for devices that don't have the provider fix, by initiating this a 2nd time here.
232            // However, it would still theoretically be possible to hit the race condition
233            // the 2nd time and still miss alarms.
234            //
235            // TODO: Remove this when the provider fix is rolled out everywhere.
236            Intent intent = new Intent();
237            intent.setClass(this, InitAlarmsService.class);
238            startService(intent);
239        } else if (action.equals(Intent.ACTION_TIME_CHANGED)) {
240            doTimeChanged();
241        } else if (action.equals(AlertReceiver.ACTION_DISMISS_OLD_REMINDERS)) {
242            dismissOldAlerts(this);
243        } else {
244            Log.w(TAG, "Invalid action: " + action);
245        }
246
247        // Schedule the alarm for the next upcoming reminder, if not done by the provider.
248        if (sReceivedProviderReminderBroadcast == null || !sReceivedProviderReminderBroadcast) {
249            Log.d(TAG, "Scheduling next alarm with AlarmScheduler. "
250                   + "sEventReminderReceived: " + sReceivedProviderReminderBroadcast);
251            AlarmScheduler.scheduleNextAlarm(this);
252        }
253    }
254
255    static void dismissOldAlerts(Context context) {
256        ContentResolver cr = context.getContentResolver();
257        final long currentTime = System.currentTimeMillis();
258        ContentValues vals = new ContentValues();
259        vals.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED);
260        cr.update(CalendarAlerts.CONTENT_URI, vals, DISMISS_OLD_SELECTION, new String[] {
261                Long.toString(currentTime), Integer.toString(CalendarAlerts.STATE_SCHEDULED)
262        });
263    }
264
265    static boolean updateAlertNotification(Context context) {
266        ContentResolver cr = context.getContentResolver();
267        NotificationMgr nm = new NotificationMgrWrapper(
268                (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
269        final long currentTime = System.currentTimeMillis();
270        SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context);
271
272        if (DEBUG) {
273            Log.d(TAG, "Beginning updateAlertNotification");
274        }
275
276        if (!prefs.getBoolean(GeneralPreferences.KEY_ALERTS, true)) {
277            if (DEBUG) {
278                Log.d(TAG, "alert preference is OFF");
279            }
280
281            // If we shouldn't be showing notifications cancel any existing ones
282            // and return.
283            nm.cancelAll();
284            return true;
285        }
286
287        // Sync CalendarAlerts with global dismiss cache before query it
288        GlobalDismissManager.syncReceiverDismissCache(context);
289        Cursor alertCursor = cr.query(CalendarAlerts.CONTENT_URI, ALERT_PROJECTION,
290                (ACTIVE_ALERTS_SELECTION + currentTime), ACTIVE_ALERTS_SELECTION_ARGS,
291                ACTIVE_ALERTS_SORT);
292
293        if (alertCursor == null || alertCursor.getCount() == 0) {
294            if (alertCursor != null) {
295                alertCursor.close();
296            }
297
298            if (DEBUG) Log.d(TAG, "No fired or scheduled alerts");
299            nm.cancelAll();
300            return false;
301        }
302
303        return generateAlerts(context, nm, AlertUtils.createAlarmManager(context), prefs,
304                alertCursor, currentTime, MAX_NOTIFICATIONS);
305    }
306
307    public static boolean generateAlerts(Context context, NotificationMgr nm,
308            AlarmManagerInterface alarmMgr, SharedPreferences prefs, Cursor alertCursor,
309            final long currentTime, final int maxNotifications) {
310        if (DEBUG) {
311            Log.d(TAG, "alertCursor count:" + alertCursor.getCount());
312        }
313
314        // Process the query results and bucketize events.
315        ArrayList<NotificationInfo> highPriorityEvents = new ArrayList<NotificationInfo>();
316        ArrayList<NotificationInfo> mediumPriorityEvents = new ArrayList<NotificationInfo>();
317        ArrayList<NotificationInfo> lowPriorityEvents = new ArrayList<NotificationInfo>();
318        int numFired = processQuery(alertCursor, context, currentTime, highPriorityEvents,
319                mediumPriorityEvents, lowPriorityEvents);
320
321        if (highPriorityEvents.size() + mediumPriorityEvents.size()
322                + lowPriorityEvents.size() == 0) {
323            nm.cancelAll();
324            return true;
325        }
326
327        long nextRefreshTime = Long.MAX_VALUE;
328        int currentNotificationId = 1;
329        NotificationPrefs notificationPrefs = new NotificationPrefs(context, prefs,
330                (numFired == 0));
331
332        // If there are more high/medium priority events than we can show, bump some to
333        // the low priority digest.
334        redistributeBuckets(highPriorityEvents, mediumPriorityEvents, lowPriorityEvents,
335                maxNotifications);
336
337        // Post the individual higher priority events (future and recently started
338        // concurrent events).  Order these so that earlier start times appear higher in
339        // the notification list.
340        for (int i = 0; i < highPriorityEvents.size(); i++) {
341            NotificationInfo info = highPriorityEvents.get(i);
342            String summaryText = AlertUtils.formatTimeLocation(context, info.startMillis,
343                    info.allDay, info.location);
344            postNotification(info, summaryText, context, true, notificationPrefs, nm,
345                    currentNotificationId++);
346
347            // Keep concurrent events high priority (to appear higher in the notification list)
348            // until 15 minutes into the event.
349            nextRefreshTime = Math.min(nextRefreshTime, getNextRefreshTime(info, currentTime));
350        }
351
352        // Post the medium priority events (concurrent events that started a while ago).
353        // Order these so more recent start times appear higher in the notification list.
354        //
355        // TODO: Post these with the same notification priority level as the higher priority
356        // events, so that all notifications will be co-located together.
357        for (int i = mediumPriorityEvents.size() - 1; i >= 0; i--) {
358            NotificationInfo info = mediumPriorityEvents.get(i);
359            // TODO: Change to a relative time description like: "Started 40 minutes ago".
360            // This requires constant refreshing to the message as time goes.
361            String summaryText = AlertUtils.formatTimeLocation(context, info.startMillis,
362                    info.allDay, info.location);
363            postNotification(info, summaryText, context, false, notificationPrefs, nm,
364                    currentNotificationId++);
365
366            // Refresh when concurrent event ends so it will drop into the expired digest.
367            nextRefreshTime = Math.min(nextRefreshTime, getNextRefreshTime(info, currentTime));
368        }
369
370        // Post the low priority events as 1 combined notification.
371        int numLowPriority = lowPriorityEvents.size();
372        if (numLowPriority > 0) {
373            String expiredDigestTitle = getDigestTitle(lowPriorityEvents);
374            NotificationWrapper notification;
375            if (numLowPriority == 1) {
376                // If only 1 expired event, display an "old-style" basic alert.
377                NotificationInfo info = lowPriorityEvents.get(0);
378                String summaryText = AlertUtils.formatTimeLocation(context, info.startMillis,
379                        info.allDay, info.location);
380                notification = AlertReceiver.makeBasicNotification(context, info.eventName,
381                        summaryText, info.startMillis, info.endMillis, info.eventId,
382                        AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID, false,
383                        Notification.PRIORITY_MIN);
384            } else {
385                // Multiple expired events are listed in a digest.
386                notification = AlertReceiver.makeDigestNotification(context,
387                    lowPriorityEvents, expiredDigestTitle, false);
388            }
389
390            // Add options for a quiet update.
391            addNotificationOptions(notification, true, expiredDigestTitle,
392                    notificationPrefs.getDefaultVibrate(),
393                    notificationPrefs.getRingtoneAndSilence(),
394                    false); /* Do not show the LED for the expired events. */
395
396            if (DEBUG) {
397              Log.d(TAG, "Quietly posting digest alarm notification, numEvents:" + numLowPriority
398                      + ", notificationId:" + AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID);
399          }
400
401            // Post the new notification for the group.
402            nm.notify(AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID, notification);
403        } else {
404            nm.cancel(AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID);
405            if (DEBUG) {
406                Log.d(TAG, "No low priority events, canceling the digest notification.");
407            }
408        }
409
410        // Remove the notifications that are hanging around from the previous refresh.
411        if (currentNotificationId <= maxNotifications) {
412            nm.cancelAllBetween(currentNotificationId, maxNotifications);
413            if (DEBUG) {
414                Log.d(TAG, "Canceling leftover notification IDs " + currentNotificationId + "-"
415                        + maxNotifications);
416            }
417        }
418
419        // Schedule the next silent refresh time so notifications will change
420        // buckets (eg. drop into expired digest, etc).
421        if (nextRefreshTime < Long.MAX_VALUE && nextRefreshTime > currentTime) {
422            AlertUtils.scheduleNextNotificationRefresh(context, alarmMgr, nextRefreshTime);
423            if (DEBUG) {
424                long minutesBeforeRefresh = (nextRefreshTime - currentTime) / MINUTE_MS;
425                Time time = new Time();
426                time.set(nextRefreshTime);
427                String msg = String.format("Scheduling next notification refresh in %d min at: "
428                        + "%d:%02d", minutesBeforeRefresh, time.hour, time.minute);
429                Log.d(TAG, msg);
430            }
431        } else if (nextRefreshTime < currentTime) {
432            Log.e(TAG, "Illegal state: next notification refresh time found to be in the past.");
433        }
434
435        // Flushes old fired alerts from internal storage, if needed.
436        AlertUtils.flushOldAlertsFromInternalStorage(context);
437
438        return true;
439    }
440
441    /**
442     * Redistributes events in the priority lists based on the max # of notifications we
443     * can show.
444     */
445    static void redistributeBuckets(ArrayList<NotificationInfo> highPriorityEvents,
446            ArrayList<NotificationInfo> mediumPriorityEvents,
447            ArrayList<NotificationInfo> lowPriorityEvents, int maxNotifications) {
448
449        // If too many high priority alerts, shift the remaining high priority and all the
450        // medium priority ones to the low priority bucket.  Note that order is important
451        // here; these lists are sorted by descending start time.  Maintain that ordering
452        // so posted notifications are in the expected order.
453        if (highPriorityEvents.size() > maxNotifications) {
454            // Move mid-priority to the digest.
455            lowPriorityEvents.addAll(0, mediumPriorityEvents);
456
457            // Move the rest of the high priority ones (latest ones) to the digest.
458            List<NotificationInfo> itemsToMoveSublist = highPriorityEvents.subList(
459                    0, highPriorityEvents.size() - maxNotifications);
460            // TODO: What order for high priority in the digest?
461            lowPriorityEvents.addAll(0, itemsToMoveSublist);
462            if (DEBUG) {
463                logEventIdsBumped(mediumPriorityEvents, itemsToMoveSublist);
464            }
465            mediumPriorityEvents.clear();
466            // Clearing the sublist view removes the items from the highPriorityEvents list.
467            itemsToMoveSublist.clear();
468        }
469
470        // Bump the medium priority events if necessary.
471        if (mediumPriorityEvents.size() + highPriorityEvents.size() > maxNotifications) {
472            int spaceRemaining = maxNotifications - highPriorityEvents.size();
473
474            // Reached our max, move the rest to the digest.  Since these are concurrent
475            // events, we move the ones with the earlier start time first since they are
476            // further in the past and less important.
477            List<NotificationInfo> itemsToMoveSublist = mediumPriorityEvents.subList(
478                    spaceRemaining, mediumPriorityEvents.size());
479            lowPriorityEvents.addAll(0, itemsToMoveSublist);
480            if (DEBUG) {
481                logEventIdsBumped(itemsToMoveSublist, null);
482            }
483
484            // Clearing the sublist view removes the items from the mediumPriorityEvents list.
485            itemsToMoveSublist.clear();
486        }
487    }
488
489    private static void logEventIdsBumped(List<NotificationInfo> list1,
490            List<NotificationInfo> list2) {
491        StringBuilder ids = new StringBuilder();
492        if (list1 != null) {
493            for (NotificationInfo info : list1) {
494                ids.append(info.eventId);
495                ids.append(",");
496            }
497        }
498        if (list2 != null) {
499            for (NotificationInfo info : list2) {
500                ids.append(info.eventId);
501                ids.append(",");
502            }
503        }
504        if (ids.length() > 0 && ids.charAt(ids.length() - 1) == ',') {
505            ids.setLength(ids.length() - 1);
506        }
507        if (ids.length() > 0) {
508            Log.d(TAG, "Reached max postings, bumping event IDs {" + ids.toString()
509                    + "} to digest.");
510        }
511    }
512
513    private static long getNextRefreshTime(NotificationInfo info, long currentTime) {
514        long startAdjustedForAllDay = info.startMillis;
515        long endAdjustedForAllDay = info.endMillis;
516        if (info.allDay) {
517            Time t = new Time();
518            startAdjustedForAllDay = Utils.convertAlldayUtcToLocal(t, info.startMillis,
519                    Time.getCurrentTimezone());
520            endAdjustedForAllDay = Utils.convertAlldayUtcToLocal(t, info.startMillis,
521                    Time.getCurrentTimezone());
522        }
523
524        // We change an event's priority bucket at 15 minutes into the event or 1/4 event duration.
525        long nextRefreshTime = Long.MAX_VALUE;
526        long gracePeriodCutoff = startAdjustedForAllDay +
527                getGracePeriodMs(startAdjustedForAllDay, endAdjustedForAllDay, info.allDay);
528        if (gracePeriodCutoff > currentTime) {
529            nextRefreshTime = Math.min(nextRefreshTime, gracePeriodCutoff);
530        }
531
532        // ... and at the end (so expiring ones drop into a digest).
533        if (endAdjustedForAllDay > currentTime && endAdjustedForAllDay > gracePeriodCutoff) {
534            nextRefreshTime = Math.min(nextRefreshTime, endAdjustedForAllDay);
535        }
536        return nextRefreshTime;
537    }
538
539    /**
540     * Processes the query results and bucketizes the alerts.
541     *
542     * @param highPriorityEvents This will contain future events, and concurrent events
543     *     that started recently (less than the interval DEPRIORITIZE_GRACE_PERIOD_MS).
544     * @param mediumPriorityEvents This will contain concurrent events that started
545     *     more than DEPRIORITIZE_GRACE_PERIOD_MS ago.
546     * @param lowPriorityEvents Will contain events that have ended.
547     * @return Returns the number of new alerts to fire.  If this is 0, it implies
548     *     a quiet update.
549     */
550    static int processQuery(final Cursor alertCursor, final Context context,
551            final long currentTime, ArrayList<NotificationInfo> highPriorityEvents,
552            ArrayList<NotificationInfo> mediumPriorityEvents,
553            ArrayList<NotificationInfo> lowPriorityEvents) {
554        // Experimental reminder setting to only remind for events that have
555        // been responded to with "yes" or "maybe".
556        String skipRemindersPref = Utils.getSharedPreference(context,
557                OtherPreferences.KEY_OTHER_REMINDERS_RESPONDED, "");
558        // Skip no-response events if the "Skip Reminders" preference has the second option,
559        // "If declined or not responded", is selected.
560        // Note that by default, the first option will be selected, so this will be false.
561        boolean remindRespondedOnly = skipRemindersPref.equals(context.getResources().
562                getStringArray(R.array.preferences_skip_reminders_values)[1]);
563        // Experimental reminder setting to silence reminders when they are
564        // during the pre-defined quiet hours.
565        boolean useQuietHours = Utils.getSharedPreference(context,
566                OtherPreferences.KEY_OTHER_QUIET_HOURS, false);
567        // Note that the start time may be either before or after the end time,
568        // depending on whether quiet hours cross through midnight.
569        int quietHoursStartHour =
570                OtherPreferences.QUIET_HOURS_DEFAULT_START_HOUR;
571        int quietHoursStartMinute =
572                OtherPreferences.QUIET_HOURS_DEFAULT_START_MINUTE;
573        int quietHoursEndHour =
574                OtherPreferences.QUIET_HOURS_DEFAULT_END_HOUR;
575        int quietHoursEndMinute =
576                OtherPreferences.QUIET_HOURS_DEFAULT_END_MINUTE;
577        if (useQuietHours) {
578            quietHoursStartHour = Utils.getSharedPreference(context,
579                    OtherPreferences.KEY_OTHER_QUIET_HOURS_START_HOUR,
580                    OtherPreferences.QUIET_HOURS_DEFAULT_START_HOUR);
581            quietHoursStartMinute = Utils.getSharedPreference(context,
582                    OtherPreferences.KEY_OTHER_QUIET_HOURS_START_MINUTE,
583                    OtherPreferences.QUIET_HOURS_DEFAULT_START_MINUTE);
584            quietHoursEndHour = Utils.getSharedPreference(context,
585                    OtherPreferences.KEY_OTHER_QUIET_HOURS_END_HOUR,
586                    OtherPreferences.QUIET_HOURS_DEFAULT_END_HOUR);
587            quietHoursEndMinute = Utils.getSharedPreference(context,
588                    OtherPreferences.KEY_OTHER_QUIET_HOURS_END_MINUTE,
589                    OtherPreferences.QUIET_HOURS_DEFAULT_END_MINUTE);
590        }
591        Time time = new Time();
592
593        ContentResolver cr = context.getContentResolver();
594        HashMap<Long, NotificationInfo> eventIds = new HashMap<Long, NotificationInfo>();
595        int numFired = 0;
596        try {
597            while (alertCursor.moveToNext()) {
598                final long alertId = alertCursor.getLong(ALERT_INDEX_ID);
599                final long eventId = alertCursor.getLong(ALERT_INDEX_EVENT_ID);
600                final int minutes = alertCursor.getInt(ALERT_INDEX_MINUTES);
601                final String eventName = alertCursor.getString(ALERT_INDEX_TITLE);
602                final String description = alertCursor.getString(ALERT_INDEX_DESCRIPTION);
603                final String location = alertCursor.getString(ALERT_INDEX_EVENT_LOCATION);
604                final int status = alertCursor.getInt(ALERT_INDEX_SELF_ATTENDEE_STATUS);
605                final boolean declined = status == Attendees.ATTENDEE_STATUS_DECLINED;
606                final boolean responded = status != Attendees.ATTENDEE_STATUS_NONE
607                        && status != Attendees.ATTENDEE_STATUS_INVITED;
608                final long beginTime = alertCursor.getLong(ALERT_INDEX_BEGIN);
609                final long endTime = alertCursor.getLong(ALERT_INDEX_END);
610                final Uri alertUri = ContentUris
611                        .withAppendedId(CalendarAlerts.CONTENT_URI, alertId);
612                final long alarmTime = alertCursor.getLong(ALERT_INDEX_ALARM_TIME);
613                boolean forceQuiet = false;
614                if (useQuietHours) {
615                    // Quiet hours have been set.
616                    time.set(alarmTime);
617                    // Check whether the alarm will fire after the quiet hours
618                    // start time and/or before the quiet hours end time.
619                    boolean alarmAfterQuietHoursStart =
620                            (time.hour > quietHoursStartHour ||
621                                    (time.hour == quietHoursStartHour
622                                    && time.minute >= quietHoursStartMinute));
623                    boolean alarmBeforeQuietHoursEnd =
624                            (time.hour < quietHoursEndHour ||
625                                    (time.hour == quietHoursEndHour
626                                    && time.minute <= quietHoursEndMinute));
627                    // Check if quiet hours crosses through midnight, iff:
628                    // start hour is after end hour, or
629                    // start hour is equal to end hour, and start minute is
630                    // after end minute.
631                    // i.e. 22:30 - 06:45; 12:45 - 12:00
632                    //      01:05 - 10:30; 05:00 - 05:30
633                    boolean quietHoursCrossesMidnight =
634                            quietHoursStartHour > quietHoursEndHour ||
635                            (quietHoursStartHour == quietHoursEndHour
636                            && quietHoursStartMinute > quietHoursEndMinute);
637                    if (quietHoursCrossesMidnight) {
638                        // Quiet hours crosses midnight. Alarm should be quiet
639                        // if it's after start time OR before end time.
640                        if (alarmAfterQuietHoursStart ||
641                                alarmBeforeQuietHoursEnd) {
642                            forceQuiet = true;
643                        }
644                    } else {
645                        // Quiet hours doesn't cross midnight. Alarm should be
646                        // quiet if it's after start time AND before end time.
647                        if (alarmAfterQuietHoursStart &&
648                                alarmBeforeQuietHoursEnd) {
649                            forceQuiet = true;
650                        }
651                    }
652                }
653                int state = alertCursor.getInt(ALERT_INDEX_STATE);
654                final boolean allDay = alertCursor.getInt(ALERT_INDEX_ALL_DAY) != 0;
655
656                // Use app local storage to keep track of fired alerts to fix problem of multiple
657                // installed calendar apps potentially causing missed alarms.
658                boolean newAlertOverride = false;
659                if (AlertUtils.BYPASS_DB && ((currentTime - alarmTime) / MINUTE_MS < 1)) {
660                    // To avoid re-firing alerts, only fire if alarmTime is very recent.  Otherwise
661                    // we can get refires for non-dismissed alerts after app installation, or if the
662                    // SharedPrefs was cleared too early.  This means alerts that were timed while
663                    // the phone was off may show up silently in the notification bar.
664                    boolean alreadyFired = AlertUtils.hasAlertFiredInSharedPrefs(context, eventId,
665                            beginTime, alarmTime);
666                    if (!alreadyFired) {
667                        newAlertOverride = true;
668                    }
669                }
670
671                if (DEBUG) {
672                    StringBuilder msgBuilder = new StringBuilder();
673                    msgBuilder.append("alertCursor result: alarmTime:").append(alarmTime)
674                            .append(" alertId:").append(alertId)
675                            .append(" eventId:").append(eventId)
676                            .append(" state: ").append(state)
677                            .append(" minutes:").append(minutes)
678                            .append(" declined:").append(declined)
679                            .append(" responded:").append(responded)
680                            .append(" beginTime:").append(beginTime)
681                            .append(" endTime:").append(endTime)
682                            .append(" allDay:").append(allDay)
683                            .append(" alarmTime:").append(alarmTime)
684                            .append(" forceQuiet:").append(forceQuiet);
685                    if (AlertUtils.BYPASS_DB) {
686                        msgBuilder.append(" newAlertOverride: " + newAlertOverride);
687                    }
688                    Log.d(TAG, msgBuilder.toString());
689                }
690
691                ContentValues values = new ContentValues();
692                int newState = -1;
693                boolean newAlert = false;
694
695                // Uncomment for the behavior of clearing out alerts after the
696                // events ended. b/1880369
697                //
698                // if (endTime < currentTime) {
699                //     newState = CalendarAlerts.DISMISSED;
700                // } else
701
702                // Remove declined events
703                boolean sendAlert = !declined;
704                // Check for experimental reminder settings.
705                if (remindRespondedOnly) {
706                    // If the experimental setting is turned on, then only send
707                    // the alert if you've responded to the event.
708                    sendAlert = sendAlert && responded;
709                }
710                if (sendAlert) {
711                    if (state == CalendarAlerts.STATE_SCHEDULED || newAlertOverride) {
712                        newState = CalendarAlerts.STATE_FIRED;
713                        numFired++;
714                        // If quiet hours are forcing the alarm to be silent,
715                        // keep newAlert as false so it will not make noise.
716                        if (!forceQuiet) {
717                            newAlert = true;
718                        }
719
720                        // Record the received time in the CalendarAlerts table.
721                        // This is useful for finding bugs that cause alarms to be
722                        // missed or delayed.
723                        values.put(CalendarAlerts.RECEIVED_TIME, currentTime);
724                    }
725                } else {
726                    newState = CalendarAlerts.STATE_DISMISSED;
727                }
728
729                // Update row if state changed
730                if (newState != -1) {
731                    values.put(CalendarAlerts.STATE, newState);
732                    state = newState;
733
734                    if (AlertUtils.BYPASS_DB) {
735                        AlertUtils.setAlertFiredInSharedPrefs(context, eventId, beginTime,
736                                alarmTime);
737                    }
738                }
739
740                if (state == CalendarAlerts.STATE_FIRED) {
741                    // Record the time posting to notification manager.
742                    // This is used for debugging missed alarms.
743                    values.put(CalendarAlerts.NOTIFY_TIME, currentTime);
744                }
745
746                // Write row to if anything changed
747                if (values.size() > 0) cr.update(alertUri, values, null, null);
748
749                if (state != CalendarAlerts.STATE_FIRED) {
750                    continue;
751                }
752
753                // TODO: Prefer accepted events in case of ties.
754                NotificationInfo newInfo = new NotificationInfo(eventName, location,
755                        description, beginTime, endTime, eventId, allDay, newAlert);
756
757                // Adjust for all day events to ensure the right bucket.  Don't use the 1/4 event
758                // duration grace period for these.
759                long beginTimeAdjustedForAllDay = beginTime;
760                String tz = null;
761                if (allDay) {
762                    tz = TimeZone.getDefault().getID();
763                    beginTimeAdjustedForAllDay = Utils.convertAlldayUtcToLocal(null, beginTime,
764                            tz);
765                }
766
767                // Handle multiple alerts for the same event ID.
768                if (eventIds.containsKey(eventId)) {
769                    NotificationInfo oldInfo = eventIds.get(eventId);
770                    long oldBeginTimeAdjustedForAllDay = oldInfo.startMillis;
771                    if (allDay) {
772                        oldBeginTimeAdjustedForAllDay = Utils.convertAlldayUtcToLocal(null,
773                                oldInfo.startMillis, tz);
774                    }
775
776                    // Determine whether to replace the previous reminder with this one.
777                    // Query results are sorted so this one will always have a lower start time.
778                    long oldStartInterval = oldBeginTimeAdjustedForAllDay - currentTime;
779                    long newStartInterval = beginTimeAdjustedForAllDay - currentTime;
780                    boolean dropOld;
781                    if (newStartInterval < 0 && oldStartInterval > 0) {
782                        // Use this reminder if this event started recently
783                        dropOld = Math.abs(newStartInterval) < MIN_DEPRIORITIZE_GRACE_PERIOD_MS;
784                    } else {
785                        // ... or if this one has a closer start time.
786                        dropOld = Math.abs(newStartInterval) < Math.abs(oldStartInterval);
787                    }
788
789                    if (dropOld) {
790                        // This is a recurring event that has a more relevant start time,
791                        // drop other reminder in favor of this one.
792                        //
793                        // It will only be present in 1 of these buckets; just remove from
794                        // multiple buckets since this occurrence is rare enough that the
795                        // inefficiency of multiple removals shouldn't be a big deal to
796                        // justify a more complicated data structure.  Expired events don't
797                        // have individual notifications so we don't need to clean that up.
798                        highPriorityEvents.remove(oldInfo);
799                        mediumPriorityEvents.remove(oldInfo);
800                        if (DEBUG) {
801                            Log.d(TAG, "Dropping alert for recurring event ID:" + oldInfo.eventId
802                                    + ", startTime:" + oldInfo.startMillis
803                                    + " in favor of startTime:" + newInfo.startMillis);
804                        }
805                    } else {
806                        // Skip duplicate reminders for the same event instance.
807                        continue;
808                    }
809                }
810
811                // TODO: Prioritize by "primary" calendar
812                eventIds.put(eventId, newInfo);
813                long highPriorityCutoff = currentTime -
814                        getGracePeriodMs(beginTime, endTime, allDay);
815
816                if (beginTimeAdjustedForAllDay > highPriorityCutoff) {
817                    // High priority = future events or events that just started
818                    highPriorityEvents.add(newInfo);
819                } else if (allDay && tz != null && DateUtils.isToday(beginTimeAdjustedForAllDay)) {
820                    // Medium priority = in progress all day events
821                    mediumPriorityEvents.add(newInfo);
822                } else {
823                    lowPriorityEvents.add(newInfo);
824                }
825            }
826            // TODO(psliwowski): move this to account synchronization
827            GlobalDismissManager.processEventIds(context, eventIds.keySet());
828        } finally {
829            if (alertCursor != null) {
830                alertCursor.close();
831            }
832        }
833        return numFired;
834    }
835
836    /**
837     * High priority cutoff should be 1/4 event duration or 15 min, whichever is longer.
838     */
839    private static long getGracePeriodMs(long beginTime, long endTime, boolean allDay) {
840        if (allDay) {
841            // We don't want all day events to be high priority for hours, so automatically
842            // demote these after 15 min.
843            return MIN_DEPRIORITIZE_GRACE_PERIOD_MS;
844        } else {
845            return Math.max(MIN_DEPRIORITIZE_GRACE_PERIOD_MS, ((endTime - beginTime) / 4));
846        }
847    }
848
849    private static String getDigestTitle(ArrayList<NotificationInfo> events) {
850        StringBuilder digestTitle = new StringBuilder();
851        for (NotificationInfo eventInfo : events) {
852            if (!TextUtils.isEmpty(eventInfo.eventName)) {
853                if (digestTitle.length() > 0) {
854                    digestTitle.append(", ");
855                }
856                digestTitle.append(eventInfo.eventName);
857            }
858        }
859        return digestTitle.toString();
860    }
861
862    private static void postNotification(NotificationInfo info, String summaryText,
863            Context context, boolean highPriority, NotificationPrefs prefs,
864            NotificationMgr notificationMgr, int notificationId) {
865        int priorityVal = Notification.PRIORITY_DEFAULT;
866        if (highPriority) {
867            priorityVal = Notification.PRIORITY_HIGH;
868        }
869
870        String tickerText = getTickerText(info.eventName, info.location);
871        NotificationWrapper notification = AlertReceiver.makeExpandingNotification(context,
872                info.eventName, summaryText, info.description, info.startMillis,
873                info.endMillis, info.eventId, notificationId, prefs.getDoPopup(), priorityVal);
874
875        boolean quietUpdate = true;
876        String ringtone = NotificationPrefs.EMPTY_RINGTONE;
877        if (info.newAlert) {
878            quietUpdate = prefs.quietUpdate;
879
880            // If we've already played a ringtone, don't play any more sounds so only
881            // 1 sound per group of notifications.
882            ringtone = prefs.getRingtoneAndSilence();
883        }
884        addNotificationOptions(notification, quietUpdate, tickerText,
885                prefs.getDefaultVibrate(), ringtone,
886                true); /* Show the LED for these non-expired events */
887
888        // Post the notification.
889        notificationMgr.notify(notificationId, notification);
890
891        if (DEBUG) {
892            Log.d(TAG, "Posting individual alarm notification, eventId:" + info.eventId
893                    + ", notificationId:" + notificationId
894                    + (TextUtils.isEmpty(ringtone) ? ", quiet" : ", LOUD")
895                    + (highPriority ? ", high-priority" : ""));
896        }
897    }
898
899    private static String getTickerText(String eventName, String location) {
900        String tickerText = eventName;
901        if (!TextUtils.isEmpty(location)) {
902            tickerText = eventName + " - " + location;
903        }
904        return tickerText;
905    }
906
907    static class NotificationInfo {
908        String eventName;
909        String location;
910        String description;
911        long startMillis;
912        long endMillis;
913        long eventId;
914        boolean allDay;
915        boolean newAlert;
916
917        NotificationInfo(String eventName, String location, String description, long startMillis,
918                long endMillis, long eventId, boolean allDay, boolean newAlert) {
919            this.eventName = eventName;
920            this.location = location;
921            this.description = description;
922            this.startMillis = startMillis;
923            this.endMillis = endMillis;
924            this.eventId = eventId;
925            this.newAlert = newAlert;
926            this.allDay = allDay;
927        }
928    }
929
930    private static void addNotificationOptions(NotificationWrapper nw, boolean quietUpdate,
931            String tickerText, boolean defaultVibrate, String reminderRingtone,
932            boolean showLights) {
933        Notification notification = nw.mNotification;
934        if (showLights) {
935            notification.flags |= Notification.FLAG_SHOW_LIGHTS;
936            notification.defaults |= Notification.DEFAULT_LIGHTS;
937        }
938
939        // Quietly update notification bar. Nothing new. Maybe something just got deleted.
940        if (!quietUpdate) {
941            // Flash ticker in status bar
942            if (!TextUtils.isEmpty(tickerText)) {
943                notification.tickerText = tickerText;
944            }
945
946            // Generate either a pop-up dialog, status bar notification, or
947            // neither. Pop-up dialog and status bar notification may include a
948            // sound, an alert, or both. A status bar notification also includes
949            // a toast.
950            if (defaultVibrate) {
951                notification.defaults |= Notification.DEFAULT_VIBRATE;
952            }
953
954            // Possibly generate a sound. If 'Silent' is chosen, the ringtone
955            // string will be empty.
956            notification.sound = TextUtils.isEmpty(reminderRingtone) ? null : Uri
957                    .parse(reminderRingtone);
958        }
959    }
960
961    /* package */ static class NotificationPrefs {
962        boolean quietUpdate;
963        private Context context;
964        private SharedPreferences prefs;
965
966        // These are lazily initialized, do not access any of the following directly; use getters.
967        private int doPopup = -1;
968        private int defaultVibrate = -1;
969        private String ringtone = null;
970
971        private static final String EMPTY_RINGTONE = "";
972
973        NotificationPrefs(Context context, SharedPreferences prefs, boolean quietUpdate) {
974            this.context = context;
975            this.prefs = prefs;
976            this.quietUpdate = quietUpdate;
977        }
978
979        private boolean getDoPopup() {
980            if (doPopup < 0) {
981                if (prefs.getBoolean(GeneralPreferences.KEY_ALERTS_POPUP, false)) {
982                    doPopup = 1;
983                } else {
984                    doPopup = 0;
985                }
986            }
987            return doPopup == 1;
988        }
989
990        private boolean getDefaultVibrate() {
991            if (defaultVibrate < 0) {
992                defaultVibrate = Utils.getDefaultVibrate(context, prefs) ? 1 : 0;
993            }
994            return defaultVibrate == 1;
995        }
996
997        private String getRingtoneAndSilence() {
998            if (ringtone == null) {
999                if (quietUpdate) {
1000                    ringtone = EMPTY_RINGTONE;
1001                } else {
1002                    ringtone = Utils.getRingTonePreference(context);
1003                }
1004            }
1005            String retVal = ringtone;
1006            ringtone = EMPTY_RINGTONE;
1007            return retVal;
1008        }
1009    }
1010
1011    private void doTimeChanged() {
1012        ContentResolver cr = getContentResolver();
1013        // TODO Move this into Provider
1014        rescheduleMissedAlarms(cr, this, AlertUtils.createAlarmManager(this));
1015        updateAlertNotification(this);
1016    }
1017
1018    private static final String SORT_ORDER_ALARMTIME_ASC =
1019            CalendarContract.CalendarAlerts.ALARM_TIME + " ASC";
1020
1021    private static final String WHERE_RESCHEDULE_MISSED_ALARMS =
1022            CalendarContract.CalendarAlerts.STATE
1023            + "="
1024            + CalendarContract.CalendarAlerts.STATE_SCHEDULED
1025            + " AND "
1026            + CalendarContract.CalendarAlerts.ALARM_TIME
1027            + "<?"
1028            + " AND "
1029            + CalendarContract.CalendarAlerts.ALARM_TIME
1030            + ">?"
1031            + " AND "
1032            + CalendarContract.CalendarAlerts.END + ">=?";
1033
1034    /**
1035     * Searches the CalendarAlerts table for alarms that should have fired but
1036     * have not and then reschedules them. This method can be called at boot
1037     * time to restore alarms that may have been lost due to a phone reboot.
1038     *
1039     * @param cr the ContentResolver
1040     * @param context the Context
1041     * @param manager the AlarmManager
1042     */
1043    private static final void rescheduleMissedAlarms(ContentResolver cr, Context context,
1044            AlarmManagerInterface manager) {
1045        // Get all the alerts that have been scheduled but have not fired
1046        // and should have fired by now and are not too old.
1047        long now = System.currentTimeMillis();
1048        long ancient = now - DateUtils.DAY_IN_MILLIS;
1049        String[] projection = new String[] {
1050            CalendarContract.CalendarAlerts.ALARM_TIME,
1051        };
1052
1053        // TODO: construct an explicit SQL query so that we can add
1054        // "GROUPBY" instead of doing a sort and de-dup
1055        Cursor cursor = cr.query(CalendarAlerts.CONTENT_URI, projection,
1056                WHERE_RESCHEDULE_MISSED_ALARMS, (new String[] {
1057                        Long.toString(now), Long.toString(ancient), Long.toString(now)
1058                }), SORT_ORDER_ALARMTIME_ASC);
1059        if (cursor == null) {
1060            return;
1061        }
1062
1063        if (DEBUG) {
1064            Log.d(TAG, "missed alarms found: " + cursor.getCount());
1065        }
1066
1067        try {
1068            long alarmTime = -1;
1069
1070            while (cursor.moveToNext()) {
1071                long newAlarmTime = cursor.getLong(0);
1072                if (alarmTime != newAlarmTime) {
1073                    if (DEBUG) {
1074                        Log.w(TAG, "rescheduling missed alarm. alarmTime: " + newAlarmTime);
1075                    }
1076                    AlertUtils.scheduleAlarm(context, manager, newAlarmTime);
1077                    alarmTime = newAlarmTime;
1078                }
1079            }
1080        } finally {
1081            cursor.close();
1082        }
1083    }
1084
1085    private final class ServiceHandler extends Handler {
1086        public ServiceHandler(Looper looper) {
1087            super(looper);
1088        }
1089
1090        @Override
1091        public void handleMessage(Message msg) {
1092            processMessage(msg);
1093            // NOTE: We MUST not call stopSelf() directly, since we need to
1094            // make sure the wake lock acquired by AlertReceiver is released.
1095            AlertReceiver.finishStartingService(AlertService.this, msg.arg1);
1096        }
1097    }
1098
1099    @Override
1100    public void onCreate() {
1101        HandlerThread thread = new HandlerThread("AlertService",
1102                Process.THREAD_PRIORITY_BACKGROUND);
1103        thread.start();
1104
1105        mServiceLooper = thread.getLooper();
1106        mServiceHandler = new ServiceHandler(mServiceLooper);
1107
1108        // Flushes old fired alerts from internal storage, if needed.
1109        AlertUtils.flushOldAlertsFromInternalStorage(getApplication());
1110    }
1111
1112    @Override
1113    public int onStartCommand(Intent intent, int flags, int startId) {
1114        if (intent != null) {
1115            Message msg = mServiceHandler.obtainMessage();
1116            msg.arg1 = startId;
1117            msg.obj = intent.getExtras();
1118            mServiceHandler.sendMessage(msg);
1119        }
1120        return START_REDELIVER_INTENT;
1121    }
1122
1123    @Override
1124    public void onDestroy() {
1125        mServiceLooper.quit();
1126    }
1127
1128    @Override
1129    public IBinder onBind(Intent intent) {
1130        return null;
1131    }
1132}
1133