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