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 package com.android.messaging.datamodel;
17 
18 import android.app.Notification;
19 import android.app.PendingIntent;
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.database.Cursor;
23 import android.graphics.Typeface;
24 import android.net.Uri;
25 import androidx.core.app.NotificationCompat;
26 import androidx.core.app.NotificationCompat.Builder;
27 import androidx.core.app.NotificationCompat.WearableExtender;
28 import androidx.core.app.NotificationManagerCompat;
29 import android.text.Html;
30 import android.text.Spannable;
31 import android.text.SpannableString;
32 import android.text.SpannableStringBuilder;
33 import android.text.Spanned;
34 import android.text.TextUtils;
35 import android.text.style.ForegroundColorSpan;
36 import android.text.style.StyleSpan;
37 import android.text.style.TextAppearanceSpan;
38 import android.text.style.URLSpan;
39 
40 import com.android.messaging.Factory;
41 import com.android.messaging.R;
42 import com.android.messaging.datamodel.data.ConversationListItemData;
43 import com.android.messaging.datamodel.data.ConversationMessageData;
44 import com.android.messaging.datamodel.data.ConversationParticipantsData;
45 import com.android.messaging.datamodel.data.MessageData;
46 import com.android.messaging.datamodel.data.MessagePartData;
47 import com.android.messaging.datamodel.data.ParticipantData;
48 import com.android.messaging.datamodel.media.VideoThumbnailRequest;
49 import com.android.messaging.sms.MmsUtils;
50 import com.android.messaging.ui.UIIntents;
51 import com.android.messaging.util.Assert;
52 import com.android.messaging.util.AvatarUriUtil;
53 import com.android.messaging.util.BugleGservices;
54 import com.android.messaging.util.BugleGservicesKeys;
55 import com.android.messaging.util.ContentType;
56 import com.android.messaging.util.ConversationIdSet;
57 import com.android.messaging.util.LogUtil;
58 import com.android.messaging.util.PendingIntentConstants;
59 import com.android.messaging.util.UriUtil;
60 import com.google.common.collect.Lists;
61 
62 import java.util.ArrayList;
63 import java.util.HashMap;
64 import java.util.HashSet;
65 import java.util.Iterator;
66 import java.util.LinkedHashMap;
67 import java.util.List;
68 import java.util.Map;
69 
70 /**
71  * Notification building class for conversation messages.
72  *
73  * Message Notifications are built in several stages with several utility classes.
74  * 1) Perform a database query and fill a data structure with information on messages and
75  *    conversations which need to be notified.
76  * 2) Based on the data structure choose an appropriate NotificationState subclass to
77  *    represent all the notifications.
78  *    -- For one or more messages in one conversation: MultiMessageNotificationState.
79  *    -- For multiple messages in multiple conversations: MultiConversationNotificationState
80  *
81  *  A three level structure is used to coalesce the data from the database. From bottom to top:
82  *  1) NotificationLineInfo - A single message that needs to be notified.
83  *  2) ConversationLineInfo - A list of NotificationLineInfo in a single conversation.
84  *  3) ConversationInfoList - A list of ConversationLineInfo and the total number of messages.
85  *
86  *  The createConversationInfoList function performs the query and creates the data structure.
87  */
88 public abstract class MessageNotificationState extends NotificationState {
89     // Logging
90     static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG;
91     private static final int MAX_MESSAGES_IN_WEARABLE_PAGE = 20;
92 
93     private static final int MAX_CHARACTERS_IN_GROUP_NAME = 30;
94 
95     private static final int REPLY_INTENT_REQUEST_CODE_OFFSET = 0;
96     private static final int NUM_EXTRA_REQUEST_CODES_NEEDED = 1;
97     protected String mTickerSender = null;
98     protected CharSequence mTickerText = null;
99     protected String mTitle = null;
100     protected CharSequence mContent = null;
101     protected Uri mAttachmentUri = null;
102     protected String mAttachmentType = null;
103     protected boolean mTickerNoContent;
104 
105     @Override
getAttachmentUri()106     protected Uri getAttachmentUri() {
107         return mAttachmentUri;
108     }
109 
110     @Override
getAttachmentType()111     protected String getAttachmentType() {
112         return mAttachmentType;
113     }
114 
115     @Override
getIcon()116     public int getIcon() {
117         return R.drawable.ic_sms_light;
118     }
119 
120     @Override
getPriority()121     public int getPriority() {
122         // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker
123         // isn't displayed.
124         return Notification.PRIORITY_HIGH;
125     }
126 
127     /**
128      * Base class for single notification events for messages. Multiple of these
129      * may be grouped into a single conversation.
130      */
131     static class NotificationLineInfo {
132 
133         final int mNotificationType;
134 
NotificationLineInfo()135         NotificationLineInfo() {
136             mNotificationType = BugleNotifications.LOCAL_SMS_NOTIFICATION;
137         }
138 
NotificationLineInfo(final int notificationType)139         NotificationLineInfo(final int notificationType) {
140             mNotificationType = notificationType;
141         }
142     }
143 
144     /**
145      * Information on a single chat message which should be shown in a notification.
146      */
147     static class MessageLineInfo extends NotificationLineInfo {
148         final CharSequence mText;
149         Uri mAttachmentUri;
150         String mAttachmentType;
151         final String mAuthorFullName;
152         final String mAuthorFirstName;
153         boolean mIsManualDownloadNeeded;
154         final String mMessageId;
155 
MessageLineInfo(final boolean isGroup, final String authorFullName, final String authorFirstName, final CharSequence text, final Uri attachmentUrl, final String attachmentType, final boolean isManualDownloadNeeded, final String messageId)156         MessageLineInfo(final boolean isGroup, final String authorFullName,
157                 final String authorFirstName, final CharSequence text, final Uri attachmentUrl,
158                 final String attachmentType, final boolean isManualDownloadNeeded,
159                 final String messageId) {
160             super(BugleNotifications.LOCAL_SMS_NOTIFICATION);
161             mAuthorFullName = authorFullName;
162             mAuthorFirstName = authorFirstName;
163             mText = text;
164             mAttachmentUri = attachmentUrl;
165             mAttachmentType = attachmentType;
166             mIsManualDownloadNeeded = isManualDownloadNeeded;
167             mMessageId = messageId;
168         }
169     }
170 
171     /**
172      * Information on all the notification messages within a single conversation.
173      */
174     static class ConversationLineInfo {
175         // Conversation id of the latest message in the notification for this merged conversation.
176         final String mConversationId;
177 
178         // True if this represents a group conversation.
179         final boolean mIsGroup;
180 
181         // Name of the group conversation if available.
182         final String mGroupConversationName;
183 
184         // True if this conversation's recipients includes one or more email address(es)
185         // (see ConversationColumns.INCLUDE_EMAIL_ADDRESS)
186         final boolean mIncludeEmailAddress;
187 
188         // Timestamp of the latest message
189         final long mReceivedTimestamp;
190 
191         // Self participant id.
192         final String mSelfParticipantId;
193 
194         // List of individual line notifications to be parsed later.
195         final List<NotificationLineInfo> mLineInfos;
196 
197         // Total number of messages. Might be different that mLineInfos.size() as the number of
198         // line infos is capped.
199         int mTotalMessageCount;
200 
201         // Custom ringtone if set
202         final String mRingtoneUri;
203 
204         // Should notification be enabled for this conversation?
205         final boolean mNotificationEnabled;
206 
207         // Should notifications vibrate for this conversation?
208         final boolean mNotificationVibrate;
209 
210         // Avatar uri of sender
211         final Uri mAvatarUri;
212 
213         // Contact uri of sender
214         final Uri mContactUri;
215 
216         // Subscription id.
217         final int mSubId;
218 
219         // Number of participants
220         final int mParticipantCount;
221 
ConversationLineInfo(final String conversationId, final boolean isGroup, final String groupConversationName, final boolean includeEmailAddress, final long receivedTimestamp, final String selfParticipantId, final String ringtoneUri, final boolean notificationEnabled, final boolean notificationVibrate, final Uri avatarUri, final Uri contactUri, final int subId, final int participantCount)222         public ConversationLineInfo(final String conversationId,
223                 final boolean isGroup,
224                 final String groupConversationName,
225                 final boolean includeEmailAddress,
226                 final long receivedTimestamp,
227                 final String selfParticipantId,
228                 final String ringtoneUri,
229                 final boolean notificationEnabled,
230                 final boolean notificationVibrate,
231                 final Uri avatarUri,
232                 final Uri contactUri,
233                 final int subId,
234                 final int participantCount) {
235             mConversationId = conversationId;
236             mIsGroup = isGroup;
237             mGroupConversationName = groupConversationName;
238             mIncludeEmailAddress = includeEmailAddress;
239             mReceivedTimestamp = receivedTimestamp;
240             mSelfParticipantId = selfParticipantId;
241             mLineInfos = new ArrayList<NotificationLineInfo>();
242             mTotalMessageCount = 0;
243             mRingtoneUri = ringtoneUri;
244             mAvatarUri = avatarUri;
245             mContactUri = contactUri;
246             mNotificationEnabled = notificationEnabled;
247             mNotificationVibrate = notificationVibrate;
248             mSubId = subId;
249             mParticipantCount = participantCount;
250         }
251 
getLatestMessageNotificationType()252         public int getLatestMessageNotificationType() {
253             final MessageLineInfo messageLineInfo = getLatestMessageLineInfo();
254             if (messageLineInfo == null) {
255                 return BugleNotifications.LOCAL_SMS_NOTIFICATION;
256             }
257             return messageLineInfo.mNotificationType;
258         }
259 
getLatestMessageId()260         public String getLatestMessageId() {
261             final MessageLineInfo messageLineInfo = getLatestMessageLineInfo();
262             if (messageLineInfo == null) {
263                 return null;
264             }
265             return messageLineInfo.mMessageId;
266         }
267 
getDoesLatestMessageNeedDownload()268         public boolean getDoesLatestMessageNeedDownload() {
269             final MessageLineInfo messageLineInfo = getLatestMessageLineInfo();
270             if (messageLineInfo == null) {
271                 return false;
272             }
273             return messageLineInfo.mIsManualDownloadNeeded;
274         }
275 
getLatestMessageLineInfo()276         private MessageLineInfo getLatestMessageLineInfo() {
277             // The latest message is stored at index zero of the message line infos.
278             if (mLineInfos.size() > 0 && mLineInfos.get(0) instanceof MessageLineInfo) {
279                 return (MessageLineInfo) mLineInfos.get(0);
280             }
281             return null;
282         }
283     }
284 
285     /**
286      * Information on all the notification messages across all conversations.
287      */
288     public static class ConversationInfoList {
289         final int mMessageCount;
290         final List<ConversationLineInfo> mConvInfos;
ConversationInfoList(final int count, final List<ConversationLineInfo> infos)291         public ConversationInfoList(final int count, final List<ConversationLineInfo> infos) {
292             mMessageCount = count;
293             mConvInfos = infos;
294         }
295     }
296 
297     final ConversationInfoList mConvList;
298     private long mLatestReceivedTimestamp;
299 
makeConversationIdSet(final ConversationInfoList convList)300     private static ConversationIdSet makeConversationIdSet(final ConversationInfoList convList) {
301         ConversationIdSet set = null;
302         if (convList != null && convList.mConvInfos != null && convList.mConvInfos.size() > 0) {
303             set = new ConversationIdSet();
304             for (final ConversationLineInfo info : convList.mConvInfos) {
305                     set.add(info.mConversationId);
306             }
307         }
308         return set;
309     }
310 
MessageNotificationState(final ConversationInfoList convList)311     protected MessageNotificationState(final ConversationInfoList convList) {
312         super(makeConversationIdSet(convList));
313         mConvList = convList;
314         mType = PendingIntentConstants.SMS_NOTIFICATION_ID;
315         mLatestReceivedTimestamp = Long.MIN_VALUE;
316         if (convList != null) {
317             for (final ConversationLineInfo info : convList.mConvInfos) {
318                 mLatestReceivedTimestamp = Math.max(mLatestReceivedTimestamp,
319                         info.mReceivedTimestamp);
320             }
321         }
322     }
323 
324     @Override
getLatestReceivedTimestamp()325     public long getLatestReceivedTimestamp() {
326         return mLatestReceivedTimestamp;
327     }
328 
329     @Override
getNumRequestCodesNeeded()330     public int getNumRequestCodesNeeded() {
331         // Get additional request codes for the Reply PendingIntent (wearables only)
332         // and the DND PendingIntent.
333         return super.getNumRequestCodesNeeded() + NUM_EXTRA_REQUEST_CODES_NEEDED;
334     }
335 
getBaseExtraRequestCode()336     private int getBaseExtraRequestCode() {
337         return mBaseRequestCode + super.getNumRequestCodesNeeded();
338     }
339 
getReplyIntentRequestCode()340     public int getReplyIntentRequestCode() {
341         return getBaseExtraRequestCode() + REPLY_INTENT_REQUEST_CODE_OFFSET;
342     }
343 
344     @Override
getClearIntent()345     public PendingIntent getClearIntent() {
346         return UIIntents.get().getPendingIntentForClearingNotifications(
347                     Factory.get().getApplicationContext(),
348                     BugleNotifications.UPDATE_MESSAGES,
349                     mConversationIds,
350                     getClearIntentRequestCode());
351     }
352 
353     /**
354      * Notification for multiple messages in at least 2 different conversations.
355      */
356     public static class MultiConversationNotificationState extends MessageNotificationState {
357 
358         public final List<MessageNotificationState>
359                 mChildren = new ArrayList<MessageNotificationState>();
360 
MultiConversationNotificationState( final ConversationInfoList convList, final MessageNotificationState state)361         public MultiConversationNotificationState(
362                 final ConversationInfoList convList, final MessageNotificationState state) {
363             super(convList);
364             mAttachmentUri = null;
365             mAttachmentType = null;
366 
367             // Pull the ticker title/text from the single notification
368             mTickerSender = state.getTitle();
369             mTitle = Factory.get().getApplicationContext().getResources().getQuantityString(
370                     R.plurals.notification_new_messages,
371                     convList.mMessageCount, convList.mMessageCount);
372             mTickerText = state.mContent;
373 
374             // Create child notifications for each conversation,
375             // which will be displayed (only) on a wearable device.
376             for (int i = 0; i < convList.mConvInfos.size(); i++) {
377                 final ConversationLineInfo convInfo = convList.mConvInfos.get(i);
378                 if (!(convInfo.mLineInfos.get(0) instanceof MessageLineInfo)) {
379                     continue;
380                 }
381                 setPeopleForConversation(convInfo.mConversationId);
382                 final ConversationInfoList list = new ConversationInfoList(
383                         convInfo.mTotalMessageCount, Lists.newArrayList(convInfo));
384                 mChildren.add(new BundledMessageNotificationState(list, i));
385             }
386         }
387 
388         @Override
getIcon()389         public int getIcon() {
390             return R.drawable.ic_sms_multi_light;
391         }
392 
393         @Override
build(final Builder builder)394         protected NotificationCompat.Style build(final Builder builder) {
395             builder.setContentTitle(mTitle);
396             NotificationCompat.InboxStyle inboxStyle = null;
397             inboxStyle = new NotificationCompat.InboxStyle(builder);
398 
399             final Context context = Factory.get().getApplicationContext();
400             // enumeration_comma is defined as ", "
401             final String separator = context.getString(R.string.enumeration_comma);
402             final StringBuilder senders = new StringBuilder();
403             long when = 0;
404             for (int i = 0; i < mConvList.mConvInfos.size(); i++) {
405                 final ConversationLineInfo convInfo = mConvList.mConvInfos.get(i);
406                 if (convInfo.mReceivedTimestamp > when) {
407                     when = convInfo.mReceivedTimestamp;
408                 }
409                 String sender;
410                 CharSequence text;
411                 final NotificationLineInfo lineInfo = convInfo.mLineInfos.get(0);
412                 final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfo;
413                 if (convInfo.mIsGroup) {
414                     sender = (convInfo.mGroupConversationName.length() >
415                             MAX_CHARACTERS_IN_GROUP_NAME) ?
416                                     truncateGroupMessageName(convInfo.mGroupConversationName)
417                                     : convInfo.mGroupConversationName;
418                 } else {
419                     sender = messageLineInfo.mAuthorFullName;
420                 }
421                 text = messageLineInfo.mText;
422                 mAttachmentUri = messageLineInfo.mAttachmentUri;
423                 mAttachmentType = messageLineInfo.mAttachmentType;
424 
425                 inboxStyle.addLine(BugleNotifications.formatInboxMessage(
426                         sender, text, mAttachmentUri, mAttachmentType));
427                 if (sender != null) {
428                     if (senders.length() > 0) {
429                         senders.append(separator);
430                     }
431                     senders.append(sender);
432                 }
433             }
434             // for collapsed state
435             mContent = senders;
436             builder.setContentText(senders)
437                 .setTicker(getTicker())
438                 .setWhen(when);
439 
440             return inboxStyle;
441         }
442     }
443 
444     /**
445      * Truncate group conversation name to be displayed in the notifications. This either truncates
446      * the entire group name or finds the last comma in the available length and truncates the name
447      * at that point
448      */
truncateGroupMessageName(final String conversationName)449     private static String truncateGroupMessageName(final String conversationName) {
450         int endIndex = MAX_CHARACTERS_IN_GROUP_NAME;
451         for (int i = MAX_CHARACTERS_IN_GROUP_NAME; i >= 0; i--) {
452             // The dividing marker should stay consistent with ConversationListItemData.DIVIDER_TEXT
453             if (conversationName.charAt(i) == ',') {
454                 endIndex = i;
455                 break;
456             }
457         }
458         return conversationName.substring(0, endIndex) + '\u2026';
459     }
460 
461     /**
462      * Notification for multiple messages in a single conversation. Also used if there is a single
463      * message in a single conversation.
464      */
465     public static class MultiMessageNotificationState extends MessageNotificationState {
466 
MultiMessageNotificationState(final ConversationInfoList convList)467         public MultiMessageNotificationState(final ConversationInfoList convList) {
468             super(convList);
469             // This conversation has been accepted.
470             final ConversationLineInfo convInfo = convList.mConvInfos.get(0);
471             setAvatarUrlsForConversation(convInfo.mConversationId);
472             setPeopleForConversation(convInfo.mConversationId);
473 
474             final Context context = Factory.get().getApplicationContext();
475             MessageLineInfo messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0);
476             // attached photo
477             mAttachmentUri = messageInfo.mAttachmentUri;
478             mAttachmentType = messageInfo.mAttachmentType;
479             mContent = messageInfo.mText;
480 
481             if (mAttachmentUri != null) {
482                 // The default attachment type is an image, since that's what was originally
483                 // supported. When there's no content type, assume it's an image.
484                 int message = R.string.notification_picture;
485                 if (ContentType.isAudioType(mAttachmentType)) {
486                     message = R.string.notification_audio;
487                 } else if (ContentType.isVideoType(mAttachmentType)) {
488                     message = R.string.notification_video;
489                 } else if (ContentType.isVCardType(mAttachmentType)) {
490                     message = R.string.notification_vcard;
491                 }
492                 final String attachment = context.getString(message);
493                 final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
494                 if (!TextUtils.isEmpty(mContent)) {
495                     spanBuilder.append(mContent).append(System.getProperty("line.separator"));
496                 }
497                 final int start = spanBuilder.length();
498                 spanBuilder.append(attachment);
499                 spanBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, spanBuilder.length(),
500                         Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
501                 mContent = spanBuilder;
502             }
503             if (convInfo.mIsGroup) {
504                 // When the message is part of a group, the sender's first name
505                 // is prepended to the message, but not for the ticker message.
506                 mTickerText = mContent;
507                 mTickerSender = messageInfo.mAuthorFullName;
508                 // append the bold name to the front of the message
509                 mContent = BugleNotifications.buildSpaceSeparatedMessage(
510                         messageInfo.mAuthorFullName, mContent, mAttachmentUri,
511                         mAttachmentType);
512                 mTitle = convInfo.mGroupConversationName;
513             } else {
514                 // No matter how many messages there are, since this is a 1:1, just
515                 // get the author full name from the first one.
516                 messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0);
517                 mTitle = messageInfo.mAuthorFullName;
518             }
519         }
520 
521         @Override
build(final Builder builder)522         protected NotificationCompat.Style build(final Builder builder) {
523             builder.setContentTitle(mTitle)
524                 .setTicker(getTicker());
525 
526             NotificationCompat.Style notifStyle = null;
527             final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
528             final List<NotificationLineInfo> lineInfos = convInfo.mLineInfos;
529             final int messageCount = lineInfos.size();
530             // At this point, all the messages come from the same conversation. We need to load
531             // the sender's avatar and then finish building the notification on a callback.
532 
533             builder.setContentText(mContent);   // for collapsed state
534 
535             if (messageCount == 1) {
536                 final boolean shouldShowImage = ContentType.isImageType(mAttachmentType)
537                         || (ContentType.isVideoType(mAttachmentType)
538                         && VideoThumbnailRequest.shouldShowIncomingVideoThumbnails());
539                 if (mAttachmentUri != null && shouldShowImage) {
540                     // Show "Picture" as the content
541                     final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0);
542                     String authorFirstName = messageLineInfo.mAuthorFirstName;
543 
544                     // For the collapsed state, just show "picture" unless this is a
545                     // group conversation. If it's a group, show the sender name and
546                     // "picture".
547                     final CharSequence tickerTag =
548                             BugleNotifications.formatAttachmentTag(authorFirstName,
549                                     mAttachmentType);
550                     // For 1:1 notifications don't show first name in the notification, but
551                     // do show it in the ticker text
552                     CharSequence pictureTag = tickerTag;
553                     if (!convInfo.mIsGroup) {
554                         authorFirstName = null;
555                         pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName,
556                                 mAttachmentType);
557                     }
558                     builder.setContentText(pictureTag);
559                     builder.setTicker(tickerTag);
560 
561                     notifStyle = new NotificationCompat.BigPictureStyle(builder)
562                         .setSummaryText(BugleNotifications.formatInboxMessage(
563                                 authorFirstName,
564                                 null, null,
565                                 null));  // expanded state, just show sender
566                 } else {
567                     notifStyle = new NotificationCompat.BigTextStyle(builder)
568                     .bigText(mContent);
569                 }
570             } else {
571                 // We've got multiple messages for the same sender.
572                 // Starting with the oldest new message, display the full text of each message.
573                 // Begin a line for each subsequent message.
574                 final SpannableStringBuilder buf = new SpannableStringBuilder();
575 
576                 for (int i = lineInfos.size() - 1; i >= 0; --i) {
577                     final NotificationLineInfo info = lineInfos.get(i);
578                     final MessageLineInfo messageLineInfo = (MessageLineInfo) info;
579                     mAttachmentUri = messageLineInfo.mAttachmentUri;
580                     mAttachmentType = messageLineInfo.mAttachmentType;
581                     CharSequence text = messageLineInfo.mText;
582                     if (!TextUtils.isEmpty(text) || mAttachmentUri != null) {
583                         if (convInfo.mIsGroup) {
584                             // append the bold name to the front of the message
585                             text = BugleNotifications.buildSpaceSeparatedMessage(
586                                     messageLineInfo.mAuthorFullName, text, mAttachmentUri,
587                                     mAttachmentType);
588                         } else {
589                             text = BugleNotifications.buildSpaceSeparatedMessage(
590                                     null, text, mAttachmentUri, mAttachmentType);
591                         }
592                         buf.append(text);
593                         if (i > 0) {
594                             buf.append('\n');
595                         }
596                     }
597                 }
598 
599                 // Show a single notification -- big style with the text of all the messages
600                 notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf);
601             }
602             builder.setWhen(convInfo.mReceivedTimestamp);
603             return notifStyle;
604         }
605 
606     }
607 
firstNameUsedMoreThanOnce( final HashMap<String, Integer> map, final String firstName)608     private static boolean firstNameUsedMoreThanOnce(
609             final HashMap<String, Integer> map, final String firstName) {
610         if (map == null) {
611             return false;
612         }
613         if (firstName == null) {
614             return false;
615         }
616         final Integer count = map.get(firstName);
617         if (count != null) {
618             return count > 1;
619         } else {
620             return false;
621         }
622     }
623 
scanFirstNames(final String conversationId)624     private static HashMap<String, Integer> scanFirstNames(final String conversationId) {
625         final Context context = Factory.get().getApplicationContext();
626         final Uri uri =
627                 MessagingContentProvider.buildConversationParticipantsUri(conversationId);
628         final ConversationParticipantsData participantsData = new ConversationParticipantsData();
629 
630         try (final Cursor participantsCursor = context.getContentResolver().query(
631                     uri, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null)) {
632             participantsData.bind(participantsCursor);
633         }
634 
635         final Iterator<ParticipantData> iter = participantsData.iterator();
636 
637         final HashMap<String, Integer> firstNames = new HashMap<String, Integer>();
638         boolean seenSelf = false;
639         while (iter.hasNext()) {
640             final ParticipantData participant = iter.next();
641             // Make sure we only add the self participant once
642             if (participant.isSelf()) {
643                 if (seenSelf) {
644                     continue;
645                 } else {
646                     seenSelf = true;
647                 }
648             }
649 
650             final String firstName = participant.getFirstName();
651             if (firstName == null) {
652                 continue;
653             }
654 
655             final int currentCount = firstNames.containsKey(firstName)
656                     ? firstNames.get(firstName)
657                     : 0;
658             firstNames.put(firstName, currentCount + 1);
659         }
660         return firstNames;
661     }
662 
663     // Essentially, we're building a list of the past 20 messages for this conversation to display
664     // on the wearable.
buildConversationPageForWearable(final String conversationId, int participantCount)665     public static Notification buildConversationPageForWearable(final String conversationId,
666             int participantCount) {
667         final Context context = Factory.get().getApplicationContext();
668 
669         // Limit the number of messages to show. We just want enough to provide context for the
670         // notification. Fetch one more than we need, so we can tell if there are more messages
671         // before the one we're showing.
672         // TODO: in the query, a multipart message will contain a row for each part.
673         // We might need a smarter GROUP_BY. On the other hand, we might want to show each of the
674         // parts as separate messages on the wearable.
675         final int limit = MAX_MESSAGES_IN_WEARABLE_PAGE + 1;
676 
677         final List<CharSequence> messages = Lists.newArrayList();
678         boolean hasSeenMessagesBeforeNotification = false;
679         Cursor convMessageCursor = null;
680         try {
681             final DatabaseWrapper db = DataModel.get().getDatabase();
682 
683             final String[] queryArgs = { conversationId };
684             final String convPageSql = ConversationMessageData.getWearableQuerySql() + " LIMIT " +
685                     limit;
686             convMessageCursor = db.rawQuery(
687                     convPageSql,
688                     queryArgs);
689 
690             if (convMessageCursor == null || !convMessageCursor.moveToFirst()) {
691                 return null;
692             }
693             final ConversationMessageData convMessageData =
694                     new ConversationMessageData();
695 
696             final HashMap<String, Integer> firstNames = scanFirstNames(conversationId);
697             do {
698                 convMessageData.bind(convMessageCursor);
699 
700                 final String authorFullName = convMessageData.getSenderFullName();
701                 final String authorFirstName = convMessageData.getSenderFirstName();
702                 String text = convMessageData.getText();
703 
704                 final boolean isSmsPushNotification = convMessageData.getIsMmsNotification();
705 
706                 // if auto-download was off to show a message to tap to download the message. We
707                 // might need to get that working again.
708                 if (isSmsPushNotification && text != null) {
709                     text = convertHtmlAndStripUrls(text).toString();
710                 }
711                 // Skip messages without any content
712                 if (TextUtils.isEmpty(text) && !convMessageData.hasAttachments()) {
713                     continue;
714                 }
715                 // Track whether there are messages prior to the one(s) shown in the notification.
716                 if (convMessageData.getIsSeen()) {
717                     hasSeenMessagesBeforeNotification = true;
718                 }
719 
720                 final boolean usedMoreThanOnce = firstNameUsedMoreThanOnce(
721                         firstNames, authorFirstName);
722                 String displayName = usedMoreThanOnce ? authorFullName : authorFirstName;
723                 if (TextUtils.isEmpty(displayName)) {
724                     if (convMessageData.getIsIncoming()) {
725                         displayName = convMessageData.getSenderDisplayDestination();
726                         if (TextUtils.isEmpty(displayName)) {
727                             displayName = context.getString(R.string.unknown_sender);
728                         }
729                     } else {
730                         displayName = context.getString(R.string.unknown_self_participant);
731                     }
732                 }
733 
734                 Uri attachmentUri = null;
735                 String attachmentType = null;
736                 final List<MessagePartData> attachments = convMessageData.getAttachments();
737                 for (final MessagePartData messagePartData : attachments) {
738                     // Look for the first attachment that's not the text piece.
739                     if (!messagePartData.isText()) {
740                         attachmentUri = messagePartData.getContentUri();
741                         attachmentType = messagePartData.getContentType();
742                         break;
743                     }
744                 }
745 
746                 final CharSequence message = BugleNotifications.buildSpaceSeparatedMessage(
747                         displayName, text, attachmentUri, attachmentType);
748                 messages.add(message);
749 
750             } while (convMessageCursor.moveToNext());
751         } finally {
752             if (convMessageCursor != null) {
753                 convMessageCursor.close();
754             }
755         }
756 
757         // If there is no conversation history prior to what is already visible in the main
758         // notification, there's no need to include the conversation log, too.
759         final int maxMessagesInNotification = getMaxMessagesInConversationNotification();
760         if (!hasSeenMessagesBeforeNotification && messages.size() <= maxMessagesInNotification) {
761             return null;
762         }
763 
764         final SpannableStringBuilder bigText = new SpannableStringBuilder();
765         // There is at least 1 message prior to the first one that we're going to show.
766         // Indicate this by inserting an ellipsis at the beginning of the conversation log.
767         if (convMessageCursor.getCount() == limit) {
768             bigText.append(context.getString(R.string.ellipsis) + "\n\n");
769             if (messages.size() > MAX_MESSAGES_IN_WEARABLE_PAGE) {
770                 messages.remove(messages.size() - 1);
771             }
772         }
773         // Messages are sorted in descending timestamp order, so iterate backwards
774         // to get them back in ascending order for display purposes.
775         for (int i = messages.size() - 1; i >= 0; --i) {
776             bigText.append(messages.get(i));
777             if (i > 0) {
778                 bigText.append("\n\n");
779             }
780         }
781         ++participantCount;     // Add in myself
782 
783         if (participantCount > 2) {
784             final SpannableString statusText = new SpannableString(
785                     context.getResources().getQuantityString(R.plurals.wearable_participant_count,
786                             participantCount, participantCount));
787             statusText.setSpan(new ForegroundColorSpan(context.getResources().getColor(
788                     R.color.wearable_notification_participants_count)), 0, statusText.length(),
789                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
790             bigText.append("\n\n").append(statusText);
791         }
792 
793         final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context);
794         final NotificationCompat.Style notifStyle =
795                 new NotificationCompat.BigTextStyle(notifBuilder).bigText(bigText);
796         notifBuilder.setStyle(notifStyle);
797 
798         final WearableExtender wearableExtender = new WearableExtender();
799         wearableExtender.setStartScrollBottom(true);
800         notifBuilder.extend(wearableExtender);
801 
802         return notifBuilder.build();
803     }
804 
805     /**
806      * Notification for one or more messages in a single conversation, which is bundled together
807      * with notifications for other conversations on a wearable device.
808      */
809     public static class BundledMessageNotificationState extends MultiMessageNotificationState {
810         public int mGroupOrder;
BundledMessageNotificationState(final ConversationInfoList convList, final int groupOrder)811         public BundledMessageNotificationState(final ConversationInfoList convList,
812                 final int groupOrder) {
813             super(convList);
814             mGroupOrder = groupOrder;
815         }
816     }
817 
818     /**
819      * Performs a query on the database.
820      */
createConversationInfoList()821     private static ConversationInfoList createConversationInfoList() {
822         // Map key is conversation id. We use LinkedHashMap to ensure that entries are iterated in
823         // the same order they were originally added. We scan unseen messages from newest to oldest,
824         // so the corresponding conversations are added in that order, too.
825         final Map<String, ConversationLineInfo> convLineInfos = new LinkedHashMap<>();
826         int messageCount = 0;
827 
828         Cursor convMessageCursor = null;
829         try {
830             final Context context = Factory.get().getApplicationContext();
831             final DatabaseWrapper db = DataModel.get().getDatabase();
832 
833             convMessageCursor = db.rawQuery(
834                     ConversationMessageData.getNotificationQuerySql(),
835                     null);
836 
837             if (convMessageCursor != null && convMessageCursor.moveToFirst()) {
838                 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
839                     LogUtil.v(TAG, "MessageNotificationState: Found unseen message notifications.");
840                 }
841                 final ConversationMessageData convMessageData =
842                         new ConversationMessageData();
843 
844                 HashMap<String, Integer> firstNames = null;
845                 String conversationIdForFirstNames = null;
846                 String groupConversationName = null;
847                 final int maxMessages = getMaxMessagesInConversationNotification();
848 
849                 do {
850                     convMessageData.bind(convMessageCursor);
851 
852                     // First figure out if this is a valid message.
853                     String authorFullName = convMessageData.getSenderFullName();
854                     String authorFirstName = convMessageData.getSenderFirstName();
855                     final String messageText = convMessageData.getText();
856 
857                     final String convId = convMessageData.getConversationId();
858                     final String messageId = convMessageData.getMessageId();
859 
860                     CharSequence text = messageText;
861                     final boolean isManualDownloadNeeded = convMessageData.getIsMmsNotification();
862                     if (isManualDownloadNeeded) {
863                         // Don't try and convert the text from html if it's sms and not a sms push
864                         // notification.
865                         Assert.equals(MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD,
866                                 convMessageData.getStatus());
867                         text = context.getResources().getString(
868                                 R.string.message_title_manual_download);
869                     }
870                     ConversationLineInfo currConvInfo = convLineInfos.get(convId);
871                     if (currConvInfo == null) {
872                         final ConversationListItemData convData =
873                                 ConversationListItemData.getExistingConversation(db, convId);
874                         if (!convData.getNotificationEnabled()) {
875                             // Skip conversations that have notifications disabled.
876                             continue;
877                         }
878                         final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db,
879                                 convData.getSelfId());
880                         groupConversationName = convData.getName();
881                         final Uri avatarUri = AvatarUriUtil.createAvatarUri(
882                                 convMessageData.getSenderProfilePhotoUri(),
883                                 convMessageData.getSenderFullName(),
884                                 convMessageData.getSenderNormalizedDestination(),
885                                 convMessageData.getSenderContactLookupKey());
886                         currConvInfo = new ConversationLineInfo(convId,
887                                 convData.getIsGroup(),
888                                 groupConversationName,
889                                 convData.getIncludeEmailAddress(),
890                                 convMessageData.getReceivedTimeStamp(),
891                                 convData.getSelfId(),
892                                 convData.getNotificationSoundUri(),
893                                 convData.getNotificationEnabled(),
894                                 convData.getNotifiationVibrate(),
895                                 avatarUri,
896                                 convMessageData.getSenderContactLookupUri(),
897                                 subId,
898                                 convData.getParticipantCount());
899                         convLineInfos.put(convId, currConvInfo);
900                     }
901                     // Prepare the message line
902                     if (currConvInfo.mTotalMessageCount < maxMessages) {
903                         if (currConvInfo.mIsGroup) {
904                             if (authorFirstName == null) {
905                                 // authorFullName might be null as well. In that case, we won't
906                                 // show an author. That is better than showing all the group
907                                 // names again on the 2nd line.
908                                 authorFirstName = authorFullName;
909                             }
910                         } else {
911                             // don't recompute this if we don't need to
912                             if (!TextUtils.equals(conversationIdForFirstNames, convId)) {
913                                 firstNames = scanFirstNames(convId);
914                                 conversationIdForFirstNames = convId;
915                             }
916                             if (firstNames != null) {
917                                 final Integer count = firstNames.get(authorFirstName);
918                                 if (count != null && count > 1) {
919                                     authorFirstName = authorFullName;
920                                 }
921                             }
922 
923                             if (authorFullName == null) {
924                                 authorFullName = groupConversationName;
925                             }
926                             if (authorFirstName == null) {
927                                 authorFirstName = groupConversationName;
928                             }
929                         }
930                         final String subjectText = MmsUtils.cleanseMmsSubject(
931                                 context.getResources(),
932                                 convMessageData.getMmsSubject());
933                         if (!TextUtils.isEmpty(subjectText)) {
934                             final String subjectLabel =
935                                     context.getString(R.string.subject_label);
936                             final SpannableStringBuilder spanBuilder =
937                                     new SpannableStringBuilder();
938 
939                             spanBuilder.append(context.getString(R.string.notification_subject,
940                                     subjectLabel, subjectText));
941                             spanBuilder.setSpan(new TextAppearanceSpan(
942                                     context, R.style.NotificationSubjectText), 0,
943                                     subjectLabel.length(),
944                                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
945                             if (!TextUtils.isEmpty(text)) {
946                                 // Now add the actual message text below the subject header.
947                                 spanBuilder.append(System.getProperty("line.separator") + text);
948                             }
949                             text = spanBuilder;
950                         }
951                         // If we've got attachments, find the best one. If one of the messages is
952                         // a photo, save the url so we'll display a big picture notification.
953                         // Otherwise, show the first one we find.
954                         Uri attachmentUri = null;
955                         String attachmentType = null;
956                         final MessagePartData messagePartData =
957                                 getMostInterestingAttachment(convMessageData);
958                         if (messagePartData != null) {
959                             attachmentUri = messagePartData.getContentUri();
960                             attachmentType = messagePartData.getContentType();
961                         }
962                         currConvInfo.mLineInfos.add(new MessageLineInfo(currConvInfo.mIsGroup,
963                                 authorFullName, authorFirstName, text,
964                                 attachmentUri, attachmentType, isManualDownloadNeeded, messageId));
965                     }
966                     messageCount++;
967                     currConvInfo.mTotalMessageCount++;
968                 } while (convMessageCursor.moveToNext());
969             }
970         } finally {
971             if (convMessageCursor != null) {
972                 convMessageCursor.close();
973             }
974         }
975         if (convLineInfos.isEmpty()) {
976             return null;
977         } else {
978             return new ConversationInfoList(messageCount,
979                     Lists.newLinkedList(convLineInfos.values()));
980         }
981     }
982 
983     /**
984      * Scans all the attachments for a message and returns the most interesting one that we'll
985      * show in a notification. By order of importance, in case there are multiple attachments:
986      *      1- an image (because we can show the image as a BigPictureNotification)
987      *      2- a video (because we can show a video frame as a BigPictureNotification)
988      *      3- a vcard
989      *      4- an audio attachment
990      * @return MessagePartData for the most interesting part. Can be null.
991      */
getMostInterestingAttachment( final ConversationMessageData convMessageData)992     private static MessagePartData getMostInterestingAttachment(
993             final ConversationMessageData convMessageData) {
994         final List<MessagePartData> attachments = convMessageData.getAttachments();
995 
996         MessagePartData imagePart = null;
997         MessagePartData audioPart = null;
998         MessagePartData vcardPart = null;
999         MessagePartData videoPart = null;
1000 
1001         // 99.99% of the time there will be 0 or 1 part, since receiving slideshows is so
1002         // uncommon.
1003 
1004         // Remember the first of each type of part.
1005         for (final MessagePartData messagePartData : attachments) {
1006             if (messagePartData.isImage() && imagePart == null) {
1007                 imagePart = messagePartData;
1008             }
1009             if (messagePartData.isVideo() && videoPart == null) {
1010                 videoPart = messagePartData;
1011             }
1012             if (messagePartData.isVCard() && vcardPart == null) {
1013                 vcardPart = messagePartData;
1014             }
1015             if (messagePartData.isAudio() && audioPart == null) {
1016                 audioPart = messagePartData;
1017             }
1018         }
1019         if (imagePart != null) {
1020             return imagePart;
1021         } else if (videoPart != null) {
1022             return videoPart;
1023         } else if (audioPart != null) {
1024             return audioPart;
1025         } else if (vcardPart != null) {
1026             return vcardPart;
1027         }
1028         return null;
1029     }
1030 
getMaxMessagesInConversationNotification()1031     private static int getMaxMessagesInConversationNotification() {
1032         if (!BugleNotifications.isWearCompanionAppInstalled()) {
1033             return BugleGservices.get().getInt(
1034                     BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION,
1035                     BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT);
1036         }
1037         return BugleGservices.get().getInt(
1038                 BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE,
1039                 BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT);
1040     }
1041 
1042     /**
1043      * Scans the database for messages that need to go into notifications. Creates the appropriate
1044      * MessageNotificationState depending on if there are multiple senders, or
1045      * messages from one sender.
1046      * @return NotificationState for the notification created.
1047      */
getNotificationState()1048     public static NotificationState getNotificationState() {
1049         MessageNotificationState state = null;
1050         final ConversationInfoList convList = createConversationInfoList();
1051 
1052         if (convList == null || convList.mConvInfos.size() == 0) {
1053             if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
1054                 LogUtil.v(TAG, "MessageNotificationState: No unseen notifications");
1055             }
1056         } else {
1057             final ConversationLineInfo convInfo = convList.mConvInfos.get(0);
1058             state = new MultiMessageNotificationState(convList);
1059 
1060             if (convList.mConvInfos.size() > 1) {
1061                 // We've got notifications across multiple conversations. Pass in the notification
1062                 // we just built of the most recent notification so we can use that to show the
1063                 // user the new message in the ticker.
1064                 state = new MultiConversationNotificationState(convList, state);
1065             } else {
1066                 // For now, only show avatars for notifications for a single conversation.
1067                 if (convInfo.mAvatarUri != null) {
1068                     if (state.mParticipantAvatarsUris == null) {
1069                         state.mParticipantAvatarsUris = new ArrayList<Uri>(1);
1070                     }
1071                     state.mParticipantAvatarsUris.add(convInfo.mAvatarUri);
1072                 }
1073                 if (convInfo.mContactUri != null) {
1074                     if (state.mParticipantContactUris == null) {
1075                         state.mParticipantContactUris = new ArrayList<Uri>(1);
1076                     }
1077                     state.mParticipantContactUris.add(convInfo.mContactUri);
1078                 }
1079             }
1080         }
1081         if (state != null && LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
1082             LogUtil.v(TAG, "MessageNotificationState: Notification state created"
1083                     + ", title = " + LogUtil.sanitizePII(state.mTitle)
1084                     + ", content = " + LogUtil.sanitizePII(state.mContent.toString()));
1085         }
1086         return state;
1087     }
1088 
getTitle()1089     protected String getTitle() {
1090         return mTitle;
1091     }
1092 
1093     @Override
getLatestMessageNotificationType()1094     public int getLatestMessageNotificationType() {
1095         // This function is called to determine whether the most recent notification applies
1096         // to an sms conversation or a hangout conversation. We have different ringtone/vibrate
1097         // settings for both types of conversations.
1098         if (mConvList.mConvInfos.size() > 0) {
1099             final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
1100             return convInfo.getLatestMessageNotificationType();
1101         }
1102         return BugleNotifications.LOCAL_SMS_NOTIFICATION;
1103     }
1104 
1105     @Override
getRingtoneUri()1106     public String getRingtoneUri() {
1107         if (mConvList.mConvInfos.size() > 0) {
1108             return mConvList.mConvInfos.get(0).mRingtoneUri;
1109         }
1110         return null;
1111     }
1112 
1113     @Override
getNotificationVibrate()1114     public boolean getNotificationVibrate() {
1115         if (mConvList.mConvInfos.size() > 0) {
1116             return mConvList.mConvInfos.get(0).mNotificationVibrate;
1117         }
1118         return false;
1119     }
1120 
getTicker()1121     protected CharSequence getTicker() {
1122         return BugleNotifications.buildColonSeparatedMessage(
1123                 mTickerSender != null ? mTickerSender : mTitle,
1124                 mTickerText != null ? mTickerText : (mTickerNoContent ? null : mContent), null,
1125                         null);
1126     }
1127 
convertHtmlAndStripUrls(final String s)1128     private static CharSequence convertHtmlAndStripUrls(final String s) {
1129         final Spanned text = Html.fromHtml(s);
1130         if (text instanceof Spannable) {
1131             stripUrls((Spannable) text);
1132         }
1133         return text;
1134     }
1135 
1136     // Since we don't want to show URLs in notifications, a function
1137     // to remove them in place.
stripUrls(final Spannable text)1138     private static void stripUrls(final Spannable text) {
1139         final URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class);
1140         for (final URLSpan span : spans) {
1141             text.removeSpan(span);
1142         }
1143     }
1144 
1145     /*
1146     private static void updateAlertStatusMessages(final long thresholdDeltaMs) {
1147         // TODO may need this when supporting error notifications
1148         final EsDatabaseHelper helper = EsDatabaseHelper.getDatabaseHelper();
1149         final ContentValues values = new ContentValues();
1150         final long nowMicros = System.currentTimeMillis() * 1000;
1151         values.put(MessageColumns.ALERT_STATUS, "1");
1152         final String selection =
1153                 MessageColumns.ALERT_STATUS + "=0 AND (" +
1154                 MessageColumns.STATUS + "=" + EsProvider.MESSAGE_STATUS_FAILED_TO_SEND + " OR (" +
1155                 MessageColumns.STATUS + "!=" + EsProvider.MESSAGE_STATUS_ON_SERVER + " AND " +
1156                 MessageColumns.TIMESTAMP + "+" + thresholdDeltaMs*1000 + "<" + nowMicros + ")) ";
1157 
1158         final int updateCount = helper.getWritableDatabaseWrapper().update(
1159                 EsProvider.MESSAGES_TABLE,
1160                 values,
1161                 selection,
1162                 null);
1163         if (updateCount > 0) {
1164             EsConversationsData.notifyConversationsChanged();
1165         }
1166     }*/
1167 
applyWarningTextColor(final Context context, final CharSequence text)1168     static CharSequence applyWarningTextColor(final Context context,
1169             final CharSequence text) {
1170         if (text == null) {
1171             return null;
1172         }
1173         final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
1174         spanBuilder.append(text);
1175         spanBuilder.setSpan(new ForegroundColorSpan(context.getResources().getColor(
1176                 R.color.notification_warning_color)), 0, text.length(),
1177                 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1178         return spanBuilder;
1179     }
1180 
1181     /**
1182      * Check for failed messages and post notifications as needed.
1183      * TODO: Rewrite this as a NotificationState.
1184      */
checkFailedMessages()1185     public static void checkFailedMessages() {
1186         final DatabaseWrapper db = DataModel.get().getDatabase();
1187 
1188         final Cursor messageDataCursor = db.query(DatabaseHelper.MESSAGES_TABLE,
1189             MessageData.getProjection(),
1190             FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE,
1191             null /*selectionArgs*/,
1192             null /*groupBy*/,
1193             null /*having*/,
1194             FailedMessageQuery.FAILED_ORDER_BY);
1195 
1196         try {
1197             final Context context = Factory.get().getApplicationContext();
1198             final Resources resources = context.getResources();
1199             final NotificationManagerCompat notificationManager =
1200                     NotificationManagerCompat.from(context);
1201             if (messageDataCursor != null) {
1202                 final MessageData messageData = new MessageData();
1203 
1204                 final HashSet<String> conversationsWithFailedMessages = new HashSet<String>();
1205 
1206                 // track row ids in case we want to display something that requires this
1207                 // information
1208                 final ArrayList<Integer> failedMessages = new ArrayList<Integer>();
1209 
1210                 int cursorPosition = -1;
1211                 final long when = 0;
1212 
1213                 messageDataCursor.moveToPosition(-1);
1214                 while (messageDataCursor.moveToNext()) {
1215                     messageData.bind(messageDataCursor);
1216 
1217                     final String conversationId = messageData.getConversationId();
1218                     if (DataModel.get().isNewMessageObservable(conversationId)) {
1219                         // Don't post a system notification for an observable conversation
1220                         // because we already show an angry red annotation in the conversation
1221                         // itself or in the conversation preview snippet.
1222                         continue;
1223                     }
1224 
1225                     cursorPosition = messageDataCursor.getPosition();
1226                     failedMessages.add(cursorPosition);
1227                     conversationsWithFailedMessages.add(conversationId);
1228                 }
1229 
1230                 if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
1231                     LogUtil.d(TAG, "Found " + failedMessages.size() + " failed messages");
1232                 }
1233                 if (failedMessages.size() > 0) {
1234                     final NotificationCompat.Builder builder =
1235                             new NotificationCompat.Builder(context);
1236 
1237                     CharSequence line1;
1238                     CharSequence line2;
1239                     final boolean isRichContent = false;
1240                     ConversationIdSet conversationIds = null;
1241                     PendingIntent destinationIntent;
1242                     if (failedMessages.size() == 1) {
1243                         messageDataCursor.moveToPosition(cursorPosition);
1244                         messageData.bind(messageDataCursor);
1245                         final String conversationId =  messageData.getConversationId();
1246 
1247                         // We have a single conversation, go directly to that conversation.
1248                         destinationIntent = UIIntents.get()
1249                                 .getPendingIntentForConversationActivity(context,
1250                                         conversationId,
1251                                         null /*draft*/);
1252 
1253                         conversationIds = ConversationIdSet.createSet(conversationId);
1254 
1255                         final String failedMessgeSnippet = messageData.getMessageText();
1256                         int failureStringId;
1257                         if (messageData.getStatus() ==
1258                                 MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) {
1259                             failureStringId =
1260                                     R.string.notification_download_failures_line1_singular;
1261                         } else {
1262                             failureStringId = R.string.notification_send_failures_line1_singular;
1263                         }
1264                         line1 = resources.getString(failureStringId);
1265                         line2 = failedMessgeSnippet;
1266                         // Set rich text for non-SMS messages or MMS push notification messages
1267                         // which we generate locally with rich text
1268                         // TODO- fix this
1269 //                        if (messageData.isMmsInd()) {
1270 //                            isRichContent = true;
1271 //                        }
1272                     } else {
1273                         // We have notifications for multiple conversation, go to the conversation
1274                         // list.
1275                         destinationIntent = UIIntents.get()
1276                             .getPendingIntentForConversationListActivity(context);
1277 
1278                         int line1StringId;
1279                         int line2PluralsId;
1280                         if (messageData.getStatus() ==
1281                                 MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) {
1282                             line1StringId =
1283                                     R.string.notification_download_failures_line1_plural;
1284                             line2PluralsId = R.plurals.notification_download_failures;
1285                         } else {
1286                             line1StringId = R.string.notification_send_failures_line1_plural;
1287                             line2PluralsId = R.plurals.notification_send_failures;
1288                         }
1289                         line1 = resources.getString(line1StringId);
1290                         line2 = resources.getQuantityString(
1291                                 line2PluralsId,
1292                                 conversationsWithFailedMessages.size(),
1293                                 failedMessages.size(),
1294                                 conversationsWithFailedMessages.size());
1295                     }
1296                     line1 = applyWarningTextColor(context, line1);
1297                     line2 = applyWarningTextColor(context, line2);
1298 
1299                     final PendingIntent pendingIntentForDelete =
1300                             UIIntents.get().getPendingIntentForClearingNotifications(
1301                                     context,
1302                                     BugleNotifications.UPDATE_ERRORS,
1303                                     conversationIds,
1304                                     0);
1305 
1306                     builder
1307                         .setContentTitle(line1)
1308                         .setTicker(line1)
1309                         .setWhen(when > 0 ? when : System.currentTimeMillis())
1310                         .setSmallIcon(R.drawable.ic_failed_light)
1311                         .setDeleteIntent(pendingIntentForDelete)
1312                         .setContentIntent(destinationIntent)
1313                         .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure));
1314                     if (isRichContent && !TextUtils.isEmpty(line2)) {
1315                         final NotificationCompat.InboxStyle inboxStyle =
1316                                 new NotificationCompat.InboxStyle(builder);
1317                         if (line2 != null) {
1318                             inboxStyle.addLine(Html.fromHtml(line2.toString()));
1319                         }
1320                         builder.setStyle(inboxStyle);
1321                     } else {
1322                         builder.setContentText(line2);
1323                     }
1324 
1325                     if (builder != null) {
1326                         notificationManager.notify(
1327                                 BugleNotifications.buildNotificationTag(
1328                                         PendingIntentConstants.MSG_SEND_ERROR, null),
1329                                 PendingIntentConstants.MSG_SEND_ERROR,
1330                                 builder.build());
1331                     }
1332                 } else {
1333                     notificationManager.cancel(
1334                             BugleNotifications.buildNotificationTag(
1335                                     PendingIntentConstants.MSG_SEND_ERROR, null),
1336                             PendingIntentConstants.MSG_SEND_ERROR);
1337                 }
1338             }
1339         } finally {
1340             if (messageDataCursor != null) {
1341                 messageDataCursor.close();
1342             }
1343         }
1344     }
1345 }
1346