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