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 android.support.v4.app.NotificationCompat;
26 import android.support.v4.app.NotificationCompat.Builder;
27 import android.support.v4.app.NotificationCompat.WearableExtender;
28 import android.support.v4.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 Cursor participantsCursor = context.getContentResolver().query(
629                 uri, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
630         final ConversationParticipantsData participantsData = new ConversationParticipantsData();
631         participantsData.bind(participantsCursor);
632         final Iterator<ParticipantData> iter = participantsData.iterator();
633 
634         final HashMap<String, Integer> firstNames = new HashMap<String, Integer>();
635         boolean seenSelf = false;
636         while (iter.hasNext()) {
637             final ParticipantData participant = iter.next();
638             // Make sure we only add the self participant once
639             if (participant.isSelf()) {
640                 if (seenSelf) {
641                     continue;
642                 } else {
643                     seenSelf = true;
644                 }
645             }
646 
647             final String firstName = participant.getFirstName();
648             if (firstName == null) {
649                 continue;
650             }
651 
652             final int currentCount = firstNames.containsKey(firstName)
653                     ? firstNames.get(firstName)
654                     : 0;
655             firstNames.put(firstName, currentCount + 1);
656         }
657         return firstNames;
658     }
659 
660     // Essentially, we're building a list of the past 20 messages for this conversation to display
661     // on the wearable.
buildConversationPageForWearable(final String conversationId, int participantCount)662     public static Notification buildConversationPageForWearable(final String conversationId,
663             int participantCount) {
664         final Context context = Factory.get().getApplicationContext();
665 
666         // Limit the number of messages to show. We just want enough to provide context for the
667         // notification. Fetch one more than we need, so we can tell if there are more messages
668         // before the one we're showing.
669         // TODO: in the query, a multipart message will contain a row for each part.
670         // We might need a smarter GROUP_BY. On the other hand, we might want to show each of the
671         // parts as separate messages on the wearable.
672         final int limit = MAX_MESSAGES_IN_WEARABLE_PAGE + 1;
673 
674         final List<CharSequence> messages = Lists.newArrayList();
675         boolean hasSeenMessagesBeforeNotification = false;
676         Cursor convMessageCursor = null;
677         try {
678             final DatabaseWrapper db = DataModel.get().getDatabase();
679 
680             final String[] queryArgs = { conversationId };
681             final String convPageSql = ConversationMessageData.getWearableQuerySql() + " LIMIT " +
682                     limit;
683             convMessageCursor = db.rawQuery(
684                     convPageSql,
685                     queryArgs);
686 
687             if (convMessageCursor == null || !convMessageCursor.moveToFirst()) {
688                 return null;
689             }
690             final ConversationMessageData convMessageData =
691                     new ConversationMessageData();
692 
693             final HashMap<String, Integer> firstNames = scanFirstNames(conversationId);
694             do {
695                 convMessageData.bind(convMessageCursor);
696 
697                 final String authorFullName = convMessageData.getSenderFullName();
698                 final String authorFirstName = convMessageData.getSenderFirstName();
699                 String text = convMessageData.getText();
700 
701                 final boolean isSmsPushNotification = convMessageData.getIsMmsNotification();
702 
703                 // if auto-download was off to show a message to tap to download the message. We
704                 // might need to get that working again.
705                 if (isSmsPushNotification && text != null) {
706                     text = convertHtmlAndStripUrls(text).toString();
707                 }
708                 // Skip messages without any content
709                 if (TextUtils.isEmpty(text) && !convMessageData.hasAttachments()) {
710                     continue;
711                 }
712                 // Track whether there are messages prior to the one(s) shown in the notification.
713                 if (convMessageData.getIsSeen()) {
714                     hasSeenMessagesBeforeNotification = true;
715                 }
716 
717                 final boolean usedMoreThanOnce = firstNameUsedMoreThanOnce(
718                         firstNames, authorFirstName);
719                 String displayName = usedMoreThanOnce ? authorFullName : authorFirstName;
720                 if (TextUtils.isEmpty(displayName)) {
721                     if (convMessageData.getIsIncoming()) {
722                         displayName = convMessageData.getSenderDisplayDestination();
723                         if (TextUtils.isEmpty(displayName)) {
724                             displayName = context.getString(R.string.unknown_sender);
725                         }
726                     } else {
727                         displayName = context.getString(R.string.unknown_self_participant);
728                     }
729                 }
730 
731                 Uri attachmentUri = null;
732                 String attachmentType = null;
733                 final List<MessagePartData> attachments = convMessageData.getAttachments();
734                 for (final MessagePartData messagePartData : attachments) {
735                     // Look for the first attachment that's not the text piece.
736                     if (!messagePartData.isText()) {
737                         attachmentUri = messagePartData.getContentUri();
738                         attachmentType = messagePartData.getContentType();
739                         break;
740                     }
741                 }
742 
743                 final CharSequence message = BugleNotifications.buildSpaceSeparatedMessage(
744                         displayName, text, attachmentUri, attachmentType);
745                 messages.add(message);
746 
747             } while (convMessageCursor.moveToNext());
748         } finally {
749             if (convMessageCursor != null) {
750                 convMessageCursor.close();
751             }
752         }
753 
754         // If there is no conversation history prior to what is already visible in the main
755         // notification, there's no need to include the conversation log, too.
756         final int maxMessagesInNotification = getMaxMessagesInConversationNotification();
757         if (!hasSeenMessagesBeforeNotification && messages.size() <= maxMessagesInNotification) {
758             return null;
759         }
760 
761         final SpannableStringBuilder bigText = new SpannableStringBuilder();
762         // There is at least 1 message prior to the first one that we're going to show.
763         // Indicate this by inserting an ellipsis at the beginning of the conversation log.
764         if (convMessageCursor.getCount() == limit) {
765             bigText.append(context.getString(R.string.ellipsis) + "\n\n");
766             if (messages.size() > MAX_MESSAGES_IN_WEARABLE_PAGE) {
767                 messages.remove(messages.size() - 1);
768             }
769         }
770         // Messages are sorted in descending timestamp order, so iterate backwards
771         // to get them back in ascending order for display purposes.
772         for (int i = messages.size() - 1; i >= 0; --i) {
773             bigText.append(messages.get(i));
774             if (i > 0) {
775                 bigText.append("\n\n");
776             }
777         }
778         ++participantCount;     // Add in myself
779 
780         if (participantCount > 2) {
781             final SpannableString statusText = new SpannableString(
782                     context.getResources().getQuantityString(R.plurals.wearable_participant_count,
783                             participantCount, participantCount));
784             statusText.setSpan(new ForegroundColorSpan(context.getResources().getColor(
785                     R.color.wearable_notification_participants_count)), 0, statusText.length(),
786                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
787             bigText.append("\n\n").append(statusText);
788         }
789 
790         final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context);
791         final NotificationCompat.Style notifStyle =
792                 new NotificationCompat.BigTextStyle(notifBuilder).bigText(bigText);
793         notifBuilder.setStyle(notifStyle);
794 
795         final WearableExtender wearableExtender = new WearableExtender();
796         wearableExtender.setStartScrollBottom(true);
797         notifBuilder.extend(wearableExtender);
798 
799         return notifBuilder.build();
800     }
801 
802     /**
803      * Notification for one or more messages in a single conversation, which is bundled together
804      * with notifications for other conversations on a wearable device.
805      */
806     public static class BundledMessageNotificationState extends MultiMessageNotificationState {
807         public int mGroupOrder;
BundledMessageNotificationState(final ConversationInfoList convList, final int groupOrder)808         public BundledMessageNotificationState(final ConversationInfoList convList,
809                 final int groupOrder) {
810             super(convList);
811             mGroupOrder = groupOrder;
812         }
813     }
814 
815     /**
816      * Performs a query on the database.
817      */
createConversationInfoList()818     private static ConversationInfoList createConversationInfoList() {
819         // Map key is conversation id. We use LinkedHashMap to ensure that entries are iterated in
820         // the same order they were originally added. We scan unseen messages from newest to oldest,
821         // so the corresponding conversations are added in that order, too.
822         final Map<String, ConversationLineInfo> convLineInfos = new LinkedHashMap<>();
823         int messageCount = 0;
824 
825         Cursor convMessageCursor = null;
826         try {
827             final Context context = Factory.get().getApplicationContext();
828             final DatabaseWrapper db = DataModel.get().getDatabase();
829 
830             convMessageCursor = db.rawQuery(
831                     ConversationMessageData.getNotificationQuerySql(),
832                     null);
833 
834             if (convMessageCursor != null && convMessageCursor.moveToFirst()) {
835                 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
836                     LogUtil.v(TAG, "MessageNotificationState: Found unseen message notifications.");
837                 }
838                 final ConversationMessageData convMessageData =
839                         new ConversationMessageData();
840 
841                 HashMap<String, Integer> firstNames = null;
842                 String conversationIdForFirstNames = null;
843                 String groupConversationName = null;
844                 final int maxMessages = getMaxMessagesInConversationNotification();
845 
846                 do {
847                     convMessageData.bind(convMessageCursor);
848 
849                     // First figure out if this is a valid message.
850                     String authorFullName = convMessageData.getSenderFullName();
851                     String authorFirstName = convMessageData.getSenderFirstName();
852                     final String messageText = convMessageData.getText();
853 
854                     final String convId = convMessageData.getConversationId();
855                     final String messageId = convMessageData.getMessageId();
856 
857                     CharSequence text = messageText;
858                     final boolean isManualDownloadNeeded = convMessageData.getIsMmsNotification();
859                     if (isManualDownloadNeeded) {
860                         // Don't try and convert the text from html if it's sms and not a sms push
861                         // notification.
862                         Assert.equals(MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD,
863                                 convMessageData.getStatus());
864                         text = context.getResources().getString(
865                                 R.string.message_title_manual_download);
866                     }
867                     ConversationLineInfo currConvInfo = convLineInfos.get(convId);
868                     if (currConvInfo == null) {
869                         final ConversationListItemData convData =
870                                 ConversationListItemData.getExistingConversation(db, convId);
871                         if (!convData.getNotificationEnabled()) {
872                             // Skip conversations that have notifications disabled.
873                             continue;
874                         }
875                         final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db,
876                                 convData.getSelfId());
877                         groupConversationName = convData.getName();
878                         final Uri avatarUri = AvatarUriUtil.createAvatarUri(
879                                 convMessageData.getSenderProfilePhotoUri(),
880                                 convMessageData.getSenderFullName(),
881                                 convMessageData.getSenderNormalizedDestination(),
882                                 convMessageData.getSenderContactLookupKey());
883                         currConvInfo = new ConversationLineInfo(convId,
884                                 convData.getIsGroup(),
885                                 groupConversationName,
886                                 convData.getIncludeEmailAddress(),
887                                 convMessageData.getReceivedTimeStamp(),
888                                 convData.getSelfId(),
889                                 convData.getNotificationSoundUri(),
890                                 convData.getNotificationEnabled(),
891                                 convData.getNotifiationVibrate(),
892                                 avatarUri,
893                                 convMessageData.getSenderContactLookupUri(),
894                                 subId,
895                                 convData.getParticipantCount());
896                         convLineInfos.put(convId, currConvInfo);
897                     }
898                     // Prepare the message line
899                     if (currConvInfo.mTotalMessageCount < maxMessages) {
900                         if (currConvInfo.mIsGroup) {
901                             if (authorFirstName == null) {
902                                 // authorFullName might be null as well. In that case, we won't
903                                 // show an author. That is better than showing all the group
904                                 // names again on the 2nd line.
905                                 authorFirstName = authorFullName;
906                             }
907                         } else {
908                             // don't recompute this if we don't need to
909                             if (!TextUtils.equals(conversationIdForFirstNames, convId)) {
910                                 firstNames = scanFirstNames(convId);
911                                 conversationIdForFirstNames = convId;
912                             }
913                             if (firstNames != null) {
914                                 final Integer count = firstNames.get(authorFirstName);
915                                 if (count != null && count > 1) {
916                                     authorFirstName = authorFullName;
917                                 }
918                             }
919 
920                             if (authorFullName == null) {
921                                 authorFullName = groupConversationName;
922                             }
923                             if (authorFirstName == null) {
924                                 authorFirstName = groupConversationName;
925                             }
926                         }
927                         final String subjectText = MmsUtils.cleanseMmsSubject(
928                                 context.getResources(),
929                                 convMessageData.getMmsSubject());
930                         if (!TextUtils.isEmpty(subjectText)) {
931                             final String subjectLabel =
932                                     context.getString(R.string.subject_label);
933                             final SpannableStringBuilder spanBuilder =
934                                     new SpannableStringBuilder();
935 
936                             spanBuilder.append(context.getString(R.string.notification_subject,
937                                     subjectLabel, subjectText));
938                             spanBuilder.setSpan(new TextAppearanceSpan(
939                                     context, R.style.NotificationSubjectText), 0,
940                                     subjectLabel.length(),
941                                     Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
942                             if (!TextUtils.isEmpty(text)) {
943                                 // Now add the actual message text below the subject header.
944                                 spanBuilder.append(System.getProperty("line.separator") + text);
945                             }
946                             text = spanBuilder;
947                         }
948                         // If we've got attachments, find the best one. If one of the messages is
949                         // a photo, save the url so we'll display a big picture notification.
950                         // Otherwise, show the first one we find.
951                         Uri attachmentUri = null;
952                         String attachmentType = null;
953                         final MessagePartData messagePartData =
954                                 getMostInterestingAttachment(convMessageData);
955                         if (messagePartData != null) {
956                             attachmentUri = messagePartData.getContentUri();
957                             attachmentType = messagePartData.getContentType();
958                         }
959                         currConvInfo.mLineInfos.add(new MessageLineInfo(currConvInfo.mIsGroup,
960                                 authorFullName, authorFirstName, text,
961                                 attachmentUri, attachmentType, isManualDownloadNeeded, messageId));
962                     }
963                     messageCount++;
964                     currConvInfo.mTotalMessageCount++;
965                 } while (convMessageCursor.moveToNext());
966             }
967         } finally {
968             if (convMessageCursor != null) {
969                 convMessageCursor.close();
970             }
971         }
972         if (convLineInfos.isEmpty()) {
973             return null;
974         } else {
975             return new ConversationInfoList(messageCount,
976                     Lists.newLinkedList(convLineInfos.values()));
977         }
978     }
979 
980     /**
981      * Scans all the attachments for a message and returns the most interesting one that we'll
982      * show in a notification. By order of importance, in case there are multiple attachments:
983      *      1- an image (because we can show the image as a BigPictureNotification)
984      *      2- a video (because we can show a video frame as a BigPictureNotification)
985      *      3- a vcard
986      *      4- an audio attachment
987      * @return MessagePartData for the most interesting part. Can be null.
988      */
getMostInterestingAttachment( final ConversationMessageData convMessageData)989     private static MessagePartData getMostInterestingAttachment(
990             final ConversationMessageData convMessageData) {
991         final List<MessagePartData> attachments = convMessageData.getAttachments();
992 
993         MessagePartData imagePart = null;
994         MessagePartData audioPart = null;
995         MessagePartData vcardPart = null;
996         MessagePartData videoPart = null;
997 
998         // 99.99% of the time there will be 0 or 1 part, since receiving slideshows is so
999         // uncommon.
1000 
1001         // Remember the first of each type of part.
1002         for (final MessagePartData messagePartData : attachments) {
1003             if (messagePartData.isImage() && imagePart == null) {
1004                 imagePart = messagePartData;
1005             }
1006             if (messagePartData.isVideo() && videoPart == null) {
1007                 videoPart = messagePartData;
1008             }
1009             if (messagePartData.isVCard() && vcardPart == null) {
1010                 vcardPart = messagePartData;
1011             }
1012             if (messagePartData.isAudio() && audioPart == null) {
1013                 audioPart = messagePartData;
1014             }
1015         }
1016         if (imagePart != null) {
1017             return imagePart;
1018         } else if (videoPart != null) {
1019             return videoPart;
1020         } else if (audioPart != null) {
1021             return audioPart;
1022         } else if (vcardPart != null) {
1023             return vcardPart;
1024         }
1025         return null;
1026     }
1027 
getMaxMessagesInConversationNotification()1028     private static int getMaxMessagesInConversationNotification() {
1029         if (!BugleNotifications.isWearCompanionAppInstalled()) {
1030             return BugleGservices.get().getInt(
1031                     BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION,
1032                     BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT);
1033         }
1034         return BugleGservices.get().getInt(
1035                 BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE,
1036                 BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT);
1037     }
1038 
1039     /**
1040      * Scans the database for messages that need to go into notifications. Creates the appropriate
1041      * MessageNotificationState depending on if there are multiple senders, or
1042      * messages from one sender.
1043      * @return NotificationState for the notification created.
1044      */
getNotificationState()1045     public static NotificationState getNotificationState() {
1046         MessageNotificationState state = null;
1047         final ConversationInfoList convList = createConversationInfoList();
1048 
1049         if (convList == null || convList.mConvInfos.size() == 0) {
1050             if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
1051                 LogUtil.v(TAG, "MessageNotificationState: No unseen notifications");
1052             }
1053         } else {
1054             final ConversationLineInfo convInfo = convList.mConvInfos.get(0);
1055             state = new MultiMessageNotificationState(convList);
1056 
1057             if (convList.mConvInfos.size() > 1) {
1058                 // We've got notifications across multiple conversations. Pass in the notification
1059                 // we just built of the most recent notification so we can use that to show the
1060                 // user the new message in the ticker.
1061                 state = new MultiConversationNotificationState(convList, state);
1062             } else {
1063                 // For now, only show avatars for notifications for a single conversation.
1064                 if (convInfo.mAvatarUri != null) {
1065                     if (state.mParticipantAvatarsUris == null) {
1066                         state.mParticipantAvatarsUris = new ArrayList<Uri>(1);
1067                     }
1068                     state.mParticipantAvatarsUris.add(convInfo.mAvatarUri);
1069                 }
1070                 if (convInfo.mContactUri != null) {
1071                     if (state.mParticipantContactUris == null) {
1072                         state.mParticipantContactUris = new ArrayList<Uri>(1);
1073                     }
1074                     state.mParticipantContactUris.add(convInfo.mContactUri);
1075                 }
1076             }
1077         }
1078         if (state != null && LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
1079             LogUtil.v(TAG, "MessageNotificationState: Notification state created"
1080                     + ", title = " + LogUtil.sanitizePII(state.mTitle)
1081                     + ", content = " + LogUtil.sanitizePII(state.mContent.toString()));
1082         }
1083         return state;
1084     }
1085 
getTitle()1086     protected String getTitle() {
1087         return mTitle;
1088     }
1089 
1090     @Override
getLatestMessageNotificationType()1091     public int getLatestMessageNotificationType() {
1092         // This function is called to determine whether the most recent notification applies
1093         // to an sms conversation or a hangout conversation. We have different ringtone/vibrate
1094         // settings for both types of conversations.
1095         if (mConvList.mConvInfos.size() > 0) {
1096             final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0);
1097             return convInfo.getLatestMessageNotificationType();
1098         }
1099         return BugleNotifications.LOCAL_SMS_NOTIFICATION;
1100     }
1101 
1102     @Override
getRingtoneUri()1103     public String getRingtoneUri() {
1104         if (mConvList.mConvInfos.size() > 0) {
1105             return mConvList.mConvInfos.get(0).mRingtoneUri;
1106         }
1107         return null;
1108     }
1109 
1110     @Override
getNotificationVibrate()1111     public boolean getNotificationVibrate() {
1112         if (mConvList.mConvInfos.size() > 0) {
1113             return mConvList.mConvInfos.get(0).mNotificationVibrate;
1114         }
1115         return false;
1116     }
1117 
getTicker()1118     protected CharSequence getTicker() {
1119         return BugleNotifications.buildColonSeparatedMessage(
1120                 mTickerSender != null ? mTickerSender : mTitle,
1121                 mTickerText != null ? mTickerText : (mTickerNoContent ? null : mContent), null,
1122                         null);
1123     }
1124 
convertHtmlAndStripUrls(final String s)1125     private static CharSequence convertHtmlAndStripUrls(final String s) {
1126         final Spanned text = Html.fromHtml(s);
1127         if (text instanceof Spannable) {
1128             stripUrls((Spannable) text);
1129         }
1130         return text;
1131     }
1132 
1133     // Since we don't want to show URLs in notifications, a function
1134     // to remove them in place.
stripUrls(final Spannable text)1135     private static void stripUrls(final Spannable text) {
1136         final URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class);
1137         for (final URLSpan span : spans) {
1138             text.removeSpan(span);
1139         }
1140     }
1141 
1142     /*
1143     private static void updateAlertStatusMessages(final long thresholdDeltaMs) {
1144         // TODO may need this when supporting error notifications
1145         final EsDatabaseHelper helper = EsDatabaseHelper.getDatabaseHelper();
1146         final ContentValues values = new ContentValues();
1147         final long nowMicros = System.currentTimeMillis() * 1000;
1148         values.put(MessageColumns.ALERT_STATUS, "1");
1149         final String selection =
1150                 MessageColumns.ALERT_STATUS + "=0 AND (" +
1151                 MessageColumns.STATUS + "=" + EsProvider.MESSAGE_STATUS_FAILED_TO_SEND + " OR (" +
1152                 MessageColumns.STATUS + "!=" + EsProvider.MESSAGE_STATUS_ON_SERVER + " AND " +
1153                 MessageColumns.TIMESTAMP + "+" + thresholdDeltaMs*1000 + "<" + nowMicros + ")) ";
1154 
1155         final int updateCount = helper.getWritableDatabaseWrapper().update(
1156                 EsProvider.MESSAGES_TABLE,
1157                 values,
1158                 selection,
1159                 null);
1160         if (updateCount > 0) {
1161             EsConversationsData.notifyConversationsChanged();
1162         }
1163     }*/
1164 
applyWarningTextColor(final Context context, final CharSequence text)1165     static CharSequence applyWarningTextColor(final Context context,
1166             final CharSequence text) {
1167         if (text == null) {
1168             return null;
1169         }
1170         final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
1171         spanBuilder.append(text);
1172         spanBuilder.setSpan(new ForegroundColorSpan(context.getResources().getColor(
1173                 R.color.notification_warning_color)), 0, text.length(),
1174                 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1175         return spanBuilder;
1176     }
1177 
1178     /**
1179      * Check for failed messages and post notifications as needed.
1180      * TODO: Rewrite this as a NotificationState.
1181      */
checkFailedMessages()1182     public static void checkFailedMessages() {
1183         final DatabaseWrapper db = DataModel.get().getDatabase();
1184 
1185         final Cursor messageDataCursor = db.query(DatabaseHelper.MESSAGES_TABLE,
1186             MessageData.getProjection(),
1187             FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE,
1188             null /*selectionArgs*/,
1189             null /*groupBy*/,
1190             null /*having*/,
1191             FailedMessageQuery.FAILED_ORDER_BY);
1192 
1193         try {
1194             final Context context = Factory.get().getApplicationContext();
1195             final Resources resources = context.getResources();
1196             final NotificationManagerCompat notificationManager =
1197                     NotificationManagerCompat.from(context);
1198             if (messageDataCursor != null) {
1199                 final MessageData messageData = new MessageData();
1200 
1201                 final HashSet<String> conversationsWithFailedMessages = new HashSet<String>();
1202 
1203                 // track row ids in case we want to display something that requires this
1204                 // information
1205                 final ArrayList<Integer> failedMessages = new ArrayList<Integer>();
1206 
1207                 int cursorPosition = -1;
1208                 final long when = 0;
1209 
1210                 messageDataCursor.moveToPosition(-1);
1211                 while (messageDataCursor.moveToNext()) {
1212                     messageData.bind(messageDataCursor);
1213 
1214                     final String conversationId = messageData.getConversationId();
1215                     if (DataModel.get().isNewMessageObservable(conversationId)) {
1216                         // Don't post a system notification for an observable conversation
1217                         // because we already show an angry red annotation in the conversation
1218                         // itself or in the conversation preview snippet.
1219                         continue;
1220                     }
1221 
1222                     cursorPosition = messageDataCursor.getPosition();
1223                     failedMessages.add(cursorPosition);
1224                     conversationsWithFailedMessages.add(conversationId);
1225                 }
1226 
1227                 if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
1228                     LogUtil.d(TAG, "Found " + failedMessages.size() + " failed messages");
1229                 }
1230                 if (failedMessages.size() > 0) {
1231                     final NotificationCompat.Builder builder =
1232                             new NotificationCompat.Builder(context);
1233 
1234                     CharSequence line1;
1235                     CharSequence line2;
1236                     final boolean isRichContent = false;
1237                     ConversationIdSet conversationIds = null;
1238                     PendingIntent destinationIntent;
1239                     if (failedMessages.size() == 1) {
1240                         messageDataCursor.moveToPosition(cursorPosition);
1241                         messageData.bind(messageDataCursor);
1242                         final String conversationId =  messageData.getConversationId();
1243 
1244                         // We have a single conversation, go directly to that conversation.
1245                         destinationIntent = UIIntents.get()
1246                                 .getPendingIntentForConversationActivity(context,
1247                                         conversationId,
1248                                         null /*draft*/);
1249 
1250                         conversationIds = ConversationIdSet.createSet(conversationId);
1251 
1252                         final String failedMessgeSnippet = messageData.getMessageText();
1253                         int failureStringId;
1254                         if (messageData.getStatus() ==
1255                                 MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) {
1256                             failureStringId =
1257                                     R.string.notification_download_failures_line1_singular;
1258                         } else {
1259                             failureStringId = R.string.notification_send_failures_line1_singular;
1260                         }
1261                         line1 = resources.getString(failureStringId);
1262                         line2 = failedMessgeSnippet;
1263                         // Set rich text for non-SMS messages or MMS push notification messages
1264                         // which we generate locally with rich text
1265                         // TODO- fix this
1266 //                        if (messageData.isMmsInd()) {
1267 //                            isRichContent = true;
1268 //                        }
1269                     } else {
1270                         // We have notifications for multiple conversation, go to the conversation
1271                         // list.
1272                         destinationIntent = UIIntents.get()
1273                             .getPendingIntentForConversationListActivity(context);
1274 
1275                         int line1StringId;
1276                         int line2PluralsId;
1277                         if (messageData.getStatus() ==
1278                                 MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) {
1279                             line1StringId =
1280                                     R.string.notification_download_failures_line1_plural;
1281                             line2PluralsId = R.plurals.notification_download_failures;
1282                         } else {
1283                             line1StringId = R.string.notification_send_failures_line1_plural;
1284                             line2PluralsId = R.plurals.notification_send_failures;
1285                         }
1286                         line1 = resources.getString(line1StringId);
1287                         line2 = resources.getQuantityString(
1288                                 line2PluralsId,
1289                                 conversationsWithFailedMessages.size(),
1290                                 failedMessages.size(),
1291                                 conversationsWithFailedMessages.size());
1292                     }
1293                     line1 = applyWarningTextColor(context, line1);
1294                     line2 = applyWarningTextColor(context, line2);
1295 
1296                     final PendingIntent pendingIntentForDelete =
1297                             UIIntents.get().getPendingIntentForClearingNotifications(
1298                                     context,
1299                                     BugleNotifications.UPDATE_ERRORS,
1300                                     conversationIds,
1301                                     0);
1302 
1303                     builder
1304                         .setContentTitle(line1)
1305                         .setTicker(line1)
1306                         .setWhen(when > 0 ? when : System.currentTimeMillis())
1307                         .setSmallIcon(R.drawable.ic_failed_light)
1308                         .setDeleteIntent(pendingIntentForDelete)
1309                         .setContentIntent(destinationIntent)
1310                         .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure));
1311                     if (isRichContent && !TextUtils.isEmpty(line2)) {
1312                         final NotificationCompat.InboxStyle inboxStyle =
1313                                 new NotificationCompat.InboxStyle(builder);
1314                         if (line2 != null) {
1315                             inboxStyle.addLine(Html.fromHtml(line2.toString()));
1316                         }
1317                         builder.setStyle(inboxStyle);
1318                     } else {
1319                         builder.setContentText(line2);
1320                     }
1321 
1322                     if (builder != null) {
1323                         notificationManager.notify(
1324                                 BugleNotifications.buildNotificationTag(
1325                                         PendingIntentConstants.MSG_SEND_ERROR, null),
1326                                 PendingIntentConstants.MSG_SEND_ERROR,
1327                                 builder.build());
1328                     }
1329                 } else {
1330                     notificationManager.cancel(
1331                             BugleNotifications.buildNotificationTag(
1332                                     PendingIntentConstants.MSG_SEND_ERROR, null),
1333                             PendingIntentConstants.MSG_SEND_ERROR);
1334                 }
1335             }
1336         } finally {
1337             if (messageDataCursor != null) {
1338                 messageDataCursor.close();
1339             }
1340         }
1341     }
1342 }
1343