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