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"); thr.start()97 thr.start(); 98 sAsyncHandler = new Handler(thr.getLooper()); 99 } 100 101 @Override onReceive(final Context context, final Intent intent)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 */ beginStartingService(Context context, Intent intent)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 */ finishStartingService(Service service, int startId)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 createClickEventIntent(Context context, long eventId, long startMillis, long endMillis, int notificationId)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 createDeleteEventIntent(Context context, long eventId, long startMillis, long endMillis, int notificationId)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 createDismissAlarmsIntent(Context context, long eventId, long startMillis, long endMillis, int notificationId, String action, boolean showEvent)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 createSnoozeIntent(Context context, long eventId, long startMillis, long endMillis, int notificationId)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 createAlertActivityIntent(Context context)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 makeBasicNotification(Context context, String title, String summaryText, long startMillis, long endMillis, long eventId, int notificationId, boolean doPopup, int priority)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 buildBasicNotification(Notification.Builder notificationBuilder, Context context, String title, String summaryText, long startMillis, long endMillis, long eventId, int notificationId, boolean doPopup, int priority, boolean addActionButtons)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 */ makeExpandingNotification(Context context, String title, String summaryText, String description, long startMillis, long endMillis, long eventId, int notificationId, boolean doPopup, int priority)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 */ makeDigestNotification(Context context, ArrayList<AlertService.NotificationInfo> notificationInfos, String digestTitle, boolean expandable)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 closeNotificationShade(Context context)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 getEventCursor(Context context, long eventId)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 getAttendeesCursor(Context context, long eventId)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 getLocationCursor(Context context, long eventId)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 */ createBroadcastMailIntent(Context context, long eventId, String eventTitle)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 */ createEmailIntent(Context context, long eventId, String body)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 addIfEmailable(List<String> emailList, String email, String syncAccount)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 */ getURLSpans(Context context, long eventId)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 */ createMapBroadcastIntent(Context context, URLSpan[] urlSpans, long eventId)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 */ createMapActivityIntent(Context context, URLSpan[] urlSpans)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 */ createCallBroadcastIntent(Context context, URLSpan[] urlSpans, long eventId)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 */ createCallActivityIntent(Context context, URLSpan[] urlSpans)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