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