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