AlertReceiver.java revision ab94b747c63244ed2e406e886f19b308d7b57fc6
1/* 2 * Copyright (C) 2007 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 com.android.calendar.R; 20import com.android.calendar.Utils; 21 22import android.app.Notification; 23import android.app.PendingIntent; 24import android.app.Service; 25import android.content.BroadcastReceiver; 26import android.content.ContentResolver; 27import android.content.ContentUris; 28import android.content.Context; 29import android.content.Intent; 30import android.content.res.Resources; 31import android.database.Cursor; 32import android.net.Uri; 33import android.os.Handler; 34import android.os.HandlerThread; 35import android.os.PowerManager; 36import android.provider.CalendarContract.Attendees; 37import android.provider.CalendarContract.Calendars; 38import android.provider.CalendarContract.Events; 39import android.text.SpannableStringBuilder; 40import android.text.TextUtils; 41import android.text.style.TextAppearanceSpan; 42import android.util.Log; 43 44import java.util.ArrayList; 45import java.util.List; 46 47/** 48 * Receives android.intent.action.EVENT_REMINDER intents and handles 49 * event reminders. The intent URI specifies an alert id in the 50 * CalendarAlerts database table. This class also receives the 51 * BOOT_COMPLETED intent so that it can add a status bar notification 52 * if there are Calendar event alarms that have not been dismissed. 53 * It also receives the TIME_CHANGED action so that it can fire off 54 * snoozed alarms that have become ready. The real work is done in 55 * the AlertService class. 56 * 57 * To trigger this code after pushing the apk to device: 58 * adb shell am broadcast -a "android.intent.action.EVENT_REMINDER" 59 * -n "com.android.calendar/.alerts.AlertReceiver" 60 */ 61public class AlertReceiver extends BroadcastReceiver { 62 private static final String TAG = "AlertReceiver"; 63 64 private static final String DELETE_ALL_ACTION = "com.android.calendar.DELETEALL"; 65 private static final String MAIL_ACTION = "com.android.calendar.MAIL"; 66 private static final String EXTRA_EVENT_ID = "eventid"; 67 68 static final Object mStartingServiceSync = new Object(); 69 static PowerManager.WakeLock mStartingService; 70 71 public static final String ACTION_DISMISS_OLD_REMINDERS = "removeOldReminders"; 72 private static final int NOTIFICATION_DIGEST_MAX_LENGTH = 3; 73 74 private static Handler sAsyncHandler; 75 static { 76 HandlerThread thr = new HandlerThread("AlertReceiver async"); 77 thr.start(); 78 sAsyncHandler = new Handler(thr.getLooper()); 79 } 80 81 @Override 82 public void onReceive(final Context context, final Intent intent) { 83 if (AlertService.DEBUG) { 84 Log.d(TAG, "onReceive: a=" + intent.getAction() + " " + intent.toString()); 85 } 86 if (DELETE_ALL_ACTION.equals(intent.getAction())) { 87 88 /* The user has clicked the "Clear All Notifications" 89 * buttons so dismiss all Calendar alerts. 90 */ 91 // TODO Grab a wake lock here? 92 Intent serviceIntent = new Intent(context, DismissAlarmsService.class); 93 context.startService(serviceIntent); 94 } else if (MAIL_ACTION.equals(intent.getAction())) { 95 // Close the notification shade. 96 Intent closeNotificationShadeIntent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); 97 context.sendBroadcast(closeNotificationShadeIntent); 98 99 // Now start the email intent. 100 final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); 101 if (eventId != -1) { 102 final PendingResult result = goAsync(); 103 Runnable worker = new Runnable() { 104 @Override 105 public void run() { 106 Intent emailIntent = createEmailIntent(context, eventId); 107 if (emailIntent != null) { 108 context.startActivity(emailIntent); 109 } 110 result.finish(); 111 } 112 }; 113 sAsyncHandler.post(worker); 114 } 115 } else { 116 Intent i = new Intent(); 117 i.setClass(context, AlertService.class); 118 i.putExtras(intent); 119 i.putExtra("action", intent.getAction()); 120 Uri uri = intent.getData(); 121 122 // This intent might be a BOOT_COMPLETED so it might not have a Uri. 123 if (uri != null) { 124 i.putExtra("uri", uri.toString()); 125 } 126 beginStartingService(context, i); 127 } 128 } 129 130 /** 131 * Start the service to process the current event notifications, acquiring 132 * the wake lock before returning to ensure that the service will run. 133 */ 134 public static void beginStartingService(Context context, Intent intent) { 135 synchronized (mStartingServiceSync) { 136 if (mStartingService == null) { 137 PowerManager pm = 138 (PowerManager)context.getSystemService(Context.POWER_SERVICE); 139 mStartingService = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, 140 "StartingAlertService"); 141 mStartingService.setReferenceCounted(false); 142 } 143 mStartingService.acquire(); 144 context.startService(intent); 145 } 146 } 147 148 /** 149 * Called back by the service when it has finished processing notifications, 150 * releasing the wake lock if the service is now stopping. 151 */ 152 public static void finishStartingService(Service service, int startId) { 153 synchronized (mStartingServiceSync) { 154 if (mStartingService != null) { 155 if (service.stopSelfResult(startId)) { 156 mStartingService.release(); 157 } 158 } 159 } 160 } 161 162 private static PendingIntent createClickEventIntent(Context context, long eventId, 163 long startMillis, long endMillis, int notificationId) { 164 return createDismissAlarmsIntent(context, eventId, startMillis, endMillis, notificationId, 165 "com.android.calendar.CLICK", true); 166 } 167 168 private static PendingIntent createDeleteEventIntent(Context context, long eventId, 169 long startMillis, long endMillis, int notificationId) { 170 return createDismissAlarmsIntent(context, eventId, startMillis, endMillis, notificationId, 171 "com.android.calendar.DELETE", false); 172 } 173 174 private static PendingIntent createDismissAlarmsIntent(Context context, long eventId, 175 long startMillis, long endMillis, int notificationId, String action, 176 boolean showEvent) { 177 Intent intent = new Intent(); 178 intent.setClass(context, DismissAlarmsService.class); 179 intent.putExtra(AlertUtils.EVENT_ID_KEY, eventId); 180 intent.putExtra(AlertUtils.EVENT_START_KEY, startMillis); 181 intent.putExtra(AlertUtils.EVENT_END_KEY, endMillis); 182 intent.putExtra(AlertUtils.SHOW_EVENT_KEY, showEvent); 183 intent.putExtra(AlertUtils.NOTIFICATION_ID_KEY, notificationId); 184 185 // Must set a field that affects Intent.filterEquals so that the resulting 186 // PendingIntent will be a unique instance (the 'extras' don't achieve this). 187 // This must be unique for the click event across all reminders (so using 188 // event ID + startTime should be unique). This also must be unique from 189 // the delete event (which also uses DismissAlarmsService). 190 Uri.Builder builder = Events.CONTENT_URI.buildUpon(); 191 ContentUris.appendId(builder, eventId); 192 ContentUris.appendId(builder, startMillis); 193 intent.setData(builder.build()); 194 intent.setAction(action); 195 return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 196 } 197 198 private static PendingIntent createSnoozeIntent(Context context, long eventId, 199 long startMillis, long endMillis, int notificationId) { 200 Intent intent = new Intent(); 201 intent.setClass(context, SnoozeAlarmsService.class); 202 intent.putExtra(AlertUtils.EVENT_ID_KEY, eventId); 203 intent.putExtra(AlertUtils.EVENT_START_KEY, startMillis); 204 intent.putExtra(AlertUtils.EVENT_END_KEY, endMillis); 205 intent.putExtra(AlertUtils.NOTIFICATION_ID_KEY, notificationId); 206 207 Uri.Builder builder = Events.CONTENT_URI.buildUpon(); 208 ContentUris.appendId(builder, eventId); 209 ContentUris.appendId(builder, startMillis); 210 intent.setData(builder.build()); 211 return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 212 } 213 214 public static Notification makeBasicNotification(Context context, String title, 215 String summaryText, long startMillis, long endMillis, long eventId, 216 int notificationId, boolean doPopup) { 217 return makeBasicNotificationBuilder(context, title, summaryText, startMillis, endMillis, 218 eventId, notificationId, doPopup, false, false).build(); 219 } 220 221 private static Notification.Builder makeBasicNotificationBuilder(Context context, String title, 222 String summaryText, long startMillis, long endMillis, long eventId, 223 int notificationId, boolean doPopup, boolean highPriority, boolean addActionButtons) { 224 Resources resources = context.getResources(); 225 if (title == null || title.length() == 0) { 226 title = resources.getString(R.string.no_title_label); 227 } 228 229 // Create an intent triggered by clicking on the status icon, that dismisses the 230 // notification and shows the event. 231 PendingIntent clickIntent = createClickEventIntent(context, eventId, startMillis, 232 endMillis, notificationId); 233 234 // Create a delete intent triggered by dismissing the notification. 235 PendingIntent deleteIntent = createDeleteEventIntent(context, eventId, startMillis, 236 endMillis, notificationId); 237 238 // Create the base notification. 239 Notification.Builder notificationBuilder = new Notification.Builder(context); 240 notificationBuilder.setContentTitle(title); 241 notificationBuilder.setContentText(summaryText); 242 notificationBuilder.setSmallIcon(R.drawable.stat_notify_calendar); 243 notificationBuilder.setContentIntent(clickIntent); 244 notificationBuilder.setDeleteIntent(deleteIntent); 245 if (addActionButtons) { 246 // Create a snooze button. TODO: change snooze to 10 minutes. 247 PendingIntent snoozeIntent = createSnoozeIntent(context, eventId, startMillis, 248 endMillis, notificationId); 249 notificationBuilder.addAction(R.drawable.snooze, 250 resources.getString(R.string.snooze_5min_label), snoozeIntent); 251 252 // Create an email button. 253 PendingIntent emailIntent = createBroadcastMailIntent(context, eventId, title); 254 if (emailIntent != null) { 255 notificationBuilder.addAction(R.drawable.ic_menu_email_holo_dark, 256 resources.getString(R.string.email_guests_label), emailIntent); 257 } 258 } 259 if (doPopup) { 260 notificationBuilder.setFullScreenIntent(clickIntent, true); 261 } 262 263 // Turn off timestamp. 264 notificationBuilder.setWhen(0); 265 266 // Setting to a higher priority will encourage notification manager to expand the 267 // notification. 268 if (highPriority) { 269 notificationBuilder.setPriority(Notification.PRIORITY_HIGH); 270 } else { 271 notificationBuilder.setPriority(Notification.PRIORITY_DEFAULT); 272 } 273 return notificationBuilder; 274 } 275 276 /** 277 * Creates an expanding notification. The initial expanded state is decided by 278 * the notification manager based on the priority. 279 */ 280 public static Notification makeExpandingNotification(Context context, String title, 281 String summaryText, String description, long startMillis, long endMillis, long eventId, 282 int notificationId, boolean doPopup, boolean highPriority) { 283 Notification.Builder basicBuilder = makeBasicNotificationBuilder(context, title, 284 summaryText, startMillis, endMillis, eventId, notificationId, 285 doPopup, highPriority, true); 286 287 // Create an expanded notification 288 Notification.BigTextStyle expandedBuilder = new Notification.BigTextStyle( 289 basicBuilder); 290 if (description != null) { 291 description = description.trim(); 292 } 293 String text; 294 if (TextUtils.isEmpty(description)) { 295 text = summaryText; 296 } else { 297 text = context.getResources().getString( 298 R.string.event_notification_big_text, summaryText, description); 299 } 300 expandedBuilder.bigText(text); 301 return expandedBuilder.build(); 302 } 303 304 /** 305 * Creates an expanding digest notification for expired events. 306 */ 307 public static Notification makeDigestNotification(Context context, 308 List<AlertService.NotificationInfo> notificationInfos, String digestTitle, 309 boolean expandable) { 310 if (notificationInfos == null || notificationInfos.size() < 1) { 311 return null; 312 } 313 314 Resources res = context.getResources(); 315 int numEvents = notificationInfos.size(); 316 317 // Create an intent triggered by clicking on the status icon that shows the alerts list. 318 Intent clickIntent = new Intent(); 319 clickIntent.setClass(context, AlertActivity.class); 320 clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 321 PendingIntent pendingClickIntent = PendingIntent.getActivity(context, 0, clickIntent, 322 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT); 323 324 // Create an intent triggered by dismissing the digest notification that clears all 325 // expired events. 326 Intent deleteIntent = new Intent(); 327 deleteIntent.setClass(context, DismissAlarmsService.class); 328 deleteIntent.setAction(DELETE_ALL_ACTION); 329 deleteIntent.putExtra(AlertUtils.DELETE_EXPIRED_ONLY_KEY, true); 330 PendingIntent pendingDeleteIntent = PendingIntent.getService(context, 0, deleteIntent, 331 PendingIntent.FLAG_UPDATE_CURRENT); 332 333 if (digestTitle == null || digestTitle.length() == 0) { 334 digestTitle = res.getString(R.string.no_title_label); 335 } 336 337 Notification.Builder notificationBuilder = new Notification.Builder(context); 338 notificationBuilder.setContentText(digestTitle); 339 notificationBuilder.setSmallIcon(R.drawable.stat_notify_calendar); 340 notificationBuilder.setContentIntent(pendingClickIntent); 341 notificationBuilder.setDeleteIntent(pendingDeleteIntent); 342 String nEventsStr = res.getQuantityString(R.plurals.Nevents, numEvents, numEvents); 343 notificationBuilder.setContentTitle(nEventsStr); 344 345 // Set to min priority to encourage the notification manager to collapse it. 346 notificationBuilder.setPriority(Notification.PRIORITY_MIN); 347 348 if (expandable) { 349 // Multiple reminders. Combine into an expanded digest notification. 350 Notification.InboxStyle expandedBuilder = new Notification.InboxStyle( 351 notificationBuilder); 352 int i = 0; 353 for (AlertService.NotificationInfo info : notificationInfos) { 354 if (i < NOTIFICATION_DIGEST_MAX_LENGTH) { 355 String name = info.eventName; 356 if (TextUtils.isEmpty(name)) { 357 name = context.getResources().getString(R.string.no_title_label); 358 } 359 String timeLocation = AlertUtils.formatTimeLocation(context, info.startMillis, 360 info.allDay, info.location); 361 362 TextAppearanceSpan primaryTextSpan = new TextAppearanceSpan(context, 363 R.style.NotificationPrimaryText); 364 TextAppearanceSpan secondaryTextSpan = new TextAppearanceSpan(context, 365 R.style.NotificationSecondaryText); 366 367 // Event title in bold. 368 SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); 369 stringBuilder.append(name); 370 stringBuilder.setSpan(primaryTextSpan, 0, stringBuilder.length(), 0); 371 stringBuilder.append(" "); 372 373 // Followed by time and location. 374 int secondaryIndex = stringBuilder.length(); 375 stringBuilder.append(timeLocation); 376 stringBuilder.setSpan(secondaryTextSpan, secondaryIndex, stringBuilder.length(), 377 0); 378 expandedBuilder.addLine(stringBuilder); 379 i++; 380 } else { 381 break; 382 } 383 } 384 385 // If there are too many to display, add "+X missed events" for the last line. 386 int remaining = numEvents - i; 387 if (remaining > 0) { 388 String nMoreEventsStr = res.getQuantityString(R.plurals.N_missed_events, remaining, 389 remaining); 390 // TODO: Add highlighting and icon to this last entry once framework allows it. 391 expandedBuilder.setSummaryText(nMoreEventsStr); 392 } 393 394 // Remove the title in the expanded form (redundant with the listed items). 395 expandedBuilder.setBigContentTitle(""); 396 397 return expandedBuilder.build(); 398 } else { 399 return notificationBuilder.build(); 400 } 401 } 402 403 private static final String[] ATTENDEES_PROJECTION = new String[] { 404 Attendees.ATTENDEE_EMAIL, // 0 405 Attendees.ATTENDEE_STATUS, // 1 406 }; 407 private static final int ATTENDEES_INDEX_EMAIL = 0; 408 private static final int ATTENDEES_INDEX_STATUS = 1; 409 private static final String ATTENDEES_WHERE = Attendees.EVENT_ID + "=?"; 410 private static final String ATTENDEES_SORT_ORDER = Attendees.ATTENDEE_NAME + " ASC, " 411 + Attendees.ATTENDEE_EMAIL + " ASC"; 412 413 private static final String[] EVENT_PROJECTION = new String[] { 414 Calendars.OWNER_ACCOUNT, // 0 415 Calendars.ACCOUNT_NAME, // 1 416 Events.TITLE, // 2 417 }; 418 private static final int EVENT_INDEX_OWNER_ACCOUNT = 0; 419 private static final int EVENT_INDEX_ACCOUNT_NAME = 1; 420 private static final int EVENT_INDEX_TITLE = 2; 421 422 private static Cursor getEventCursor(Context context, long eventId) { 423 return context.getContentResolver().query( 424 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), EVENT_PROJECTION, 425 null, null, null); 426 } 427 428 private static Cursor getAttendeesCursor(Context context, long eventId) { 429 return context.getContentResolver().query(Attendees.CONTENT_URI, 430 ATTENDEES_PROJECTION, ATTENDEES_WHERE, new String[] { Long.toString(eventId) }, 431 ATTENDEES_SORT_ORDER); 432 } 433 434 /** 435 * Creates a broadcast pending intent that fires to AlertReceiver when the email button 436 * is clicked. 437 */ 438 private static PendingIntent createBroadcastMailIntent(Context context, long eventId, 439 String eventTitle) { 440 // Query for viewer account. 441 String syncAccount = null; 442 Cursor eventCursor = getEventCursor(context, eventId); 443 try { 444 if (eventCursor != null && eventCursor.moveToFirst()) { 445 syncAccount = eventCursor.getString(EVENT_INDEX_ACCOUNT_NAME); 446 } 447 } finally { 448 if (eventCursor != null) { 449 eventCursor.close(); 450 } 451 } 452 453 // Query attendees to see if there are any to email. 454 Cursor attendeesCursor = getAttendeesCursor(context, eventId); 455 try { 456 if (attendeesCursor != null && attendeesCursor.moveToFirst()) { 457 do { 458 String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL); 459 if (Utils.isEmailableFrom(email, syncAccount)) { 460 // Send intent back to ourself first for a couple reasons: 461 // 1) Workaround issue where clicking action button in notification does 462 // not automatically close the notification shade. 463 // 2) Attendees list in email will always be up to date. 464 Intent broadcastIntent = new Intent(MAIL_ACTION); 465 broadcastIntent.setClass(context, AlertReceiver.class); 466 broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); 467 return PendingIntent.getBroadcast(context, 468 Long.valueOf(eventId).hashCode(), broadcastIntent, 469 PendingIntent.FLAG_CANCEL_CURRENT); 470 } 471 } while (attendeesCursor.moveToNext()); 472 } 473 return null; 474 475 } finally { 476 if (attendeesCursor != null) { 477 attendeesCursor.close(); 478 } 479 } 480 } 481 482 /** 483 * Creates an Intent for emailing the attendees of the event. Returns null if there 484 * are no emailable attendees. 485 */ 486 private static Intent createEmailIntent(Context context, long eventId) { 487 ContentResolver resolver = context.getContentResolver(); 488 489 // TODO: Refactor to move query part into Utils.createEmailAttendeeIntent, to 490 // be shared with EventInfoFragment. 491 492 // Query for the owner account(s). 493 String ownerAccount = null; 494 String syncAccount = null; 495 String eventTitle = null; 496 Cursor eventCursor = getEventCursor(context, eventId); 497 try { 498 if (eventCursor != null && eventCursor.moveToFirst()) { 499 ownerAccount = eventCursor.getString(EVENT_INDEX_OWNER_ACCOUNT); 500 syncAccount = eventCursor.getString(EVENT_INDEX_ACCOUNT_NAME); 501 eventTitle = eventCursor.getString(EVENT_INDEX_TITLE); 502 } 503 } finally { 504 if (eventCursor != null) { 505 eventCursor.close(); 506 } 507 } 508 if (TextUtils.isEmpty(eventTitle)) { 509 eventTitle = context.getResources().getString(R.string.no_title_label); 510 } 511 512 // Query for the attendees. 513 List<String> toEmails = new ArrayList<String>(); 514 List<String> ccEmails = new ArrayList<String>(); 515 Cursor attendeesCursor = getAttendeesCursor(context, eventId); 516 try { 517 if (attendeesCursor != null && attendeesCursor.moveToFirst()) { 518 do { 519 int status = attendeesCursor.getInt(ATTENDEES_INDEX_STATUS); 520 String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL); 521 switch(status) { 522 case Attendees.ATTENDEE_STATUS_DECLINED: 523 addIfEmailable(ccEmails, email, syncAccount); 524 break; 525 default: 526 addIfEmailable(toEmails, email, syncAccount); 527 } 528 } while (attendeesCursor.moveToNext()); 529 } 530 } finally { 531 if (attendeesCursor != null) { 532 attendeesCursor.close(); 533 } 534 } 535 536 Intent intent = null; 537 if (ownerAccount != null && (toEmails.size() > 0 || ccEmails.size() > 0)) { 538 intent = Utils.createEmailAttendeesIntent(context.getResources(), eventTitle, 539 toEmails, ccEmails, ownerAccount); 540 } 541 542 if (intent == null) { 543 return null; 544 } 545 else { 546 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 547 return intent; 548 } 549 } 550 551 private static void addIfEmailable(List<String> emailList, String email, String syncAccount) { 552 if (Utils.isEmailableFrom(email, syncAccount)) { 553 emailList.add(email); 554 } 555 } 556} 557