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.telephony.TelephonyManager; 36 import android.text.Spannable; 37 import android.text.SpannableStringBuilder; 38 import android.text.TextUtils; 39 import android.text.style.RelativeSizeSpan; 40 import android.text.style.TextAppearanceSpan; 41 import android.text.style.URLSpan; 42 import android.util.Log; 43 import android.view.View; 44 import android.widget.RemoteViews; 45 46 import com.android.calendar.R; 47 import com.android.calendar.Utils; 48 import com.android.calendar.alerts.AlertService.NotificationWrapper; 49 50 import java.util.ArrayList; 51 import java.util.List; 52 import java.util.regex.Pattern; 53 54 /** 55 * Receives android.intent.action.EVENT_REMINDER intents and handles 56 * event reminders. The intent URI specifies an alert id in the 57 * CalendarAlerts database table. This class also receives the 58 * BOOT_COMPLETED intent so that it can add a status bar notification 59 * if there are Calendar event alarms that have not been dismissed. 60 * It also receives the TIME_CHANGED action so that it can fire off 61 * snoozed alarms that have become ready. The real work is done in 62 * the AlertService class. 63 * 64 * To trigger this code after pushing the apk to device: 65 * adb shell am broadcast -a "android.intent.action.EVENT_REMINDER" 66 * -n "com.android.calendar/.alerts.AlertReceiver" 67 */ 68 public class AlertReceiver extends BroadcastReceiver { 69 private static final String TAG = "AlertReceiver"; 70 71 private static final String DELETE_ALL_ACTION = "com.android.calendar.DELETEALL"; 72 private static final String MAP_ACTION = "com.android.calendar.MAP"; 73 private static final String CALL_ACTION = "com.android.calendar.CALL"; 74 private static final String MAIL_ACTION = "com.android.calendar.MAIL"; 75 private static final String EXTRA_EVENT_ID = "eventid"; 76 77 // The broadcast for notification refreshes scheduled by the app. This is to 78 // distinguish the EVENT_REMINDER broadcast sent by the provider. 79 public static final String EVENT_REMINDER_APP_ACTION = 80 "com.android.calendar.EVENT_REMINDER_APP"; 81 82 static final Object mStartingServiceSync = new Object(); 83 static PowerManager.WakeLock mStartingService; 84 private static final Pattern mBlankLinePattern = Pattern.compile("^\\s*$[\n\r]", 85 Pattern.MULTILINE); 86 87 public static final String ACTION_DISMISS_OLD_REMINDERS = "removeOldReminders"; 88 private static final int NOTIFICATION_DIGEST_MAX_LENGTH = 3; 89 90 private static final String GEO_PREFIX = "geo:"; 91 private static final String TEL_PREFIX = "tel:"; 92 private static final int MAX_NOTIF_ACTIONS = 3; 93 94 private static Handler sAsyncHandler; 95 static { 96 HandlerThread thr = new HandlerThread("AlertReceiver async"); 97 thr.start(); 98 sAsyncHandler = new Handler(thr.getLooper()); 99 } 100 101 @Override 102 public void onReceive(final Context context, final Intent intent) { 103 if (AlertService.DEBUG) { 104 Log.d(TAG, "onReceive: a=" + intent.getAction() + " " + intent.toString()); 105 } 106 if (DELETE_ALL_ACTION.equals(intent.getAction())) { 107 108 // The user has dismissed a digest notification. 109 // TODO Grab a wake lock here? 110 Intent serviceIntent = new Intent(context, DismissAlarmsService.class); 111 context.startService(serviceIntent); 112 } else if (MAP_ACTION.equals(intent.getAction())) { 113 // Try starting the map action. 114 // If no map location is found (something changed since the notification was originally 115 // fired), update the notifications to express this change. 116 final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); 117 if (eventId != -1) { 118 URLSpan[] urlSpans = getURLSpans(context, eventId); 119 Intent geoIntent = createMapActivityIntent(context, urlSpans); 120 if (geoIntent != null) { 121 // Location was successfully found, so dismiss the shade and start maps. 122 context.startActivity(geoIntent); 123 closeNotificationShade(context); 124 } else { 125 // No location was found, so update all notifications. 126 // Our alert service does not currently allow us to specify only one 127 // specific notification to refresh. 128 AlertService.updateAlertNotification(context); 129 } 130 } 131 } else if (CALL_ACTION.equals(intent.getAction())) { 132 // Try starting the call action. 133 // If no call location is found (something changed since the notification was originally 134 // fired), update the notifications to express this change. 135 final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); 136 if (eventId != -1) { 137 URLSpan[] urlSpans = getURLSpans(context, eventId); 138 Intent callIntent = createCallActivityIntent(context, urlSpans); 139 if (callIntent != null) { 140 // Call location was successfully found, so dismiss the shade and start dialer. 141 context.startActivity(callIntent); 142 closeNotificationShade(context); 143 } else { 144 // No call location was found, so update all notifications. 145 // Our alert service does not currently allow us to specify only one 146 // specific notification to refresh. 147 AlertService.updateAlertNotification(context); 148 } 149 } 150 } else if (MAIL_ACTION.equals(intent.getAction())) { 151 closeNotificationShade(context); 152 153 // Now start the email intent. 154 final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); 155 if (eventId != -1) { 156 Intent i = new Intent(context, QuickResponseActivity.class); 157 i.putExtra(QuickResponseActivity.EXTRA_EVENT_ID, eventId); 158 i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 159 context.startActivity(i); 160 } 161 } else { 162 Intent i = new Intent(); 163 i.setClass(context, AlertService.class); 164 i.putExtras(intent); 165 i.putExtra("action", intent.getAction()); 166 Uri uri = intent.getData(); 167 168 // This intent might be a BOOT_COMPLETED so it might not have a Uri. 169 if (uri != null) { 170 i.putExtra("uri", uri.toString()); 171 } 172 beginStartingService(context, i); 173 } 174 } 175 176 /** 177 * Start the service to process the current event notifications, acquiring 178 * the wake lock before returning to ensure that the service will run. 179 */ 180 public static void beginStartingService(Context context, Intent intent) { 181 synchronized (mStartingServiceSync) { 182 if (mStartingService == null) { 183 PowerManager pm = 184 (PowerManager)context.getSystemService(Context.POWER_SERVICE); 185 mStartingService = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, 186 "StartingAlertService"); 187 mStartingService.setReferenceCounted(false); 188 } 189 mStartingService.acquire(); 190 context.startService(intent); 191 } 192 } 193 194 /** 195 * Called back by the service when it has finished processing notifications, 196 * releasing the wake lock if the service is now stopping. 197 */ 198 public static void finishStartingService(Service service, int startId) { 199 synchronized (mStartingServiceSync) { 200 if (mStartingService != null) { 201 if (service.stopSelfResult(startId)) { 202 mStartingService.release(); 203 } 204 } 205 } 206 } 207 208 private static PendingIntent createClickEventIntent(Context context, long eventId, 209 long startMillis, long endMillis, int notificationId) { 210 return createDismissAlarmsIntent(context, eventId, startMillis, endMillis, notificationId, 211 "com.android.calendar.CLICK", true); 212 } 213 214 private static PendingIntent createDeleteEventIntent(Context context, long eventId, 215 long startMillis, long endMillis, int notificationId) { 216 return createDismissAlarmsIntent(context, eventId, startMillis, endMillis, notificationId, 217 "com.android.calendar.DELETE", false); 218 } 219 220 private static PendingIntent createDismissAlarmsIntent(Context context, long eventId, 221 long startMillis, long endMillis, int notificationId, String action, 222 boolean showEvent) { 223 Intent intent = new Intent(); 224 intent.setClass(context, DismissAlarmsService.class); 225 intent.putExtra(AlertUtils.EVENT_ID_KEY, eventId); 226 intent.putExtra(AlertUtils.EVENT_START_KEY, startMillis); 227 intent.putExtra(AlertUtils.EVENT_END_KEY, endMillis); 228 intent.putExtra(AlertUtils.SHOW_EVENT_KEY, showEvent); 229 intent.putExtra(AlertUtils.NOTIFICATION_ID_KEY, notificationId); 230 231 // Must set a field that affects Intent.filterEquals so that the resulting 232 // PendingIntent will be a unique instance (the 'extras' don't achieve this). 233 // This must be unique for the click event across all reminders (so using 234 // event ID + startTime should be unique). This also must be unique from 235 // the delete event (which also uses DismissAlarmsService). 236 Uri.Builder builder = Events.CONTENT_URI.buildUpon(); 237 ContentUris.appendId(builder, eventId); 238 ContentUris.appendId(builder, startMillis); 239 intent.setData(builder.build()); 240 intent.setAction(action); 241 return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 242 } 243 244 private static PendingIntent createSnoozeIntent(Context context, long eventId, 245 long startMillis, long endMillis, int notificationId) { 246 Intent intent = new Intent(); 247 intent.setClass(context, SnoozeAlarmsService.class); 248 intent.putExtra(AlertUtils.EVENT_ID_KEY, eventId); 249 intent.putExtra(AlertUtils.EVENT_START_KEY, startMillis); 250 intent.putExtra(AlertUtils.EVENT_END_KEY, endMillis); 251 intent.putExtra(AlertUtils.NOTIFICATION_ID_KEY, notificationId); 252 253 Uri.Builder builder = Events.CONTENT_URI.buildUpon(); 254 ContentUris.appendId(builder, eventId); 255 ContentUris.appendId(builder, startMillis); 256 intent.setData(builder.build()); 257 return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 258 } 259 260 private static PendingIntent createAlertActivityIntent(Context context) { 261 Intent clickIntent = new Intent(); 262 clickIntent.setClass(context, AlertActivity.class); 263 clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 264 return PendingIntent.getActivity(context, 0, clickIntent, 265 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT); 266 } 267 268 public static NotificationWrapper makeBasicNotification(Context context, String title, 269 String summaryText, long startMillis, long endMillis, long eventId, 270 int notificationId, boolean doPopup, int priority) { 271 Notification n = buildBasicNotification(new Notification.Builder(context), 272 context, title, summaryText, startMillis, endMillis, eventId, notificationId, 273 doPopup, priority, false); 274 return new NotificationWrapper(n, notificationId, eventId, startMillis, endMillis, doPopup); 275 } 276 277 private static Notification buildBasicNotification(Notification.Builder notificationBuilder, 278 Context context, String title, String summaryText, long startMillis, long endMillis, 279 long eventId, int notificationId, boolean doPopup, int priority, 280 boolean addActionButtons) { 281 Resources resources = context.getResources(); 282 if (title == null || title.length() == 0) { 283 title = resources.getString(R.string.no_title_label); 284 } 285 286 // Create an intent triggered by clicking on the status icon, that dismisses the 287 // notification and shows the event. 288 PendingIntent clickIntent = createClickEventIntent(context, eventId, startMillis, 289 endMillis, notificationId); 290 291 // Create a delete intent triggered by dismissing the notification. 292 PendingIntent deleteIntent = createDeleteEventIntent(context, eventId, startMillis, 293 endMillis, notificationId); 294 295 // Create the base notification. 296 notificationBuilder.setContentTitle(title); 297 notificationBuilder.setContentText(summaryText); 298 notificationBuilder.setSmallIcon(R.drawable.stat_notify_calendar); 299 notificationBuilder.setContentIntent(clickIntent); 300 notificationBuilder.setDeleteIntent(deleteIntent); 301 if (doPopup) { 302 notificationBuilder.setFullScreenIntent(createAlertActivityIntent(context), true); 303 } 304 305 PendingIntent mapIntent = null, callIntent = null, snoozeIntent = null, emailIntent = null; 306 if (addActionButtons) { 307 // Send map, call, and email intent back to ourself first for a couple reasons: 308 // 1) Workaround issue where clicking action button in notification does 309 // not automatically close the notification shade. 310 // 2) Event information will always be up to date. 311 312 // Create map and/or call intents. 313 URLSpan[] urlSpans = getURLSpans(context, eventId); 314 mapIntent = createMapBroadcastIntent(context, urlSpans, eventId); 315 callIntent = createCallBroadcastIntent(context, urlSpans, eventId); 316 317 // Create email intent for emailing attendees. 318 emailIntent = createBroadcastMailIntent(context, eventId, title); 319 320 // Create snooze intent. TODO: change snooze to 10 minutes. 321 snoozeIntent = createSnoozeIntent(context, eventId, startMillis, endMillis, 322 notificationId); 323 } 324 325 if (Utils.isJellybeanOrLater()) { 326 // Turn off timestamp. 327 notificationBuilder.setWhen(0); 328 329 // Should be one of the values in Notification (ie. Notification.PRIORITY_HIGH, etc). 330 // A higher priority will encourage notification manager to expand it. 331 notificationBuilder.setPriority(priority); 332 333 // Add action buttons. Show at most three, using the following priority ordering: 334 // 1. Map 335 // 2. Call 336 // 3. Email 337 // 4. Snooze 338 // Actions will only be shown if they are applicable; i.e. with no location, map will 339 // not be shown, and with no recipients, snooze will not be shown. 340 // TODO: Get icons, get strings. Maybe show preview of actual location/number? 341 int numActions = 0; 342 if (mapIntent != null && numActions < MAX_NOTIF_ACTIONS) { 343 notificationBuilder.addAction(R.drawable.ic_map, 344 resources.getString(R.string.map_label), mapIntent); 345 numActions++; 346 } 347 if (callIntent != null && numActions < MAX_NOTIF_ACTIONS) { 348 notificationBuilder.addAction(R.drawable.ic_call, 349 resources.getString(R.string.call_label), callIntent); 350 numActions++; 351 } 352 if (emailIntent != null && numActions < MAX_NOTIF_ACTIONS) { 353 notificationBuilder.addAction(R.drawable.ic_menu_email_holo_dark, 354 resources.getString(R.string.email_guests_label), emailIntent); 355 numActions++; 356 } 357 if (snoozeIntent != null && numActions < MAX_NOTIF_ACTIONS) { 358 notificationBuilder.addAction(R.drawable.ic_alarm_holo_dark, 359 resources.getString(R.string.snooze_label), snoozeIntent); 360 numActions++; 361 } 362 return notificationBuilder.getNotification(); 363 364 } else { 365 // Old-style notification (pre-JB). Use custom view with buttons to provide 366 // JB-like functionality (snooze/email). 367 Notification n = notificationBuilder.getNotification(); 368 369 // Use custom view with buttons to provide JB-like functionality (snooze/email). 370 RemoteViews contentView = new RemoteViews(context.getPackageName(), 371 R.layout.notification); 372 contentView.setImageViewResource(R.id.image, R.drawable.stat_notify_calendar); 373 contentView.setTextViewText(R.id.title, title); 374 contentView.setTextViewText(R.id.text, summaryText); 375 376 int numActions = 0; 377 if (mapIntent == null || numActions >= MAX_NOTIF_ACTIONS) { 378 contentView.setViewVisibility(R.id.map_button, View.GONE); 379 } else { 380 contentView.setViewVisibility(R.id.map_button, View.VISIBLE); 381 contentView.setOnClickPendingIntent(R.id.map_button, mapIntent); 382 contentView.setViewVisibility(R.id.end_padding, View.GONE); 383 numActions++; 384 } 385 if (callIntent == null || numActions >= MAX_NOTIF_ACTIONS) { 386 contentView.setViewVisibility(R.id.call_button, View.GONE); 387 } else { 388 contentView.setViewVisibility(R.id.call_button, View.VISIBLE); 389 contentView.setOnClickPendingIntent(R.id.call_button, callIntent); 390 contentView.setViewVisibility(R.id.end_padding, View.GONE); 391 numActions++; 392 } 393 if (emailIntent == null || numActions >= MAX_NOTIF_ACTIONS) { 394 contentView.setViewVisibility(R.id.email_button, View.GONE); 395 } else { 396 contentView.setViewVisibility(R.id.email_button, View.VISIBLE); 397 contentView.setOnClickPendingIntent(R.id.email_button, emailIntent); 398 contentView.setViewVisibility(R.id.end_padding, View.GONE); 399 numActions++; 400 } 401 if (snoozeIntent == null || numActions >= MAX_NOTIF_ACTIONS) { 402 contentView.setViewVisibility(R.id.snooze_button, View.GONE); 403 } else { 404 contentView.setViewVisibility(R.id.snooze_button, View.VISIBLE); 405 contentView.setOnClickPendingIntent(R.id.snooze_button, snoozeIntent); 406 contentView.setViewVisibility(R.id.end_padding, View.GONE); 407 numActions++; 408 } 409 410 n.contentView = contentView; 411 412 return n; 413 } 414 } 415 416 /** 417 * Creates an expanding notification. The initial expanded state is decided by 418 * the notification manager based on the priority. 419 */ 420 public static NotificationWrapper makeExpandingNotification(Context context, String title, 421 String summaryText, String description, long startMillis, long endMillis, long eventId, 422 int notificationId, boolean doPopup, int priority) { 423 Notification.Builder basicBuilder = new Notification.Builder(context); 424 Notification notification = buildBasicNotification(basicBuilder, context, title, 425 summaryText, startMillis, endMillis, eventId, notificationId, doPopup, 426 priority, true); 427 if (Utils.isJellybeanOrLater()) { 428 // Create a new-style expanded notification 429 Notification.BigTextStyle expandedBuilder = new Notification.BigTextStyle( 430 basicBuilder); 431 if (description != null) { 432 description = mBlankLinePattern.matcher(description).replaceAll(""); 433 description = description.trim(); 434 } 435 CharSequence text; 436 if (TextUtils.isEmpty(description)) { 437 text = summaryText; 438 } else { 439 SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); 440 stringBuilder.append(summaryText); 441 stringBuilder.append("\n\n"); 442 stringBuilder.setSpan(new RelativeSizeSpan(0.5f), summaryText.length(), 443 stringBuilder.length(), 0); 444 stringBuilder.append(description); 445 text = stringBuilder; 446 } 447 expandedBuilder.bigText(text); 448 notification = expandedBuilder.build(); 449 } 450 return new NotificationWrapper(notification, notificationId, eventId, startMillis, 451 endMillis, doPopup); 452 } 453 454 /** 455 * Creates an expanding digest notification for expired events. 456 */ 457 public static NotificationWrapper makeDigestNotification(Context context, 458 ArrayList<AlertService.NotificationInfo> notificationInfos, String digestTitle, 459 boolean expandable) { 460 if (notificationInfos == null || notificationInfos.size() < 1) { 461 return null; 462 } 463 464 Resources res = context.getResources(); 465 int numEvents = notificationInfos.size(); 466 long[] eventIds = new long[notificationInfos.size()]; 467 long[] startMillis = new long[notificationInfos.size()]; 468 for (int i = 0; i < notificationInfos.size(); i++) { 469 eventIds[i] = notificationInfos.get(i).eventId; 470 startMillis[i] = notificationInfos.get(i).startMillis; 471 } 472 473 // Create an intent triggered by clicking on the status icon that shows the alerts list. 474 PendingIntent pendingClickIntent = createAlertActivityIntent(context); 475 476 // Create an intent triggered by dismissing the digest notification that clears all 477 // expired events. 478 Intent deleteIntent = new Intent(); 479 deleteIntent.setClass(context, DismissAlarmsService.class); 480 deleteIntent.setAction(DELETE_ALL_ACTION); 481 deleteIntent.putExtra(AlertUtils.EVENT_IDS_KEY, eventIds); 482 deleteIntent.putExtra(AlertUtils.EVENT_STARTS_KEY, startMillis); 483 PendingIntent pendingDeleteIntent = PendingIntent.getService(context, 0, deleteIntent, 484 PendingIntent.FLAG_UPDATE_CURRENT); 485 486 if (digestTitle == null || digestTitle.length() == 0) { 487 digestTitle = res.getString(R.string.no_title_label); 488 } 489 490 Notification.Builder notificationBuilder = new Notification.Builder(context); 491 notificationBuilder.setContentText(digestTitle); 492 notificationBuilder.setSmallIcon(R.drawable.stat_notify_calendar_multiple); 493 notificationBuilder.setContentIntent(pendingClickIntent); 494 notificationBuilder.setDeleteIntent(pendingDeleteIntent); 495 String nEventsStr = res.getQuantityString(R.plurals.Nevents, numEvents, numEvents); 496 notificationBuilder.setContentTitle(nEventsStr); 497 498 Notification n; 499 if (Utils.isJellybeanOrLater()) { 500 // New-style notification... 501 502 // Set to min priority to encourage the notification manager to collapse it. 503 notificationBuilder.setPriority(Notification.PRIORITY_MIN); 504 505 if (expandable) { 506 // Multiple reminders. Combine into an expanded digest notification. 507 Notification.InboxStyle expandedBuilder = new Notification.InboxStyle( 508 notificationBuilder); 509 int i = 0; 510 for (AlertService.NotificationInfo info : notificationInfos) { 511 if (i < NOTIFICATION_DIGEST_MAX_LENGTH) { 512 String name = info.eventName; 513 if (TextUtils.isEmpty(name)) { 514 name = context.getResources().getString(R.string.no_title_label); 515 } 516 String timeLocation = AlertUtils.formatTimeLocation(context, 517 info.startMillis, info.allDay, info.location); 518 519 TextAppearanceSpan primaryTextSpan = new TextAppearanceSpan(context, 520 R.style.NotificationPrimaryText); 521 TextAppearanceSpan secondaryTextSpan = new TextAppearanceSpan(context, 522 R.style.NotificationSecondaryText); 523 524 // Event title in bold. 525 SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); 526 stringBuilder.append(name); 527 stringBuilder.setSpan(primaryTextSpan, 0, stringBuilder.length(), 0); 528 stringBuilder.append(" "); 529 530 // Followed by time and location. 531 int secondaryIndex = stringBuilder.length(); 532 stringBuilder.append(timeLocation); 533 stringBuilder.setSpan(secondaryTextSpan, secondaryIndex, 534 stringBuilder.length(), 0); 535 expandedBuilder.addLine(stringBuilder); 536 i++; 537 } else { 538 break; 539 } 540 } 541 542 // If there are too many to display, add "+X missed events" for the last line. 543 int remaining = numEvents - i; 544 if (remaining > 0) { 545 String nMoreEventsStr = res.getQuantityString(R.plurals.N_remaining_events, 546 remaining, remaining); 547 // TODO: Add highlighting and icon to this last entry once framework allows it. 548 expandedBuilder.setSummaryText(nMoreEventsStr); 549 } 550 551 // Remove the title in the expanded form (redundant with the listed items). 552 expandedBuilder.setBigContentTitle(""); 553 554 n = expandedBuilder.build(); 555 } else { 556 n = notificationBuilder.build(); 557 } 558 } else { 559 // Old-style notification (pre-JB). We only need a standard notification (no 560 // buttons) but use a custom view so it is consistent with the others. 561 n = notificationBuilder.getNotification(); 562 563 // Use custom view with buttons to provide JB-like functionality (snooze/email). 564 RemoteViews contentView = new RemoteViews(context.getPackageName(), 565 R.layout.notification); 566 contentView.setImageViewResource(R.id.image, R.drawable.stat_notify_calendar_multiple); 567 contentView.setTextViewText(R.id.title, nEventsStr); 568 contentView.setTextViewText(R.id.text, digestTitle); 569 contentView.setViewVisibility(R.id.time, View.VISIBLE); 570 contentView.setViewVisibility(R.id.map_button, View.GONE); 571 contentView.setViewVisibility(R.id.call_button, View.GONE); 572 contentView.setViewVisibility(R.id.email_button, View.GONE); 573 contentView.setViewVisibility(R.id.snooze_button, View.GONE); 574 contentView.setViewVisibility(R.id.end_padding, View.VISIBLE); 575 n.contentView = contentView; 576 577 // Use timestamp to force expired digest notification to the bottom (there is no 578 // priority setting before JB release). This is hidden by the custom view. 579 n.when = 1; 580 } 581 582 NotificationWrapper nw = new NotificationWrapper(n); 583 if (AlertService.DEBUG) { 584 for (AlertService.NotificationInfo info : notificationInfos) { 585 nw.add(new NotificationWrapper(null, 0, info.eventId, info.startMillis, 586 info.endMillis, false)); 587 } 588 } 589 return nw; 590 } 591 592 private void closeNotificationShade(Context context) { 593 Intent closeNotificationShadeIntent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); 594 context.sendBroadcast(closeNotificationShadeIntent); 595 } 596 597 private static final String[] ATTENDEES_PROJECTION = new String[] { 598 Attendees.ATTENDEE_EMAIL, // 0 599 Attendees.ATTENDEE_STATUS, // 1 600 }; 601 private static final int ATTENDEES_INDEX_EMAIL = 0; 602 private static final int ATTENDEES_INDEX_STATUS = 1; 603 private static final String ATTENDEES_WHERE = Attendees.EVENT_ID + "=?"; 604 private static final String ATTENDEES_SORT_ORDER = Attendees.ATTENDEE_NAME + " ASC, " 605 + Attendees.ATTENDEE_EMAIL + " ASC"; 606 607 private static final String[] EVENT_PROJECTION = new String[] { 608 Calendars.OWNER_ACCOUNT, // 0 609 Calendars.ACCOUNT_NAME, // 1 610 Events.TITLE, // 2 611 Events.ORGANIZER, // 3 612 }; 613 private static final int EVENT_INDEX_OWNER_ACCOUNT = 0; 614 private static final int EVENT_INDEX_ACCOUNT_NAME = 1; 615 private static final int EVENT_INDEX_TITLE = 2; 616 private static final int EVENT_INDEX_ORGANIZER = 3; 617 618 private static Cursor getEventCursor(Context context, long eventId) { 619 return context.getContentResolver().query( 620 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), EVENT_PROJECTION, 621 null, null, null); 622 } 623 624 private static Cursor getAttendeesCursor(Context context, long eventId) { 625 return context.getContentResolver().query(Attendees.CONTENT_URI, 626 ATTENDEES_PROJECTION, ATTENDEES_WHERE, new String[] { Long.toString(eventId) }, 627 ATTENDEES_SORT_ORDER); 628 } 629 630 private static Cursor getLocationCursor(Context context, long eventId) { 631 return context.getContentResolver().query( 632 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), 633 new String[] { Events.EVENT_LOCATION }, null, null, null); 634 } 635 636 /** 637 * Creates a broadcast pending intent that fires to AlertReceiver when the email button 638 * is clicked. 639 */ 640 private static PendingIntent createBroadcastMailIntent(Context context, long eventId, 641 String eventTitle) { 642 // Query for viewer account. 643 String syncAccount = null; 644 Cursor eventCursor = getEventCursor(context, eventId); 645 try { 646 if (eventCursor != null && eventCursor.moveToFirst()) { 647 syncAccount = eventCursor.getString(EVENT_INDEX_ACCOUNT_NAME); 648 } 649 } finally { 650 if (eventCursor != null) { 651 eventCursor.close(); 652 } 653 } 654 655 // Query attendees to see if there are any to email. 656 Cursor attendeesCursor = getAttendeesCursor(context, eventId); 657 try { 658 if (attendeesCursor != null && attendeesCursor.moveToFirst()) { 659 do { 660 String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL); 661 if (Utils.isEmailableFrom(email, syncAccount)) { 662 Intent broadcastIntent = new Intent(MAIL_ACTION); 663 broadcastIntent.setClass(context, AlertReceiver.class); 664 broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); 665 return PendingIntent.getBroadcast(context, 666 Long.valueOf(eventId).hashCode(), broadcastIntent, 667 PendingIntent.FLAG_CANCEL_CURRENT); 668 } 669 } while (attendeesCursor.moveToNext()); 670 } 671 return null; 672 673 } finally { 674 if (attendeesCursor != null) { 675 attendeesCursor.close(); 676 } 677 } 678 } 679 680 /** 681 * Creates an Intent for emailing the attendees of the event. Returns null if there 682 * are no emailable attendees. 683 */ 684 static Intent createEmailIntent(Context context, long eventId, String body) { 685 // TODO: Refactor to move query part into Utils.createEmailAttendeeIntent, to 686 // be shared with EventInfoFragment. 687 688 // Query for the owner account(s). 689 String ownerAccount = null; 690 String syncAccount = null; 691 String eventTitle = null; 692 String eventOrganizer = null; 693 Cursor eventCursor = getEventCursor(context, eventId); 694 try { 695 if (eventCursor != null && eventCursor.moveToFirst()) { 696 ownerAccount = eventCursor.getString(EVENT_INDEX_OWNER_ACCOUNT); 697 syncAccount = eventCursor.getString(EVENT_INDEX_ACCOUNT_NAME); 698 eventTitle = eventCursor.getString(EVENT_INDEX_TITLE); 699 eventOrganizer = eventCursor.getString(EVENT_INDEX_ORGANIZER); 700 } 701 } finally { 702 if (eventCursor != null) { 703 eventCursor.close(); 704 } 705 } 706 if (TextUtils.isEmpty(eventTitle)) { 707 eventTitle = context.getResources().getString(R.string.no_title_label); 708 } 709 710 // Query for the attendees. 711 List<String> toEmails = new ArrayList<String>(); 712 List<String> ccEmails = new ArrayList<String>(); 713 Cursor attendeesCursor = getAttendeesCursor(context, eventId); 714 try { 715 if (attendeesCursor != null && attendeesCursor.moveToFirst()) { 716 do { 717 int status = attendeesCursor.getInt(ATTENDEES_INDEX_STATUS); 718 String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL); 719 switch(status) { 720 case Attendees.ATTENDEE_STATUS_DECLINED: 721 addIfEmailable(ccEmails, email, syncAccount); 722 break; 723 default: 724 addIfEmailable(toEmails, email, syncAccount); 725 } 726 } while (attendeesCursor.moveToNext()); 727 } 728 } finally { 729 if (attendeesCursor != null) { 730 attendeesCursor.close(); 731 } 732 } 733 734 // Add organizer only if no attendees to email (the case when too many attendees 735 // in the event to sync or show). 736 if (toEmails.size() == 0 && ccEmails.size() == 0 && eventOrganizer != null) { 737 addIfEmailable(toEmails, eventOrganizer, syncAccount); 738 } 739 740 Intent intent = null; 741 if (ownerAccount != null && (toEmails.size() > 0 || ccEmails.size() > 0)) { 742 intent = Utils.createEmailAttendeesIntent(context.getResources(), eventTitle, body, 743 toEmails, ccEmails, ownerAccount); 744 } 745 746 if (intent == null) { 747 return null; 748 } 749 else { 750 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 751 return intent; 752 } 753 } 754 755 private static void addIfEmailable(List<String> emailList, String email, String syncAccount) { 756 if (Utils.isEmailableFrom(email, syncAccount)) { 757 emailList.add(email); 758 } 759 } 760 761 /** 762 * Using the linkify magic, get a list of URLs from the event's location. If no such links 763 * are found, we should end up with a single geo link of the entire string. 764 */ 765 private static URLSpan[] getURLSpans(Context context, long eventId) { 766 Cursor locationCursor = getLocationCursor(context, eventId); 767 if (locationCursor != null && locationCursor.moveToFirst()) { 768 String location = locationCursor.getString(0); // Only one item in this cursor. 769 if (location == null || location.isEmpty()) { 770 // Return an empty list if we know there was nothing in the location field. 771 return new URLSpan[0]; 772 } 773 774 Spannable text = Utils.extendedLinkify(location, true); 775 776 // The linkify method should have found at least one link, at the very least. 777 // If no smart links were found, it should have set the whole string as a geo link. 778 URLSpan[] urlSpans = text.getSpans(0, text.length(), URLSpan.class); 779 return urlSpans; 780 } 781 782 // If no links were found or location was empty, return an empty list. 783 return new URLSpan[0]; 784 } 785 786 /** 787 * Create a pending intent to send ourself a broadcast to start maps, using the first map 788 * link available. 789 * If no links are found, return null. 790 */ 791 private static PendingIntent createMapBroadcastIntent(Context context, URLSpan[] urlSpans, 792 long eventId) { 793 for (int span_i = 0; span_i < urlSpans.length; span_i++) { 794 URLSpan urlSpan = urlSpans[span_i]; 795 String urlString = urlSpan.getURL(); 796 if (urlString.startsWith(GEO_PREFIX)) { 797 Intent broadcastIntent = new Intent(MAP_ACTION); 798 broadcastIntent.setClass(context, AlertReceiver.class); 799 broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); 800 return PendingIntent.getBroadcast(context, 801 Long.valueOf(eventId).hashCode(), broadcastIntent, 802 PendingIntent.FLAG_CANCEL_CURRENT); 803 } 804 } 805 806 // No geo link was found, so return null; 807 return null; 808 } 809 810 /** 811 * Create an intent to take the user to maps, using the first map link available. 812 * If no links are found, return null. 813 */ 814 private static Intent createMapActivityIntent(Context context, URLSpan[] urlSpans) { 815 for (int span_i = 0; span_i < urlSpans.length; span_i++) { 816 URLSpan urlSpan = urlSpans[span_i]; 817 String urlString = urlSpan.getURL(); 818 if (urlString.startsWith(GEO_PREFIX)) { 819 Intent geoIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlString)); 820 geoIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 821 return geoIntent; 822 } 823 } 824 825 // No geo link was found, so return null; 826 return null; 827 } 828 829 /** 830 * Create a pending intent to send ourself a broadcast to take the user to dialer, or any other 831 * app capable of making phone calls. Use the first phone number available. If no phone number 832 * is found, or if the device is not capable of making phone calls (i.e. a tablet), return null. 833 */ 834 private static PendingIntent createCallBroadcastIntent(Context context, URLSpan[] urlSpans, 835 long eventId) { 836 // Return null if the device is unable to make phone calls. 837 TelephonyManager tm = 838 (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 839 if (tm.getPhoneType() == TelephonyManager.PHONE_TYPE_NONE) { 840 return null; 841 } 842 843 for (int span_i = 0; span_i < urlSpans.length; span_i++) { 844 URLSpan urlSpan = urlSpans[span_i]; 845 String urlString = urlSpan.getURL(); 846 if (urlString.startsWith(TEL_PREFIX)) { 847 Intent broadcastIntent = new Intent(CALL_ACTION); 848 broadcastIntent.setClass(context, AlertReceiver.class); 849 broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); 850 return PendingIntent.getBroadcast(context, 851 Long.valueOf(eventId).hashCode(), broadcastIntent, 852 PendingIntent.FLAG_CANCEL_CURRENT); 853 } 854 } 855 856 // No tel link was found, so return null; 857 return null; 858 } 859 860 /** 861 * Create an intent to take the user to dialer, or any other app capable of making phone calls. 862 * Use the first phone number available. If no phone number is found, or if the device is 863 * not capable of making phone calls (i.e. a tablet), return null. 864 */ 865 private static Intent createCallActivityIntent(Context context, URLSpan[] urlSpans) { 866 // Return null if the device is unable to make phone calls. 867 TelephonyManager tm = 868 (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 869 if (tm.getPhoneType() == TelephonyManager.PHONE_TYPE_NONE) { 870 return null; 871 } 872 873 for (int span_i = 0; span_i < urlSpans.length; span_i++) { 874 URLSpan urlSpan = urlSpans[span_i]; 875 String urlString = urlSpan.getURL(); 876 if (urlString.startsWith(TEL_PREFIX)) { 877 Intent callIntent = new Intent(Intent.ACTION_DIAL, Uri.parse(urlString)); 878 callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 879 return callIntent; 880 } 881 } 882 883 // No tel link was found, so return null; 884 return null; 885 } 886 } 887