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