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