Home | History | Annotate | Download | only in datamodel
      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 
     17 package com.android.messaging.datamodel;
     18 
     19 import android.app.Notification;
     20 import android.app.PendingIntent;
     21 import android.content.Context;
     22 import android.content.Intent;
     23 import android.content.pm.PackageManager.NameNotFoundException;
     24 import android.content.res.Resources;
     25 import android.graphics.Bitmap;
     26 import android.graphics.Bitmap.Config;
     27 import android.graphics.BitmapFactory;
     28 import android.graphics.Typeface;
     29 import android.media.AudioManager;
     30 import android.net.Uri;
     31 import android.os.Bundle;
     32 import android.os.SystemClock;
     33 import android.provider.ContactsContract;
     34 import android.provider.ContactsContract.Contacts;
     35 import android.support.v4.app.NotificationCompat;
     36 import android.support.v4.app.NotificationCompat.WearableExtender;
     37 import android.support.v4.app.NotificationManagerCompat;
     38 import android.support.v4.app.RemoteInput;
     39 import android.support.v4.util.SimpleArrayMap;
     40 import android.text.Spannable;
     41 import android.text.SpannableStringBuilder;
     42 import android.text.TextUtils;
     43 import android.text.style.StyleSpan;
     44 import android.text.style.TextAppearanceSpan;
     45 
     46 import com.android.messaging.Factory;
     47 import com.android.messaging.R;
     48 import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState;
     49 import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo;
     50 import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState;
     51 import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState;
     52 import com.android.messaging.datamodel.action.MarkAsReadAction;
     53 import com.android.messaging.datamodel.action.MarkAsSeenAction;
     54 import com.android.messaging.datamodel.action.RedownloadMmsAction;
     55 import com.android.messaging.datamodel.data.ConversationListItemData;
     56 import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
     57 import com.android.messaging.datamodel.media.ImageResource;
     58 import com.android.messaging.datamodel.media.MediaRequest;
     59 import com.android.messaging.datamodel.media.MediaResourceManager;
     60 import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor;
     61 import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
     62 import com.android.messaging.datamodel.media.VideoThumbnailRequest;
     63 import com.android.messaging.sms.MmsSmsUtils;
     64 import com.android.messaging.sms.MmsUtils;
     65 import com.android.messaging.ui.UIIntents;
     66 import com.android.messaging.util.Assert;
     67 import com.android.messaging.util.AvatarUriUtil;
     68 import com.android.messaging.util.BugleGservices;
     69 import com.android.messaging.util.BugleGservicesKeys;
     70 import com.android.messaging.util.BuglePrefs;
     71 import com.android.messaging.util.BuglePrefsKeys;
     72 import com.android.messaging.util.ContentType;
     73 import com.android.messaging.util.ConversationIdSet;
     74 import com.android.messaging.util.ImageUtils;
     75 import com.android.messaging.util.LogUtil;
     76 import com.android.messaging.util.NotificationPlayer;
     77 import com.android.messaging.util.OsUtil;
     78 import com.android.messaging.util.PendingIntentConstants;
     79 import com.android.messaging.util.PhoneUtils;
     80 import com.android.messaging.util.RingtoneUtil;
     81 import com.android.messaging.util.ThreadUtil;
     82 import com.android.messaging.util.UriUtil;
     83 
     84 import java.util.HashSet;
     85 import java.util.Iterator;
     86 import java.util.List;
     87 import java.util.Locale;
     88 import 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  */
    103 public 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