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