1/*
2 * Copyright (C) 2015 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.messaging.datamodel;
18
19import android.app.Notification;
20import android.app.PendingIntent;
21import android.content.Context;
22import android.content.Intent;
23import android.content.pm.PackageManager.NameNotFoundException;
24import android.content.res.Resources;
25import android.graphics.Bitmap;
26import android.graphics.Bitmap.Config;
27import android.graphics.BitmapFactory;
28import android.graphics.Typeface;
29import android.media.AudioManager;
30import android.net.Uri;
31import android.os.Bundle;
32import android.os.SystemClock;
33import android.provider.ContactsContract;
34import android.provider.ContactsContract.Contacts;
35import android.support.v4.app.NotificationCompat;
36import android.support.v4.app.NotificationCompat.WearableExtender;
37import android.support.v4.app.NotificationManagerCompat;
38import android.support.v4.app.RemoteInput;
39import android.support.v4.util.SimpleArrayMap;
40import android.text.Spannable;
41import android.text.SpannableStringBuilder;
42import android.text.TextUtils;
43import android.text.style.StyleSpan;
44import android.text.style.TextAppearanceSpan;
45
46import com.android.messaging.Factory;
47import com.android.messaging.R;
48import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState;
49import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo;
50import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState;
51import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState;
52import com.android.messaging.datamodel.action.MarkAsReadAction;
53import com.android.messaging.datamodel.action.MarkAsSeenAction;
54import com.android.messaging.datamodel.action.RedownloadMmsAction;
55import com.android.messaging.datamodel.data.ConversationListItemData;
56import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
57import com.android.messaging.datamodel.media.ImageResource;
58import com.android.messaging.datamodel.media.MediaRequest;
59import com.android.messaging.datamodel.media.MediaResourceManager;
60import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor;
61import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
62import com.android.messaging.datamodel.media.VideoThumbnailRequest;
63import com.android.messaging.sms.MmsSmsUtils;
64import com.android.messaging.sms.MmsUtils;
65import com.android.messaging.ui.UIIntents;
66import com.android.messaging.util.Assert;
67import com.android.messaging.util.AvatarUriUtil;
68import com.android.messaging.util.BugleGservices;
69import com.android.messaging.util.BugleGservicesKeys;
70import com.android.messaging.util.BuglePrefs;
71import com.android.messaging.util.BuglePrefsKeys;
72import com.android.messaging.util.ContentType;
73import com.android.messaging.util.ConversationIdSet;
74import com.android.messaging.util.ImageUtils;
75import com.android.messaging.util.LogUtil;
76import com.android.messaging.util.NotificationPlayer;
77import com.android.messaging.util.OsUtil;
78import com.android.messaging.util.PendingIntentConstants;
79import com.android.messaging.util.PhoneUtils;
80import com.android.messaging.util.RingtoneUtil;
81import com.android.messaging.util.ThreadUtil;
82import com.android.messaging.util.UriUtil;
83
84import java.util.HashSet;
85import java.util.Iterator;
86import java.util.List;
87import java.util.Locale;
88import java.util.Set;
89
90/**
91 * Handle posting, updating and removing all conversation notifications.
92 *
93 * There are currently two main classes of notification and their rules: <p>
94 * 1) Messages - {@link MessageNotificationState}. Only one message notification.
95 * Unread messages across senders and conversations are coalesced.<p>
96 * 2) Failed Messages - {@link MessageNotificationState#checkFailedMesages } Only one failed
97 * message. Multiple failures are coalesced.<p>
98 *
99 * To add a new class of notifications, subclass the NotificationState and add commands which
100 * create one and pass into general creation function.
101 *
102 */
103public class BugleNotifications {
104    // Logging
105    public static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG;
106
107    // Constants to use for update.
108    public static final int UPDATE_NONE = 0;
109    public static final int UPDATE_MESSAGES = 1;
110    public static final int UPDATE_ERRORS = 2;
111    public static final int UPDATE_ALL = UPDATE_MESSAGES + UPDATE_ERRORS;
112
113    // Constants for notification type used for audio and vibration settings.
114    public static final int LOCAL_SMS_NOTIFICATION = 0;
115
116    private static final String SMS_NOTIFICATION_TAG = ":sms:";
117    private static final String SMS_ERROR_NOTIFICATION_TAG = ":error:";
118
119    private static final String WEARABLE_COMPANION_APP_PACKAGE = "com.google.android.wearable.app";
120
121    private static final Set<NotificationState> sPendingNotifications =
122            new HashSet<NotificationState>();
123
124    private static int sWearableImageWidth;
125    private static int sWearableImageHeight;
126    private static int sIconWidth;
127    private static int sIconHeight;
128
129    private static boolean sInitialized = false;
130
131    private static final Object mLock = new Object();
132
133    // sLastMessageDingTime is a map between a conversation id and a time. It's used to keep track
134    // of the time we last dinged a message for this conversation. When messages are coming in
135    // at flurry, we don't want to over-ding the user.
136    private static final SimpleArrayMap<String, Long> sLastMessageDingTime =
137            new SimpleArrayMap<String, Long>();
138    private static int sTimeBetweenDingsMs;
139
140    /**
141     * This is the volume at which to play the observable-conversation notification sound,
142     * expressed as a fraction of the system notification volume.
143     */
144    private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f;
145
146    /**
147     * Entry point for posting notifications.
148     * Don't call this on the UI thread.
149     * @param silent If true, no ring will be played. If false, checks global settings before
150     * playing a ringtone
151     * @param coverage Indicates which notification types should be checked. Valid values are
152     * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL
153     */
154    public static void update(final boolean silent, final int coverage) {
155        update(silent, null /* conversationId */, coverage);
156    }
157
158    /**
159     * Entry point for posting notifications.
160     * Don't call this on the UI thread.
161     * @param silent If true, no ring will be played. If false, checks global settings before
162     * playing a ringtone
163     * @param conversationId Conversation ID where a new message was received
164     * @param coverage Indicates which notification types should be checked. Valid values are
165     * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL
166     */
167    public static void update(final boolean silent, final String conversationId,
168            final int coverage) {
169        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
170            LogUtil.v(TAG, "Update: silent = " + silent
171                    + " conversationId = " + conversationId
172                    + " coverage = " + coverage);
173        }
174    Assert.isNotMainThread();
175        checkInitialized();
176
177        if (!shouldNotify()) {
178            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
179                LogUtil.v(TAG, "Notifications disabled");
180            }
181            cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
182            return;
183        } else {
184            if ((coverage & UPDATE_MESSAGES) != 0) {
185                createMessageNotification(silent, conversationId);
186            }
187        }
188        if ((coverage & UPDATE_ERRORS) != 0) {
189            MessageNotificationState.checkFailedMessages();
190        }
191    }
192
193    /**
194     * Cancel all notifications of a certain type.
195     *
196     * @param type Message or error notifications from Constants.
197     */
198    private static synchronized void cancel(final int type) {
199        cancel(type, null, false);
200    }
201
202    /**
203     * Cancel all notifications of a certain type.
204     *
205     * @param type Message or error notifications from Constants.
206     * @param conversationId If set, cancel the notification for this
207     *            conversation only. For message notifications, this only works
208     *            if the notifications are bundled (group children).
209     * @param isBundledNotification True if this notification is part of a
210     *            notification bundle. This only applies to message notifications,
211     *            which are bundled together with other message notifications.
212     */
213    private static synchronized void cancel(final int type, final String conversationId,
214            final boolean isBundledNotification) {
215        final String notificationTag = buildNotificationTag(type, conversationId,
216                isBundledNotification);
217        final NotificationManagerCompat notificationManager =
218                NotificationManagerCompat.from(Factory.get().getApplicationContext());
219
220        // Find all pending notifications and cancel them.
221        synchronized (sPendingNotifications) {
222            final Iterator<NotificationState> iter = sPendingNotifications.iterator();
223            while (iter.hasNext()) {
224                final NotificationState notifState = iter.next();
225                if (notifState.mType == type) {
226                    notifState.mCanceled = true;
227                    if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
228                        LogUtil.v(TAG, "Canceling pending notification");
229                    }
230                    iter.remove();
231                }
232            }
233        }
234        notificationManager.cancel(notificationTag, type);
235        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
236            LogUtil.d(TAG, "Canceled notifications of type " + type);
237        }
238
239        // Message notifications for multiple conversations can be grouped together (see comment in
240        // createMessageNotification). We need to do bookkeeping to track the current set of
241        // notification group children, including removing them when we cancel notifications).
242        if (type == PendingIntentConstants.SMS_NOTIFICATION_ID) {
243            final Context context = Factory.get().getApplicationContext();
244            final ConversationIdSet groupChildIds = getGroupChildIds(context);
245
246            if (groupChildIds != null && groupChildIds.size() > 0) {
247                // If a conversation is specified, remove just that notification. Otherwise,
248                // we're removing the group summary so clear all children.
249                if (conversationId != null) {
250                    groupChildIds.remove(conversationId);
251                    writeGroupChildIds(context, groupChildIds);
252                } else {
253                    cancelStaleGroupChildren(groupChildIds, null);
254                    // We'll update the group children preference as we cancel each child,
255                    // so we don't need to do it here.
256                }
257            }
258        }
259    }
260
261    /**
262     * Cancels stale notifications from the currently active group of
263     * notifications. If the {@code state} parameter is an instance of
264     * {@link MultiConversationNotificationState} it represents a new
265     * notification group. This method will cancel any notifications that were
266     * in the old group, but not the new one. If the new notification is not a
267     * group, then all existing grouped notifications are cancelled.
268     *
269     * @param previousGroupChildren Conversation ids for the active notification
270     *            group
271     * @param state New notification state
272     */
273    private static void cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren,
274            final NotificationState state) {
275        final ConversationIdSet newChildren = new ConversationIdSet();
276        if (state instanceof MultiConversationNotificationState) {
277            for (final NotificationState child :
278                ((MultiConversationNotificationState) state).mChildren) {
279                if (child.mConversationIds != null) {
280                    newChildren.add(child.mConversationIds.first());
281                }
282            }
283        }
284        for (final String childConversationId : previousGroupChildren) {
285            if (!newChildren.contains(childConversationId)) {
286                cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, childConversationId, true);
287            }
288        }
289    }
290
291    /**
292     * Returns {@code true} if incoming notifications should display a
293     * notification, {@code false} otherwise.
294     *
295     * @return true if the notification should occur
296     */
297    private static boolean shouldNotify() {
298        // If we're not the default sms app, don't put up any notifications.
299        if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
300            return false;
301        }
302
303        // Now check prefs (i.e. settings) to see if the user turned off notifications.
304        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
305        final Context context = Factory.get().getApplicationContext();
306        final String prefKey = context.getString(R.string.notifications_enabled_pref_key);
307        final boolean defaultValue = context.getResources().getBoolean(
308                R.bool.notifications_enabled_pref_default);
309        return prefs.getBoolean(prefKey, defaultValue);
310    }
311
312    /**
313     * Returns {@code true} if incoming notifications for the given {@link NotificationState}
314     * should vibrate the device, {@code false} otherwise.
315     *
316     * @return true if vibration should be used
317     */
318    public static boolean shouldVibrate(final NotificationState state) {
319        // The notification should vibrate if the global setting is turned on AND
320        // the per-conversation setting is turned on (default).
321        if (!state.getNotificationVibrate()) {
322            return false;
323        } else {
324            final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
325            final Context context = Factory.get().getApplicationContext();
326            final String prefKey = context.getString(R.string.notification_vibration_pref_key);
327            final boolean defaultValue = context.getResources().getBoolean(
328                    R.bool.notification_vibration_pref_default);
329            return prefs.getBoolean(prefKey, defaultValue);
330        }
331    }
332
333    private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) {
334        final DatabaseWrapper db = DataModel.get().getDatabase();
335        final ConversationListItemData convData =
336                ConversationListItemData.getExistingConversation(db, conversationId);
337        return RingtoneUtil.getNotificationRingtoneUri(
338                convData != null ? convData.getNotificationSoundUri() : null);
339    }
340
341    /**
342     * Returns a unique tag to identify a notification.
343     *
344     * @param name The tag name (in practice, the type)
345     * @param conversationId The conversation id (optional)
346     */
347    private static String buildNotificationTag(final String name,
348            final String conversationId) {
349        final Context context = Factory.get().getApplicationContext();
350        if (conversationId != null) {
351            return context.getPackageName() + name + ":" + conversationId;
352        } else {
353            return context.getPackageName() + name;
354        }
355    }
356
357    /**
358     * Returns a unique tag to identify a notification.
359     * <p>
360     * This delegates to
361     * {@link #buildNotificationTag(int, String, boolean)} and can be
362     * used when the notification is never bundled (e.g. error notifications).
363     */
364    static String buildNotificationTag(final int type, final String conversationId) {
365        return buildNotificationTag(type, conversationId, false /* bundledNotification */);
366    }
367
368    /**
369     * Returns a unique tag to identify a notification.
370     *
371     * @param type One of the constants in {@link PendingIntentConstants}
372     * @param conversationId The conversation id (where applicable)
373     * @param bundledNotification Set to true if this notification will be
374     *            bundled together with other notifications (e.g. on a wearable
375     *            device).
376     */
377    static String buildNotificationTag(final int type, final String conversationId,
378            final boolean bundledNotification) {
379        String tag = null;
380        switch(type) {
381            case PendingIntentConstants.SMS_NOTIFICATION_ID:
382                if (bundledNotification) {
383                    tag = buildNotificationTag(SMS_NOTIFICATION_TAG, conversationId);
384                } else {
385                    tag = buildNotificationTag(SMS_NOTIFICATION_TAG, null);
386                }
387                break;
388            case PendingIntentConstants.MSG_SEND_ERROR:
389                tag = buildNotificationTag(SMS_ERROR_NOTIFICATION_TAG, null);
390                break;
391        }
392        return tag;
393    }
394
395    private static void checkInitialized() {
396        if (!sInitialized) {
397            final Resources resources = Factory.get().getApplicationContext().getResources();
398            sWearableImageWidth = resources.getDimensionPixelSize(
399                    R.dimen.notification_wearable_image_width);
400            sWearableImageHeight = resources.getDimensionPixelSize(
401                    R.dimen.notification_wearable_image_height);
402            sIconHeight = (int) resources.getDimension(
403                    android.R.dimen.notification_large_icon_height);
404            sIconWidth =
405                    (int) resources.getDimension(android.R.dimen.notification_large_icon_width);
406
407            sInitialized = true;
408        }
409    }
410
411    private static void processAndSend(final NotificationState state, final boolean silent,
412            final boolean softSound) {
413        final Context context = Factory.get().getApplicationContext();
414        final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context);
415        notifBuilder.setCategory(Notification.CATEGORY_MESSAGE);
416        // TODO: Need to fix this for multi conversation notifications to rate limit dings.
417        final String conversationId = state.mConversationIds.first();
418
419
420        final Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(state.getRingtoneUri());
421        // If the notification's conversation is currently observable (focused or in the
422        // conversation list),  then play a notification beep at a low volume and don't display an
423        // actual notification.
424        if (softSound) {
425            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
426                LogUtil.v(TAG, "processAndSend: fromConversationId == " +
427                        "sCurrentlyDisplayedConversationId so NOT showing notification," +
428                        " but playing soft sound. conversationId: " + conversationId);
429            }
430            playObservableConversationNotificationSound(ringtoneUri);
431            return;
432        }
433        state.mBaseRequestCode = state.mType;
434
435        // Set the delete intent (except for bundled wearable notifications, which are dismissed
436        // as a group, either from the wearable or when the summary notification is dismissed from
437        // the host device).
438        if (!(state instanceof BundledMessageNotificationState)) {
439            final PendingIntent clearIntent = state.getClearIntent();
440            notifBuilder.setDeleteIntent(clearIntent);
441        }
442
443        updateBuilderAudioVibrate(state, notifBuilder, silent, ringtoneUri, conversationId);
444
445        // Set the content intent
446        PendingIntent destinationIntent;
447        if (state.mConversationIds.size() > 1) {
448            // We have notifications for multiple conversation, go to the conversation list.
449            destinationIntent = UIIntents.get()
450                .getPendingIntentForConversationListActivity(context);
451        } else {
452            // We have a single conversation, go directly to that conversation.
453            destinationIntent = UIIntents.get()
454                    .getPendingIntentForConversationActivity(context,
455                            state.mConversationIds.first(),
456                            null /*draft*/);
457        }
458        notifBuilder.setContentIntent(destinationIntent);
459
460        // TODO: set based on contact coming from a favorite.
461        notifBuilder.setPriority(state.getPriority());
462
463        // Save the state of the notification in-progress so when the avatar is loaded,
464        // we can continue building the notification.
465        final NotificationCompat.Style notifStyle = state.build(notifBuilder);
466        state.mNotificationBuilder = notifBuilder;
467        state.mNotificationStyle = notifStyle;
468        if (!state.mPeople.isEmpty()) {
469            final Bundle people = new Bundle();
470            people.putStringArray(NotificationCompat.EXTRA_PEOPLE,
471                    state.mPeople.toArray(new String[state.mPeople.size()]));
472            notifBuilder.addExtras(people);
473        }
474
475        if (state.mParticipantAvatarsUris != null) {
476            final Uri avatarUri = state.mParticipantAvatarsUris.get(0);
477            final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(avatarUri,
478                    sIconWidth, sIconHeight, OsUtil.isAtLeastL());
479            final MediaRequest<ImageResource> imageRequest = descriptor.buildSyncMediaRequest(
480                    context);
481
482            synchronized (sPendingNotifications) {
483                sPendingNotifications.add(state);
484            }
485
486            // Synchronously load the avatar.
487            final ImageResource avatarImage =
488                    MediaResourceManager.get().requestMediaResourceSync(imageRequest);
489            if (avatarImage != null) {
490                ImageResource avatarHiRes = null;
491                try {
492                    if (isWearCompanionAppInstalled()) {
493                        // For Wear users, we need to request a high-res avatar image to use as the
494                        // notification card background. If the sender has a contact photo, we'll
495                        // request the display photo from the Contacts provider. Otherwise, we ask
496                        // the local content provider for a hi-res version of the generic avatar
497                        // (e.g. letter with colored background).
498                        avatarHiRes = requestContactDisplayPhoto(context,
499                                getDisplayPhotoUri(avatarUri));
500                        if (avatarHiRes == null) {
501                            final AvatarRequestDescriptor hiResDesc =
502                                    new AvatarRequestDescriptor(avatarUri,
503                                    sWearableImageWidth,
504                                    sWearableImageHeight,
505                                    false /* cropToCircle */,
506                                    true /* isWearBackground */);
507                            avatarHiRes = MediaResourceManager.get().requestMediaResourceSync(
508                                    hiResDesc.buildSyncMediaRequest(context));
509                        }
510                    }
511
512                    // We have to make copies of the bitmaps to hand to the NotificationManager
513                    // because the bitmap in the ImageResource is managed and will automatically
514                    // get released.
515                    Bitmap avatarBitmap = Bitmap.createBitmap(avatarImage.getBitmap());
516                    Bitmap avatarHiResBitmap = (avatarHiRes != null) ?
517                            Bitmap.createBitmap(avatarHiRes.getBitmap()) : null;
518                    sendNotification(state, avatarBitmap, avatarHiResBitmap);
519                    return;
520                } finally {
521                    avatarImage.release();
522                    if (avatarHiRes != null) {
523                        avatarHiRes.release();
524                    }
525                }
526            }
527        }
528        // We have no avatar. Post the notification anyway.
529        sendNotification(state, null, null);
530    }
531
532    /**
533     * Returns the thumbnailUri from the avatar URI, or null if avatar URI does not have thumbnail.
534     */
535    private static Uri getThumbnailUri(final Uri avatarUri) {
536        Uri localUri = null;
537        final String avatarType = AvatarUriUtil.getAvatarType(avatarUri);
538        if (TextUtils.equals(avatarType, AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI)) {
539            localUri = AvatarUriUtil.getPrimaryUri(avatarUri);
540        } else if (UriUtil.isLocalResourceUri(avatarUri)) {
541            localUri = avatarUri;
542        }
543        if (localUri != null && localUri.getAuthority().equals(ContactsContract.AUTHORITY)) {
544            // Contact photos are of the form: content://com.android.contacts/contacts/123/photo
545            final List<String> pathParts = localUri.getPathSegments();
546            if (pathParts.size() == 3 &&
547                    pathParts.get(2).equals(Contacts.Photo.CONTENT_DIRECTORY)) {
548                return localUri;
549            }
550        }
551        return null;
552    }
553
554    /**
555     * Returns the displayPhotoUri from the avatar URI, or null if avatar URI
556     * does not have a displayPhotoUri.
557     */
558    private static Uri getDisplayPhotoUri(final Uri avatarUri) {
559        final Uri thumbnailUri = getThumbnailUri(avatarUri);
560        if (thumbnailUri == null) {
561            return null;
562        }
563        final List<String> originalPaths = thumbnailUri.getPathSegments();
564        final int originalPathsSize = originalPaths.size();
565        final StringBuilder newPathBuilder = new StringBuilder();
566        // Change content://com.android.contacts/contacts("_corp")/123/photo to
567        // content://com.android.contacts/contacts("_corp")/123/display_photo
568        for (int i = 0; i < originalPathsSize; i++) {
569            newPathBuilder.append('/');
570            if (i == 2) {
571                newPathBuilder.append(ContactsContract.Contacts.Photo.DISPLAY_PHOTO);
572            } else {
573                newPathBuilder.append(originalPaths.get(i));
574            }
575        }
576        return thumbnailUri.buildUpon().path(newPathBuilder.toString()).build();
577    }
578
579    private static ImageResource requestContactDisplayPhoto(final Context context,
580            final Uri displayPhotoUri) {
581        final UriImageRequestDescriptor bgDescriptor =
582                new UriImageRequestDescriptor(displayPhotoUri,
583                        sWearableImageWidth,
584                        sWearableImageHeight,
585                        false, /* allowCompression */
586                        true, /* isStatic */
587                        false /* cropToCircle */,
588                        ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
589                        ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
590        return MediaResourceManager.get().requestMediaResourceSync(
591                bgDescriptor.buildSyncMediaRequest(context));
592    }
593
594    private static void createMessageNotification(final boolean silent,
595            final String conversationId) {
596        final NotificationState state = MessageNotificationState.getNotificationState();
597        final boolean softSound = DataModel.get().isNewMessageObservable(conversationId);
598        if (state == null) {
599            cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
600            if (softSound && !TextUtils.isEmpty(conversationId)) {
601                final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId);
602                playObservableConversationNotificationSound(ringtoneUri);
603            }
604            return;
605        }
606        processAndSend(state, silent, softSound);
607
608        // The rest of the logic here is for supporting Android Wear devices, specifically for when
609        // we are notifying about multiple conversations. In that case, the Inbox-style summary
610        // notification (which we already processed above) appears on the phone (as it always has),
611        // but wearables show per-conversation notifications, bundled together in a group.
612
613        // It is valid to replace a notification group with another group with fewer conversations,
614        // or even with one notification for a single conversation. In either case, we need to
615        // explicitly cancel any children from the old group which are not being notified about now.
616        final Context context = Factory.get().getApplicationContext();
617        final ConversationIdSet oldGroupChildIds = getGroupChildIds(context);
618        if (oldGroupChildIds != null && oldGroupChildIds.size() > 0) {
619            cancelStaleGroupChildren(oldGroupChildIds, state);
620        }
621
622        // Send per-conversation notifications (if there are multiple conversations).
623        final ConversationIdSet groupChildIds = new ConversationIdSet();
624        if (state instanceof MultiConversationNotificationState) {
625            for (final NotificationState child :
626                ((MultiConversationNotificationState) state).mChildren) {
627                processAndSend(child, true /* silent */, softSound);
628                if (child.mConversationIds != null) {
629                    groupChildIds.add(child.mConversationIds.first());
630                }
631            }
632        }
633
634        // Record the new set of group children.
635        writeGroupChildIds(context, groupChildIds);
636    }
637
638    private static void updateBuilderAudioVibrate(final NotificationState state,
639            final NotificationCompat.Builder notifBuilder, final boolean silent,
640            final Uri ringtoneUri, final String conversationId) {
641        int defaults = Notification.DEFAULT_LIGHTS;
642        if (!silent) {
643            final BuglePrefs prefs = Factory.get().getApplicationPrefs();
644            final long latestNotificationTimestamp = prefs.getLong(
645                    BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE);
646            final long latestReceivedTimestamp = state.getLatestReceivedTimestamp();
647            prefs.putLong(
648                    BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP,
649                    Math.max(latestNotificationTimestamp, latestReceivedTimestamp));
650            if (latestReceivedTimestamp > latestNotificationTimestamp) {
651                synchronized (mLock) {
652                    // Find out the last time we dinged for this conversation
653                    Long lastTime = sLastMessageDingTime.get(conversationId);
654                    if (sTimeBetweenDingsMs == 0) {
655                        sTimeBetweenDingsMs = BugleGservices.get().getInt(
656                                BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS,
657                                BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT) *
658                                    1000;
659                    }
660                    if (lastTime == null
661                            || SystemClock.elapsedRealtime() - lastTime > sTimeBetweenDingsMs) {
662                        sLastMessageDingTime.put(conversationId, SystemClock.elapsedRealtime());
663                        notifBuilder.setSound(ringtoneUri);
664                        if (shouldVibrate(state)) {
665                            defaults |= Notification.DEFAULT_VIBRATE;
666                        }
667                    }
668                }
669            }
670        }
671        notifBuilder.setDefaults(defaults);
672    }
673
674    // TODO: this doesn't seem to be defined in NotificationCompat yet. Temporarily
675    // define it here until it makes its way from Notification -> NotificationCompat.
676    /**
677     * Notification category: incoming direct message (SMS, instant message, etc.).
678     */
679    private static final String CATEGORY_MESSAGE = "msg";
680
681    private static void sendNotification(final NotificationState notificationState,
682            final Bitmap avatarIcon, final Bitmap avatarHiRes) {
683        final Context context = Factory.get().getApplicationContext();
684        if (notificationState.mCanceled) {
685            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
686                LogUtil.d(TAG, "sendNotification: Notification already cancelled; dropping it");
687            }
688            return;
689        }
690
691        synchronized (sPendingNotifications) {
692            if (sPendingNotifications.contains(notificationState)) {
693                sPendingNotifications.remove(notificationState);
694            }
695        }
696
697        notificationState.mNotificationBuilder
698            .setSmallIcon(notificationState.getIcon())
699            .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
700            .setColor(context.getResources().getColor(R.color.notification_accent_color))
701//            .setPublicVersion(null)    // TODO: when/if we ever support different
702                                         // text on the lockscreen, instead of "contents hidden"
703            .setCategory(CATEGORY_MESSAGE);
704
705        if (avatarIcon != null) {
706            notificationState.mNotificationBuilder.setLargeIcon(avatarIcon);
707        }
708
709        if (notificationState.mParticipantContactUris != null &&
710                notificationState.mParticipantContactUris.size() > 0) {
711            for (final Uri contactUri : notificationState.mParticipantContactUris) {
712                notificationState.mNotificationBuilder.addPerson(contactUri.toString());
713            }
714        }
715
716        final Uri attachmentUri = notificationState.getAttachmentUri();
717        final String attachmentType = notificationState.getAttachmentType();
718        Bitmap attachmentBitmap = null;
719
720        // For messages with photo/video attachment, request an image to show in the notification.
721        if (attachmentUri != null && notificationState.mNotificationStyle != null &&
722                (notificationState.mNotificationStyle instanceof
723                        NotificationCompat.BigPictureStyle) &&
724                        (ContentType.isImageType(attachmentType) ||
725                                ContentType.isVideoType(attachmentType))) {
726            final boolean isVideo = ContentType.isVideoType(attachmentType);
727
728            MediaRequest<ImageResource> imageRequest;
729            if (isVideo) {
730                Assert.isTrue(VideoThumbnailRequest.shouldShowIncomingVideoThumbnails());
731                final MessagePartVideoThumbnailRequestDescriptor videoDescriptor =
732                        new MessagePartVideoThumbnailRequestDescriptor(attachmentUri);
733                imageRequest = videoDescriptor.buildSyncMediaRequest(context);
734            } else {
735                final UriImageRequestDescriptor imageDescriptor =
736                        new UriImageRequestDescriptor(attachmentUri,
737                            sWearableImageWidth,
738                            sWearableImageHeight,
739                            false /* allowCompression */,
740                            true /* isStatic */,
741                            false /* cropToCircle */,
742                            ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
743                            ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
744                imageRequest = imageDescriptor.buildSyncMediaRequest(context);
745            }
746            final ImageResource imageResource =
747                    MediaResourceManager.get().requestMediaResourceSync(imageRequest);
748            if (imageResource != null) {
749                try {
750                    // Copy the bitmap, because the one in the ImageResource is managed by
751                    // MediaResourceManager.
752                    Bitmap imageResourceBitmap = imageResource.getBitmap();
753                    Config config = imageResourceBitmap.getConfig();
754
755                    // Make sure our bitmap has a valid format.
756                    if (config == null) {
757                        config = Bitmap.Config.ARGB_8888;
758                    }
759                    attachmentBitmap = imageResourceBitmap.copy(config, true);
760                } finally {
761                    imageResource.release();
762                }
763            }
764        }
765
766        fireOffNotification(notificationState, attachmentBitmap, avatarIcon, avatarHiRes);
767    }
768
769    private static void fireOffNotification(final NotificationState notificationState,
770            final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap) {
771        if (notificationState.mCanceled) {
772            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
773                LogUtil.v(TAG, "Firing off notification, but notification already canceled");
774            }
775            return;
776        }
777
778        final Context context = Factory.get().getApplicationContext();
779
780        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
781            LogUtil.v(TAG, "MMS picture loaded, bitmap: " + attachmentBitmap);
782        }
783
784        final NotificationCompat.Builder notifBuilder = notificationState.mNotificationBuilder;
785        notifBuilder.setStyle(notificationState.mNotificationStyle);
786        notifBuilder.setColor(context.getResources().getColor(R.color.notification_accent_color));
787
788        final WearableExtender wearableExtender = new WearableExtender();
789        setWearableGroupOptions(notifBuilder, notificationState);
790
791        if (avatarHiResBitmap != null) {
792            wearableExtender.setBackground(avatarHiResBitmap);
793        } else if (avatarBitmap != null) {
794            // Nothing to do here; we already set avatarBitmap as the notification icon
795        } else {
796            final Bitmap defaultBackground = BitmapFactory.decodeResource(
797                    context.getResources(), R.drawable.bg_sms);
798            wearableExtender.setBackground(defaultBackground);
799        }
800
801        if (notificationState instanceof MultiMessageNotificationState) {
802            if (attachmentBitmap != null) {
803                // When we've got a picture attachment, we do some switcheroo trickery. When
804                // the notification is expanded, we show the picture as a bigPicture. The small
805                // icon shows the sender's avatar. When that same notification is collapsed, the
806                // picture is shown in the location where the avatar is normally shown. The lines
807                // below make all that happen.
808
809                // Here we're taking the picture attachment and making a small, scaled, center
810                // cropped version of the picture we can stuff into the place where the avatar
811                // goes when the notification is collapsed.
812                final Bitmap smallBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, sIconWidth,
813                        sIconHeight);
814                ((NotificationCompat.BigPictureStyle) notificationState.mNotificationStyle)
815                    .bigPicture(attachmentBitmap)
816                    .bigLargeIcon(avatarBitmap);
817                notificationState.mNotificationBuilder.setLargeIcon(smallBitmap);
818
819                // Add a wearable page with no visible card so you can more easily see the photo.
820                final NotificationCompat.Builder photoPageNotifBuilder =
821                        new NotificationCompat.Builder(Factory.get().getApplicationContext());
822                final WearableExtender photoPageWearableExtender = new WearableExtender();
823                photoPageWearableExtender.setHintShowBackgroundOnly(true);
824                if (attachmentBitmap != null) {
825                    final Bitmap wearBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap,
826                            sWearableImageWidth, sWearableImageHeight);
827                    photoPageWearableExtender.setBackground(wearBitmap);
828                }
829                photoPageNotifBuilder.extend(photoPageWearableExtender);
830                wearableExtender.addPage(photoPageNotifBuilder.build());
831            }
832
833            maybeAddWearableConversationLog(wearableExtender,
834                    (MultiMessageNotificationState) notificationState);
835            addDownloadMmsAction(notifBuilder, wearableExtender, notificationState);
836            addWearableVoiceReplyAction(wearableExtender, notificationState);
837        }
838
839        // Apply the wearable options and build & post the notification
840        notifBuilder.extend(wearableExtender);
841        doNotify(notifBuilder.build(), notificationState);
842    }
843
844    private static void setWearableGroupOptions(final NotificationCompat.Builder notifBuilder,
845            final NotificationState notificationState) {
846        final String groupKey = "groupkey";
847        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
848            LogUtil.v(TAG, "Group key (for wearables)=" + groupKey);
849        }
850        if (notificationState instanceof MultiConversationNotificationState) {
851            notifBuilder.setGroup(groupKey).setGroupSummary(true);
852        } else if (notificationState instanceof BundledMessageNotificationState) {
853            final int order = ((BundledMessageNotificationState) notificationState).mGroupOrder;
854            // Convert the order to a zero-padded string ("00", "01", "02", etc).
855            // The Wear library orders notifications within a bundle lexicographically
856            // by the sort key, hence the need for zeroes to preserve the ordering.
857            final String sortKey = String.format(Locale.US, "%02d", order);
858            notifBuilder.setGroup(groupKey).setSortKey(sortKey);
859        }
860    }
861
862    private static void maybeAddWearableConversationLog(
863            final WearableExtender wearableExtender,
864            final MultiMessageNotificationState notificationState) {
865        if (!isWearCompanionAppInstalled()) {
866            return;
867        }
868        final String convId = notificationState.mConversationIds.first();
869        ConversationLineInfo convInfo = notificationState.mConvList.mConvInfos.get(0);
870        final Notification page = MessageNotificationState.buildConversationPageForWearable(
871                convId,
872                convInfo.mParticipantCount);
873        if (page != null) {
874            wearableExtender.addPage(page);
875        }
876    }
877
878    private static void addWearableVoiceReplyAction(
879            final WearableExtender wearableExtender, final NotificationState notificationState) {
880        if (!(notificationState instanceof MultiMessageNotificationState)) {
881            return;
882        }
883        final MultiMessageNotificationState multiMessageNotificationState =
884                (MultiMessageNotificationState) notificationState;
885        final Context context = Factory.get().getApplicationContext();
886
887        final String conversationId = notificationState.mConversationIds.first();
888        final ConversationLineInfo convInfo =
889                multiMessageNotificationState.mConvList.mConvInfos.get(0);
890        final String selfId = convInfo.mSelfParticipantId;
891
892        final boolean requiresMms =
893                MmsSmsUtils.getRequireMmsForEmailAddress(
894                        convInfo.mIncludeEmailAddress, convInfo.mSubId) ||
895                (convInfo.mIsGroup && MmsUtils.groupMmsEnabled(convInfo.mSubId));
896
897        final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode();
898        final PendingIntent replyPendingIntent = UIIntents.get()
899                .getPendingIntentForSendingMessageToConversation(context,
900                        conversationId, selfId, requiresMms, requestCode);
901
902        final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms :
903            R.string.notification_reply_via_sms;
904
905        final NotificationCompat.Action.Builder actionBuilder =
906                new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
907                        context.getString(replyLabelRes), replyPendingIntent);
908        final String[] choices = context.getResources().getStringArray(
909                R.array.notification_reply_choices);
910        final RemoteInput remoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT).setLabel(
911                context.getString(R.string.notification_reply_prompt)).
912                setChoices(choices)
913                .build();
914        actionBuilder.addRemoteInput(remoteInput);
915        wearableExtender.addAction(actionBuilder.build());
916    }
917
918    private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder,
919            final WearableExtender wearableExtender, final NotificationState notificationState) {
920        if (!(notificationState instanceof MultiMessageNotificationState)) {
921            return;
922        }
923        final MultiMessageNotificationState multiMessageNotificationState =
924                (MultiMessageNotificationState) notificationState;
925        final ConversationLineInfo convInfo =
926                multiMessageNotificationState.mConvList.mConvInfos.get(0);
927        if (!convInfo.getDoesLatestMessageNeedDownload()) {
928            return;
929        }
930        final String messageId = convInfo.getLatestMessageId();
931        if (messageId == null) {
932            // No message Id, no download for you
933            return;
934        }
935        final Context context = Factory.get().getApplicationContext();
936        final PendingIntent downloadPendingIntent =
937                RedownloadMmsAction.getPendingIntentForRedownloadMms(context, messageId);
938
939        final NotificationCompat.Action.Builder actionBuilder =
940                new NotificationCompat.Action.Builder(R.drawable.ic_file_download_light,
941                        context.getString(R.string.notification_download_mms),
942                        downloadPendingIntent);
943        final NotificationCompat.Action downloadAction = actionBuilder.build();
944        notifBuilder.addAction(downloadAction);
945
946        // Support the action on a wearable device as well
947        wearableExtender.addAction(downloadAction);
948    }
949
950    private static synchronized void doNotify(final Notification notification,
951            final NotificationState notificationState) {
952        if (notification == null) {
953            return;
954        }
955        final int type = notificationState.mType;
956        final ConversationIdSet conversationIds = notificationState.mConversationIds;
957        final boolean isBundledNotification =
958                (notificationState instanceof BundledMessageNotificationState);
959
960        // Mark the notification as finished
961        notificationState.mCanceled = true;
962
963        final NotificationManagerCompat notificationManager =
964                NotificationManagerCompat.from(Factory.get().getApplicationContext());
965        // Only need conversationId for tags with a single conversation.
966        String conversationId = null;
967        if (conversationIds != null && conversationIds.size() == 1) {
968            conversationId = conversationIds.first();
969        }
970        final String notificationTag = buildNotificationTag(type,
971                conversationId, isBundledNotification);
972
973        notification.flags |= Notification.FLAG_AUTO_CANCEL;
974        notification.defaults |= Notification.DEFAULT_LIGHTS;
975
976        notificationManager.notify(notificationTag, type, notification);
977
978        LogUtil.i(TAG, "Notifying for conversation " + conversationId + "; "
979                + "tag = " + notificationTag + ", type = " + type);
980    }
981
982    // This is the message string used in each line of an inboxStyle notification.
983    // TODO: add attachment type
984    static CharSequence formatInboxMessage(final String sender,
985            final CharSequence message, final Uri attachmentUri, final String attachmentType) {
986      final Context context = Factory.get().getApplicationContext();
987      final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan(
988              context, R.style.NotificationSenderText);
989
990      final TextAppearanceSpan notificationTertiaryText = new TextAppearanceSpan(
991              context, R.style.NotificationTertiaryText);
992
993      final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
994      if (!TextUtils.isEmpty(sender)) {
995          spannableStringBuilder.append(sender);
996          spannableStringBuilder.setSpan(notificationSenderSpan, 0, sender.length(), 0);
997      }
998      final String separator = context.getString(R.string.notification_separator);
999
1000      if (!TextUtils.isEmpty(message)) {
1001          if (spannableStringBuilder.length() > 0) {
1002              spannableStringBuilder.append(separator);
1003          }
1004          final int start = spannableStringBuilder.length();
1005          spannableStringBuilder.append(message);
1006          spannableStringBuilder.setSpan(notificationTertiaryText, start,
1007                  start + message.length(), 0);
1008      }
1009      if (attachmentUri != null) {
1010          if (spannableStringBuilder.length() > 0) {
1011              spannableStringBuilder.append(separator);
1012          }
1013          spannableStringBuilder.append(formatAttachmentTag(null, attachmentType));
1014      }
1015      return spannableStringBuilder;
1016    }
1017
1018    protected static CharSequence buildColonSeparatedMessage(
1019            final String title, final CharSequence content, final Uri attachmentUri,
1020            final String attachmentType) {
1021        return buildBoldedMessage(title, content, attachmentUri, attachmentType,
1022                R.string.notification_ticker_separator);
1023    }
1024
1025    protected static CharSequence buildSpaceSeparatedMessage(
1026            final String title, final CharSequence content, final Uri attachmentUri,
1027            final String attachmentType) {
1028        return buildBoldedMessage(title, content, attachmentUri, attachmentType,
1029                R.string.notification_space_separator);
1030    }
1031
1032    /**
1033     * buildBoldedMessage - build a formatted message where the title is bold, there's a
1034     * separator, then the message.
1035     */
1036    private static CharSequence buildBoldedMessage(
1037            final String title, final CharSequence message, final Uri attachmentUri,
1038            final String attachmentType,
1039            final int separatorId) {
1040        final Context context = Factory.get().getApplicationContext();
1041        final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
1042
1043        // Boldify the title (which is the sender's name)
1044        if (!TextUtils.isEmpty(title)) {
1045            spanBuilder.append(title);
1046            spanBuilder.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(),
1047                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1048        }
1049        if (!TextUtils.isEmpty(message)) {
1050            if (spanBuilder.length() > 0) {
1051                spanBuilder.append(context.getString(separatorId));
1052            }
1053            spanBuilder.append(message);
1054        }
1055        if (attachmentUri != null) {
1056            if (spanBuilder.length() > 0) {
1057                final String separator = context.getString(R.string.notification_separator);
1058                spanBuilder.append(separator);
1059            }
1060            spanBuilder.append(formatAttachmentTag(null, attachmentType));
1061        }
1062        return spanBuilder;
1063    }
1064
1065    static CharSequence formatAttachmentTag(final String author, final String attachmentType) {
1066        final Context context = Factory.get().getApplicationContext();
1067            final TextAppearanceSpan notificationSecondaryText = new TextAppearanceSpan(
1068                    context, R.style.NotificationSecondaryText);
1069        final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
1070        if (!TextUtils.isEmpty(author)) {
1071            final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan(
1072                    context, R.style.NotificationSenderText);
1073            spannableStringBuilder.append(author);
1074            spannableStringBuilder.setSpan(notificationSenderSpan, 0, author.length(), 0);
1075            final String separator = context.getString(R.string.notification_separator);
1076            spannableStringBuilder.append(separator);
1077        }
1078        final int start = spannableStringBuilder.length();
1079        // The default attachment type is an image, since that's what was originally
1080        // supported. When there's no content type, assume it's an image.
1081        int message = R.string.notification_picture;
1082        if (ContentType.isAudioType(attachmentType)) {
1083            message = R.string.notification_audio;
1084        } else if (ContentType.isVideoType(attachmentType)) {
1085            message = R.string.notification_video;
1086        } else if (ContentType.isVCardType(attachmentType)) {
1087            message = R.string.notification_vcard;
1088        }
1089        spannableStringBuilder.append(context.getText(message));
1090        spannableStringBuilder.setSpan(notificationSecondaryText, start,
1091                spannableStringBuilder.length(), 0);
1092        return spannableStringBuilder;
1093    }
1094
1095    /**
1096     * Play the observable conversation notification sound (it's the regular notification sound, but
1097     * played at half-volume)
1098     */
1099    private static void playObservableConversationNotificationSound(final Uri ringtoneUri) {
1100        final Context context = Factory.get().getApplicationContext();
1101        final AudioManager audioManager = (AudioManager) context
1102                .getSystemService(Context.AUDIO_SERVICE);
1103        final boolean silenced =
1104                audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
1105        if (silenced) {
1106             return;
1107        }
1108
1109        final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG);
1110        player.play(ringtoneUri, false,
1111                AudioManager.STREAM_NOTIFICATION,
1112                OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME);
1113
1114        // Stop the sound after five seconds to handle continuous ringtones
1115        ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() {
1116            @Override
1117            public void run() {
1118                player.stop();
1119            }
1120        }, 5000);
1121    }
1122
1123    public static boolean isWearCompanionAppInstalled() {
1124        boolean found = false;
1125        try {
1126            Factory.get().getApplicationContext().getPackageManager()
1127                    .getPackageInfo(WEARABLE_COMPANION_APP_PACKAGE, 0);
1128            found = true;
1129        } catch (final NameNotFoundException e) {
1130            // Ignore; found is already false
1131        }
1132        return found;
1133    }
1134
1135    /**
1136     * When we go to the conversation list, call this to mark all messages as seen. That means
1137     * we won't show a notification again for the same message.
1138     */
1139    public static void markAllMessagesAsSeen() {
1140        MarkAsSeenAction.markAllAsSeen();
1141        resetLastMessageDing(null);     // reset the ding timeout for all conversations
1142    }
1143
1144    /**
1145     * When we open a particular conversation, call this to mark all messages as read.
1146     */
1147    public static void markMessagesAsRead(final String conversationId) {
1148        MarkAsReadAction.markAsRead(conversationId);
1149        resetLastMessageDing(conversationId);
1150    }
1151
1152    /**
1153     * Returns the conversation ids of all active, grouped notifications, or
1154     * {code null} if no notifications are currently active and grouped.
1155     */
1156    private static ConversationIdSet getGroupChildIds(final Context context) {
1157        final String prefKey = context.getString(R.string.notifications_group_children_key);
1158        final String groupChildIdsText = BuglePrefs.getApplicationPrefs().getString(prefKey, "");
1159        if (!TextUtils.isEmpty(groupChildIdsText)) {
1160            return ConversationIdSet.createSet(groupChildIdsText);
1161        } else {
1162            return null;
1163        }
1164    }
1165
1166    /**
1167     * Records the conversation ids of the currently active grouped notifications.
1168     */
1169    private static void writeGroupChildIds(final Context context,
1170            final ConversationIdSet childIds) {
1171        final ConversationIdSet oldChildIds = getGroupChildIds(context);
1172        if (childIds.equals(oldChildIds)) {
1173            return;
1174        }
1175        final String prefKey = context.getString(R.string.notifications_group_children_key);
1176        BuglePrefs.getApplicationPrefs().putString(prefKey, childIds.getDelimitedString());
1177    }
1178
1179    /**
1180     * Reset the timer for a notification ding on a particular conversation or all conversations.
1181     */
1182    public static void resetLastMessageDing(final String conversationId) {
1183        synchronized (mLock) {
1184            if (TextUtils.isEmpty(conversationId)) {
1185                // reset all conversation dings
1186                sLastMessageDingTime.clear();
1187            } else {
1188                sLastMessageDingTime.remove(conversationId);
1189            }
1190        }
1191    }
1192
1193    public static void notifyEmergencySmsFailed(final String emergencyNumber,
1194            final String conversationId) {
1195        final Context context = Factory.get().getApplicationContext();
1196
1197        final CharSequence line1 = MessageNotificationState.applyWarningTextColor(context,
1198                context.getString(R.string.notification_emergency_send_failure_line1,
1199                emergencyNumber));
1200        final String line2 = context.getString(R.string.notification_emergency_send_failure_line2,
1201                emergencyNumber);
1202        final PendingIntent destinationIntent = UIIntents.get()
1203                .getPendingIntentForConversationActivity(context, conversationId, null /* draft */);
1204
1205        final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
1206        builder.setTicker(line1)
1207                .setContentTitle(line1)
1208                .setContentText(line2)
1209                .setStyle(new NotificationCompat.BigTextStyle(builder).bigText(line2))
1210                .setSmallIcon(R.drawable.ic_failed_light)
1211                .setContentIntent(destinationIntent)
1212                .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure));
1213
1214        final String tag = context.getPackageName() + ":emergency_sms_error";
1215        NotificationManagerCompat.from(context).notify(
1216                tag,
1217                PendingIntentConstants.MSG_SEND_ERROR,
1218                builder.build());
1219    }
1220}
1221
1222