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