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 androidx.core.app.NotificationCompat;
36 import androidx.core.app.NotificationCompat.WearableExtender;
37 import androidx.core.app.NotificationManagerCompat;
38 import androidx.core.app.RemoteInput;
39 import androidx.collection.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      */
update(final boolean silent, final int coverage)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      */
update(final boolean silent, final String conversationId, final int coverage)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      */
cancel(final int type)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      */
cancel(final int type, final String conversationId, final boolean isBundledNotification)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      */
cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren, final NotificationState state)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      */
shouldNotify()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      */
shouldVibrate(final NotificationState state)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 
getNotificationRingtoneUriForConversationId(final String conversationId)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      */
buildNotificationTag(final String name, final String conversationId)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      */
buildNotificationTag(final int type, final String conversationId)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      */
buildNotificationTag(final int type, final String conversationId, final boolean bundledNotification)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 
checkInitialized()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 
processAndSend(final NotificationState state, final boolean silent, final boolean softSound)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      */
getThumbnailUri(final Uri avatarUri)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      */
getDisplayPhotoUri(final Uri avatarUri)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 
requestContactDisplayPhoto(final Context context, final Uri displayPhotoUri)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 
createMessageNotification(final boolean silent, final String conversationId)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 
updateBuilderAudioVibrate(final NotificationState state, final NotificationCompat.Builder notifBuilder, final boolean silent, final Uri ringtoneUri, final String conversationId)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 
sendNotification(final NotificationState notificationState, final Bitmap avatarIcon, final Bitmap avatarHiRes)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 
fireOffNotification(final NotificationState notificationState, final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap)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 
setWearableGroupOptions(final NotificationCompat.Builder notifBuilder, final NotificationState notificationState)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 
maybeAddWearableConversationLog( final WearableExtender wearableExtender, final MultiMessageNotificationState notificationState)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 
addWearableVoiceReplyAction( final WearableExtender wearableExtender, final NotificationState notificationState)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 
addDownloadMmsAction(final NotificationCompat.Builder notifBuilder, final WearableExtender wearableExtender, final NotificationState notificationState)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 
doNotify(final Notification notification, final NotificationState notificationState)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
formatInboxMessage(final String sender, final CharSequence message, final Uri attachmentUri, final String attachmentType)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 
buildColonSeparatedMessage( final String title, final CharSequence content, final Uri attachmentUri, final String attachmentType)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 
buildSpaceSeparatedMessage( final String title, final CharSequence content, final Uri attachmentUri, final String attachmentType)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      */
buildBoldedMessage( final String title, final CharSequence message, final Uri attachmentUri, final String attachmentType, final int separatorId)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 
formatAttachmentTag(final String author, final String attachmentType)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      */
playObservableConversationNotificationSound(final Uri ringtoneUri)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 
isWearCompanionAppInstalled()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      */
markAllMessagesAsSeen()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      */
markMessagesAsRead(final String conversationId)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      */
getGroupChildIds(final Context context)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      */
writeGroupChildIds(final Context context, final ConversationIdSet childIds)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      */
resetLastMessageDing(final String conversationId)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 
notifyEmergencySmsFailed(final String emergencyNumber, final String conversationId)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