Home | History | Annotate | Download | only in alerts
      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