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