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