AlertReceiver.java revision ab94b747c63244ed2e406e886f19b308d7b57fc6
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 com.android.calendar.R;
20import com.android.calendar.Utils;
21
22import android.app.Notification;
23import android.app.PendingIntent;
24import android.app.Service;
25import android.content.BroadcastReceiver;
26import android.content.ContentResolver;
27import android.content.ContentUris;
28import android.content.Context;
29import android.content.Intent;
30import android.content.res.Resources;
31import android.database.Cursor;
32import android.net.Uri;
33import android.os.Handler;
34import android.os.HandlerThread;
35import android.os.PowerManager;
36import android.provider.CalendarContract.Attendees;
37import android.provider.CalendarContract.Calendars;
38import android.provider.CalendarContract.Events;
39import android.text.SpannableStringBuilder;
40import android.text.TextUtils;
41import android.text.style.TextAppearanceSpan;
42import android.util.Log;
43
44import java.util.ArrayList;
45import java.util.List;
46
47/**
48 * Receives android.intent.action.EVENT_REMINDER intents and handles
49 * event reminders.  The intent URI specifies an alert id in the
50 * CalendarAlerts database table.  This class also receives the
51 * BOOT_COMPLETED intent so that it can add a status bar notification
52 * if there are Calendar event alarms that have not been dismissed.
53 * It also receives the TIME_CHANGED action so that it can fire off
54 * snoozed alarms that have become ready.  The real work is done in
55 * the AlertService class.
56 *
57 * To trigger this code after pushing the apk to device:
58 * adb shell am broadcast -a "android.intent.action.EVENT_REMINDER"
59 *    -n "com.android.calendar/.alerts.AlertReceiver"
60 */
61public class AlertReceiver extends BroadcastReceiver {
62    private static final String TAG = "AlertReceiver";
63
64    private static final String DELETE_ALL_ACTION = "com.android.calendar.DELETEALL";
65    private static final String MAIL_ACTION = "com.android.calendar.MAIL";
66    private static final String EXTRA_EVENT_ID = "eventid";
67
68    static final Object mStartingServiceSync = new Object();
69    static PowerManager.WakeLock mStartingService;
70
71    public static final String ACTION_DISMISS_OLD_REMINDERS = "removeOldReminders";
72    private static final int NOTIFICATION_DIGEST_MAX_LENGTH = 3;
73
74    private static Handler sAsyncHandler;
75    static {
76        HandlerThread thr = new HandlerThread("AlertReceiver async");
77        thr.start();
78        sAsyncHandler = new Handler(thr.getLooper());
79    }
80
81    @Override
82    public void onReceive(final Context context, final Intent intent) {
83        if (AlertService.DEBUG) {
84            Log.d(TAG, "onReceive: a=" + intent.getAction() + " " + intent.toString());
85        }
86        if (DELETE_ALL_ACTION.equals(intent.getAction())) {
87
88            /* The user has clicked the "Clear All Notifications"
89             * buttons so dismiss all Calendar alerts.
90             */
91            // TODO Grab a wake lock here?
92            Intent serviceIntent = new Intent(context, DismissAlarmsService.class);
93            context.startService(serviceIntent);
94        } else if (MAIL_ACTION.equals(intent.getAction())) {
95            // Close the notification shade.
96            Intent closeNotificationShadeIntent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
97            context.sendBroadcast(closeNotificationShadeIntent);
98
99            // Now start the email intent.
100            final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1);
101            if (eventId != -1) {
102                final PendingResult result = goAsync();
103                Runnable worker = new Runnable() {
104                    @Override
105                    public void run() {
106                        Intent emailIntent = createEmailIntent(context, eventId);
107                        if (emailIntent != null) {
108                            context.startActivity(emailIntent);
109                        }
110                        result.finish();
111                    }
112                };
113                sAsyncHandler.post(worker);
114            }
115        } else {
116            Intent i = new Intent();
117            i.setClass(context, AlertService.class);
118            i.putExtras(intent);
119            i.putExtra("action", intent.getAction());
120            Uri uri = intent.getData();
121
122            // This intent might be a BOOT_COMPLETED so it might not have a Uri.
123            if (uri != null) {
124                i.putExtra("uri", uri.toString());
125            }
126            beginStartingService(context, i);
127        }
128    }
129
130    /**
131     * Start the service to process the current event notifications, acquiring
132     * the wake lock before returning to ensure that the service will run.
133     */
134    public static void beginStartingService(Context context, Intent intent) {
135        synchronized (mStartingServiceSync) {
136            if (mStartingService == null) {
137                PowerManager pm =
138                    (PowerManager)context.getSystemService(Context.POWER_SERVICE);
139                mStartingService = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
140                        "StartingAlertService");
141                mStartingService.setReferenceCounted(false);
142            }
143            mStartingService.acquire();
144            context.startService(intent);
145        }
146    }
147
148    /**
149     * Called back by the service when it has finished processing notifications,
150     * releasing the wake lock if the service is now stopping.
151     */
152    public static void finishStartingService(Service service, int startId) {
153        synchronized (mStartingServiceSync) {
154            if (mStartingService != null) {
155                if (service.stopSelfResult(startId)) {
156                    mStartingService.release();
157                }
158            }
159        }
160    }
161
162    private static PendingIntent createClickEventIntent(Context context, long eventId,
163            long startMillis, long endMillis, int notificationId) {
164        return createDismissAlarmsIntent(context, eventId, startMillis, endMillis, notificationId,
165                "com.android.calendar.CLICK", true);
166    }
167
168    private static PendingIntent createDeleteEventIntent(Context context, long eventId,
169            long startMillis, long endMillis, int notificationId) {
170        return createDismissAlarmsIntent(context, eventId, startMillis, endMillis, notificationId,
171                "com.android.calendar.DELETE", false);
172    }
173
174    private static PendingIntent createDismissAlarmsIntent(Context context, long eventId,
175            long startMillis, long endMillis, int notificationId, String action,
176            boolean showEvent) {
177        Intent intent = new Intent();
178        intent.setClass(context, DismissAlarmsService.class);
179        intent.putExtra(AlertUtils.EVENT_ID_KEY, eventId);
180        intent.putExtra(AlertUtils.EVENT_START_KEY, startMillis);
181        intent.putExtra(AlertUtils.EVENT_END_KEY, endMillis);
182        intent.putExtra(AlertUtils.SHOW_EVENT_KEY, showEvent);
183        intent.putExtra(AlertUtils.NOTIFICATION_ID_KEY, notificationId);
184
185        // Must set a field that affects Intent.filterEquals so that the resulting
186        // PendingIntent will be a unique instance (the 'extras' don't achieve this).
187        // This must be unique for the click event across all reminders (so using
188        // event ID + startTime should be unique).  This also must be unique from
189        // the delete event (which also uses DismissAlarmsService).
190        Uri.Builder builder = Events.CONTENT_URI.buildUpon();
191        ContentUris.appendId(builder, eventId);
192        ContentUris.appendId(builder, startMillis);
193        intent.setData(builder.build());
194        intent.setAction(action);
195        return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
196    }
197
198    private static PendingIntent createSnoozeIntent(Context context, long eventId,
199            long startMillis, long endMillis, int notificationId) {
200        Intent intent = new Intent();
201        intent.setClass(context, SnoozeAlarmsService.class);
202        intent.putExtra(AlertUtils.EVENT_ID_KEY, eventId);
203        intent.putExtra(AlertUtils.EVENT_START_KEY, startMillis);
204        intent.putExtra(AlertUtils.EVENT_END_KEY, endMillis);
205        intent.putExtra(AlertUtils.NOTIFICATION_ID_KEY, notificationId);
206
207        Uri.Builder builder = Events.CONTENT_URI.buildUpon();
208        ContentUris.appendId(builder, eventId);
209        ContentUris.appendId(builder, startMillis);
210        intent.setData(builder.build());
211        return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
212    }
213
214    public static Notification makeBasicNotification(Context context, String title,
215            String summaryText, long startMillis, long endMillis, long eventId,
216            int notificationId, boolean doPopup) {
217        return makeBasicNotificationBuilder(context, title, summaryText, startMillis, endMillis,
218                eventId, notificationId, doPopup, false, false).build();
219    }
220
221    private static Notification.Builder makeBasicNotificationBuilder(Context context, String title,
222            String summaryText, long startMillis, long endMillis, long eventId,
223            int notificationId, boolean doPopup, boolean highPriority, boolean addActionButtons) {
224        Resources resources = context.getResources();
225        if (title == null || title.length() == 0) {
226            title = resources.getString(R.string.no_title_label);
227        }
228
229        // Create an intent triggered by clicking on the status icon, that dismisses the
230        // notification and shows the event.
231        PendingIntent clickIntent = createClickEventIntent(context, eventId, startMillis,
232                endMillis, notificationId);
233
234        // Create a delete intent triggered by dismissing the notification.
235        PendingIntent deleteIntent = createDeleteEventIntent(context, eventId, startMillis,
236            endMillis, notificationId);
237
238        // Create the base notification.
239        Notification.Builder notificationBuilder = new Notification.Builder(context);
240        notificationBuilder.setContentTitle(title);
241        notificationBuilder.setContentText(summaryText);
242        notificationBuilder.setSmallIcon(R.drawable.stat_notify_calendar);
243        notificationBuilder.setContentIntent(clickIntent);
244        notificationBuilder.setDeleteIntent(deleteIntent);
245        if (addActionButtons) {
246            // Create a snooze button.  TODO: change snooze to 10 minutes.
247            PendingIntent snoozeIntent = createSnoozeIntent(context, eventId, startMillis,
248                    endMillis, notificationId);
249            notificationBuilder.addAction(R.drawable.snooze,
250                    resources.getString(R.string.snooze_5min_label), snoozeIntent);
251
252            // Create an email button.
253            PendingIntent emailIntent = createBroadcastMailIntent(context, eventId, title);
254            if (emailIntent != null) {
255                notificationBuilder.addAction(R.drawable.ic_menu_email_holo_dark,
256                        resources.getString(R.string.email_guests_label), emailIntent);
257            }
258        }
259        if (doPopup) {
260            notificationBuilder.setFullScreenIntent(clickIntent, true);
261        }
262
263        // Turn off timestamp.
264        notificationBuilder.setWhen(0);
265
266        // Setting to a higher priority will encourage notification manager to expand the
267        // notification.
268        if (highPriority) {
269            notificationBuilder.setPriority(Notification.PRIORITY_HIGH);
270        } else {
271            notificationBuilder.setPriority(Notification.PRIORITY_DEFAULT);
272        }
273        return notificationBuilder;
274    }
275
276    /**
277     * Creates an expanding notification.  The initial expanded state is decided by
278     * the notification manager based on the priority.
279     */
280    public static Notification makeExpandingNotification(Context context, String title,
281            String summaryText, String description, long startMillis, long endMillis, long eventId,
282            int notificationId, boolean doPopup, boolean highPriority) {
283        Notification.Builder basicBuilder = makeBasicNotificationBuilder(context, title,
284                summaryText, startMillis, endMillis, eventId, notificationId,
285                doPopup, highPriority, true);
286
287        // Create an expanded notification
288        Notification.BigTextStyle expandedBuilder = new Notification.BigTextStyle(
289                basicBuilder);
290        if (description != null) {
291            description = description.trim();
292        }
293        String text;
294        if (TextUtils.isEmpty(description)) {
295            text = summaryText;
296        } else {
297            text = context.getResources().getString(
298                    R.string.event_notification_big_text, summaryText, description);
299        }
300        expandedBuilder.bigText(text);
301        return expandedBuilder.build();
302    }
303
304    /**
305     * Creates an expanding digest notification for expired events.
306     */
307    public static Notification makeDigestNotification(Context context,
308            List<AlertService.NotificationInfo> notificationInfos, String digestTitle,
309            boolean expandable) {
310        if (notificationInfos == null || notificationInfos.size() < 1) {
311            return null;
312        }
313
314        Resources res = context.getResources();
315        int numEvents = notificationInfos.size();
316
317        // Create an intent triggered by clicking on the status icon that shows the alerts list.
318        Intent clickIntent = new Intent();
319        clickIntent.setClass(context, AlertActivity.class);
320        clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
321        PendingIntent pendingClickIntent = PendingIntent.getActivity(context, 0, clickIntent,
322                    PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
323
324        // Create an intent triggered by dismissing the digest notification that clears all
325        // expired events.
326        Intent deleteIntent = new Intent();
327        deleteIntent.setClass(context, DismissAlarmsService.class);
328        deleteIntent.setAction(DELETE_ALL_ACTION);
329        deleteIntent.putExtra(AlertUtils.DELETE_EXPIRED_ONLY_KEY, true);
330        PendingIntent pendingDeleteIntent = PendingIntent.getService(context, 0, deleteIntent,
331                PendingIntent.FLAG_UPDATE_CURRENT);
332
333        if (digestTitle == null || digestTitle.length() == 0) {
334            digestTitle = res.getString(R.string.no_title_label);
335        }
336
337        Notification.Builder notificationBuilder = new Notification.Builder(context);
338        notificationBuilder.setContentText(digestTitle);
339        notificationBuilder.setSmallIcon(R.drawable.stat_notify_calendar);
340        notificationBuilder.setContentIntent(pendingClickIntent);
341        notificationBuilder.setDeleteIntent(pendingDeleteIntent);
342        String nEventsStr = res.getQuantityString(R.plurals.Nevents, numEvents, numEvents);
343        notificationBuilder.setContentTitle(nEventsStr);
344
345        // Set to min priority to encourage the notification manager to collapse it.
346        notificationBuilder.setPriority(Notification.PRIORITY_MIN);
347
348        if (expandable) {
349            // Multiple reminders.  Combine into an expanded digest notification.
350            Notification.InboxStyle expandedBuilder = new Notification.InboxStyle(
351                    notificationBuilder);
352            int i = 0;
353            for (AlertService.NotificationInfo info : notificationInfos) {
354                if (i < NOTIFICATION_DIGEST_MAX_LENGTH) {
355                    String name = info.eventName;
356                    if (TextUtils.isEmpty(name)) {
357                        name = context.getResources().getString(R.string.no_title_label);
358                    }
359                    String timeLocation = AlertUtils.formatTimeLocation(context, info.startMillis,
360                            info.allDay, info.location);
361
362                    TextAppearanceSpan primaryTextSpan = new TextAppearanceSpan(context,
363                            R.style.NotificationPrimaryText);
364                    TextAppearanceSpan secondaryTextSpan = new TextAppearanceSpan(context,
365                            R.style.NotificationSecondaryText);
366
367                    // Event title in bold.
368                    SpannableStringBuilder stringBuilder = new SpannableStringBuilder();
369                    stringBuilder.append(name);
370                    stringBuilder.setSpan(primaryTextSpan, 0, stringBuilder.length(), 0);
371                    stringBuilder.append("  ");
372
373                    // Followed by time and location.
374                    int secondaryIndex = stringBuilder.length();
375                    stringBuilder.append(timeLocation);
376                    stringBuilder.setSpan(secondaryTextSpan, secondaryIndex, stringBuilder.length(),
377                            0);
378                    expandedBuilder.addLine(stringBuilder);
379                    i++;
380                } else {
381                    break;
382                }
383            }
384
385            // If there are too many to display, add "+X missed events" for the last line.
386            int remaining = numEvents - i;
387            if (remaining > 0) {
388                String nMoreEventsStr = res.getQuantityString(R.plurals.N_missed_events, remaining,
389                        remaining);
390                // TODO: Add highlighting and icon to this last entry once framework allows it.
391                expandedBuilder.setSummaryText(nMoreEventsStr);
392            }
393
394            // Remove the title in the expanded form (redundant with the listed items).
395            expandedBuilder.setBigContentTitle("");
396
397            return expandedBuilder.build();
398        } else {
399            return notificationBuilder.build();
400        }
401    }
402
403    private static final String[] ATTENDEES_PROJECTION = new String[] {
404        Attendees.ATTENDEE_EMAIL,           // 0
405        Attendees.ATTENDEE_STATUS,          // 1
406    };
407    private static final int ATTENDEES_INDEX_EMAIL = 0;
408    private static final int ATTENDEES_INDEX_STATUS = 1;
409    private static final String ATTENDEES_WHERE = Attendees.EVENT_ID + "=?";
410    private static final String ATTENDEES_SORT_ORDER = Attendees.ATTENDEE_NAME + " ASC, "
411            + Attendees.ATTENDEE_EMAIL + " ASC";
412
413    private static final String[] EVENT_PROJECTION = new String[] {
414        Calendars.OWNER_ACCOUNT, // 0
415        Calendars.ACCOUNT_NAME,  // 1
416        Events.TITLE,            // 2
417    };
418    private static final int EVENT_INDEX_OWNER_ACCOUNT = 0;
419    private static final int EVENT_INDEX_ACCOUNT_NAME = 1;
420    private static final int EVENT_INDEX_TITLE = 2;
421
422    private static Cursor getEventCursor(Context context, long eventId) {
423        return context.getContentResolver().query(
424                ContentUris.withAppendedId(Events.CONTENT_URI, eventId), EVENT_PROJECTION,
425                null, null, null);
426    }
427
428    private static Cursor getAttendeesCursor(Context context, long eventId) {
429        return context.getContentResolver().query(Attendees.CONTENT_URI,
430                ATTENDEES_PROJECTION, ATTENDEES_WHERE, new String[] { Long.toString(eventId) },
431                ATTENDEES_SORT_ORDER);
432    }
433
434    /**
435     * Creates a broadcast pending intent that fires to AlertReceiver when the email button
436     * is clicked.
437     */
438    private static PendingIntent createBroadcastMailIntent(Context context, long eventId,
439            String eventTitle) {
440        // Query for viewer account.
441        String syncAccount = null;
442        Cursor eventCursor = getEventCursor(context, eventId);
443        try {
444            if (eventCursor != null && eventCursor.moveToFirst()) {
445                syncAccount = eventCursor.getString(EVENT_INDEX_ACCOUNT_NAME);
446            }
447        } finally {
448            if (eventCursor != null) {
449                eventCursor.close();
450            }
451        }
452
453        // Query attendees to see if there are any to email.
454        Cursor attendeesCursor = getAttendeesCursor(context, eventId);
455        try {
456            if (attendeesCursor != null && attendeesCursor.moveToFirst()) {
457                do {
458                    String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL);
459                    if (Utils.isEmailableFrom(email, syncAccount)) {
460                        // Send intent back to ourself first for a couple reasons:
461                        // 1) Workaround issue where clicking action button in notification does
462                        //    not automatically close the notification shade.
463                        // 2) Attendees list in email will always be up to date.
464                        Intent broadcastIntent = new Intent(MAIL_ACTION);
465                        broadcastIntent.setClass(context, AlertReceiver.class);
466                        broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId);
467                        return PendingIntent.getBroadcast(context,
468                                Long.valueOf(eventId).hashCode(), broadcastIntent,
469                                PendingIntent.FLAG_CANCEL_CURRENT);
470                    }
471                } while (attendeesCursor.moveToNext());
472            }
473            return null;
474
475        } finally {
476            if (attendeesCursor != null) {
477                attendeesCursor.close();
478            }
479        }
480    }
481
482    /**
483     * Creates an Intent for emailing the attendees of the event.  Returns null if there
484     * are no emailable attendees.
485     */
486    private static Intent createEmailIntent(Context context, long eventId) {
487        ContentResolver resolver = context.getContentResolver();
488
489        // TODO: Refactor to move query part into Utils.createEmailAttendeeIntent, to
490        // be shared with EventInfoFragment.
491
492        // Query for the owner account(s).
493        String ownerAccount = null;
494        String syncAccount = null;
495        String eventTitle = null;
496        Cursor eventCursor = getEventCursor(context, eventId);
497        try {
498            if (eventCursor != null && eventCursor.moveToFirst()) {
499                ownerAccount = eventCursor.getString(EVENT_INDEX_OWNER_ACCOUNT);
500                syncAccount = eventCursor.getString(EVENT_INDEX_ACCOUNT_NAME);
501                eventTitle = eventCursor.getString(EVENT_INDEX_TITLE);
502            }
503        } finally {
504            if (eventCursor != null) {
505                eventCursor.close();
506            }
507        }
508        if (TextUtils.isEmpty(eventTitle)) {
509            eventTitle = context.getResources().getString(R.string.no_title_label);
510        }
511
512        // Query for the attendees.
513        List<String> toEmails = new ArrayList<String>();
514        List<String> ccEmails = new ArrayList<String>();
515        Cursor attendeesCursor = getAttendeesCursor(context, eventId);
516        try {
517            if (attendeesCursor != null && attendeesCursor.moveToFirst()) {
518                do {
519                    int status = attendeesCursor.getInt(ATTENDEES_INDEX_STATUS);
520                    String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL);
521                    switch(status) {
522                        case Attendees.ATTENDEE_STATUS_DECLINED:
523                            addIfEmailable(ccEmails, email, syncAccount);
524                            break;
525                        default:
526                            addIfEmailable(toEmails, email, syncAccount);
527                    }
528                } while (attendeesCursor.moveToNext());
529            }
530        } finally {
531            if (attendeesCursor != null) {
532                attendeesCursor.close();
533            }
534        }
535
536        Intent intent = null;
537        if (ownerAccount != null && (toEmails.size() > 0 || ccEmails.size() > 0)) {
538            intent = Utils.createEmailAttendeesIntent(context.getResources(), eventTitle,
539                    toEmails, ccEmails, ownerAccount);
540        }
541
542        if (intent == null) {
543            return null;
544        }
545        else {
546            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
547            return intent;
548        }
549    }
550
551    private static void addIfEmailable(List<String> emailList, String email, String syncAccount) {
552        if (Utils.isEmailableFrom(email, syncAccount)) {
553            emailList.add(email);
554        }
555    }
556}
557