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