/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.messaging.datamodel; import android.app.Notification; import android.app.PendingIntent; import android.content.Context; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Typeface; import android.net.Uri; import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat.Builder; import android.support.v4.app.NotificationCompat.WearableExtender; import android.support.v4.app.NotificationManagerCompat; import android.text.Html; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.text.style.TextAppearanceSpan; import android.text.style.URLSpan; import com.android.messaging.Factory; import com.android.messaging.R; import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.datamodel.data.ConversationMessageData; import com.android.messaging.datamodel.data.ConversationParticipantsData; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.MessagePartData; import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.media.VideoThumbnailRequest; import com.android.messaging.sms.MmsUtils; import com.android.messaging.ui.UIIntents; import com.android.messaging.util.Assert; import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.BugleGservices; import com.android.messaging.util.BugleGservicesKeys; import com.android.messaging.util.ContentType; import com.android.messaging.util.ConversationIdSet; import com.android.messaging.util.LogUtil; import com.android.messaging.util.PendingIntentConstants; import com.android.messaging.util.UriUtil; import com.google.common.collect.Lists; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Notification building class for conversation messages. * * Message Notifications are built in several stages with several utility classes. * 1) Perform a database query and fill a data structure with information on messages and * conversations which need to be notified. * 2) Based on the data structure choose an appropriate NotificationState subclass to * represent all the notifications. * -- For one or more messages in one conversation: MultiMessageNotificationState. * -- For multiple messages in multiple conversations: MultiConversationNotificationState * * A three level structure is used to coalesce the data from the database. From bottom to top: * 1) NotificationLineInfo - A single message that needs to be notified. * 2) ConversationLineInfo - A list of NotificationLineInfo in a single conversation. * 3) ConversationInfoList - A list of ConversationLineInfo and the total number of messages. * * The createConversationInfoList function performs the query and creates the data structure. */ public abstract class MessageNotificationState extends NotificationState { // Logging static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG; private static final int MAX_MESSAGES_IN_WEARABLE_PAGE = 20; private static final int MAX_CHARACTERS_IN_GROUP_NAME = 30; private static final int REPLY_INTENT_REQUEST_CODE_OFFSET = 0; private static final int NUM_EXTRA_REQUEST_CODES_NEEDED = 1; protected String mTickerSender = null; protected CharSequence mTickerText = null; protected String mTitle = null; protected CharSequence mContent = null; protected Uri mAttachmentUri = null; protected String mAttachmentType = null; protected boolean mTickerNoContent; @Override protected Uri getAttachmentUri() { return mAttachmentUri; } @Override protected String getAttachmentType() { return mAttachmentType; } @Override public int getIcon() { return R.drawable.ic_sms_light; } @Override public int getPriority() { // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker // isn't displayed. return Notification.PRIORITY_HIGH; } /** * Base class for single notification events for messages. Multiple of these * may be grouped into a single conversation. */ static class NotificationLineInfo { final int mNotificationType; NotificationLineInfo() { mNotificationType = BugleNotifications.LOCAL_SMS_NOTIFICATION; } NotificationLineInfo(final int notificationType) { mNotificationType = notificationType; } } /** * Information on a single chat message which should be shown in a notification. */ static class MessageLineInfo extends NotificationLineInfo { final CharSequence mText; Uri mAttachmentUri; String mAttachmentType; final String mAuthorFullName; final String mAuthorFirstName; boolean mIsManualDownloadNeeded; final String mMessageId; 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) { super(BugleNotifications.LOCAL_SMS_NOTIFICATION); mAuthorFullName = authorFullName; mAuthorFirstName = authorFirstName; mText = text; mAttachmentUri = attachmentUrl; mAttachmentType = attachmentType; mIsManualDownloadNeeded = isManualDownloadNeeded; mMessageId = messageId; } } /** * Information on all the notification messages within a single conversation. */ static class ConversationLineInfo { // Conversation id of the latest message in the notification for this merged conversation. final String mConversationId; // True if this represents a group conversation. final boolean mIsGroup; // Name of the group conversation if available. final String mGroupConversationName; // True if this conversation's recipients includes one or more email address(es) // (see ConversationColumns.INCLUDE_EMAIL_ADDRESS) final boolean mIncludeEmailAddress; // Timestamp of the latest message final long mReceivedTimestamp; // Self participant id. final String mSelfParticipantId; // List of individual line notifications to be parsed later. final List mLineInfos; // Total number of messages. Might be different that mLineInfos.size() as the number of // line infos is capped. int mTotalMessageCount; // Custom ringtone if set final String mRingtoneUri; // Should notification be enabled for this conversation? final boolean mNotificationEnabled; // Should notifications vibrate for this conversation? final boolean mNotificationVibrate; // Avatar uri of sender final Uri mAvatarUri; // Contact uri of sender final Uri mContactUri; // Subscription id. final int mSubId; // Number of participants final int mParticipantCount; public 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) { mConversationId = conversationId; mIsGroup = isGroup; mGroupConversationName = groupConversationName; mIncludeEmailAddress = includeEmailAddress; mReceivedTimestamp = receivedTimestamp; mSelfParticipantId = selfParticipantId; mLineInfos = new ArrayList(); mTotalMessageCount = 0; mRingtoneUri = ringtoneUri; mAvatarUri = avatarUri; mContactUri = contactUri; mNotificationEnabled = notificationEnabled; mNotificationVibrate = notificationVibrate; mSubId = subId; mParticipantCount = participantCount; } public int getLatestMessageNotificationType() { final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); if (messageLineInfo == null) { return BugleNotifications.LOCAL_SMS_NOTIFICATION; } return messageLineInfo.mNotificationType; } public String getLatestMessageId() { final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); if (messageLineInfo == null) { return null; } return messageLineInfo.mMessageId; } public boolean getDoesLatestMessageNeedDownload() { final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); if (messageLineInfo == null) { return false; } return messageLineInfo.mIsManualDownloadNeeded; } private MessageLineInfo getLatestMessageLineInfo() { // The latest message is stored at index zero of the message line infos. if (mLineInfos.size() > 0 && mLineInfos.get(0) instanceof MessageLineInfo) { return (MessageLineInfo) mLineInfos.get(0); } return null; } } /** * Information on all the notification messages across all conversations. */ public static class ConversationInfoList { final int mMessageCount; final List mConvInfos; public ConversationInfoList(final int count, final List infos) { mMessageCount = count; mConvInfos = infos; } } final ConversationInfoList mConvList; private long mLatestReceivedTimestamp; private static ConversationIdSet makeConversationIdSet(final ConversationInfoList convList) { ConversationIdSet set = null; if (convList != null && convList.mConvInfos != null && convList.mConvInfos.size() > 0) { set = new ConversationIdSet(); for (final ConversationLineInfo info : convList.mConvInfos) { set.add(info.mConversationId); } } return set; } protected MessageNotificationState(final ConversationInfoList convList) { super(makeConversationIdSet(convList)); mConvList = convList; mType = PendingIntentConstants.SMS_NOTIFICATION_ID; mLatestReceivedTimestamp = Long.MIN_VALUE; if (convList != null) { for (final ConversationLineInfo info : convList.mConvInfos) { mLatestReceivedTimestamp = Math.max(mLatestReceivedTimestamp, info.mReceivedTimestamp); } } } @Override public long getLatestReceivedTimestamp() { return mLatestReceivedTimestamp; } @Override public int getNumRequestCodesNeeded() { // Get additional request codes for the Reply PendingIntent (wearables only) // and the DND PendingIntent. return super.getNumRequestCodesNeeded() + NUM_EXTRA_REQUEST_CODES_NEEDED; } private int getBaseExtraRequestCode() { return mBaseRequestCode + super.getNumRequestCodesNeeded(); } public int getReplyIntentRequestCode() { return getBaseExtraRequestCode() + REPLY_INTENT_REQUEST_CODE_OFFSET; } @Override public PendingIntent getClearIntent() { return UIIntents.get().getPendingIntentForClearingNotifications( Factory.get().getApplicationContext(), BugleNotifications.UPDATE_MESSAGES, mConversationIds, getClearIntentRequestCode()); } /** * Notification for multiple messages in at least 2 different conversations. */ public static class MultiConversationNotificationState extends MessageNotificationState { public final List mChildren = new ArrayList(); public MultiConversationNotificationState( final ConversationInfoList convList, final MessageNotificationState state) { super(convList); mAttachmentUri = null; mAttachmentType = null; // Pull the ticker title/text from the single notification mTickerSender = state.getTitle(); mTitle = Factory.get().getApplicationContext().getResources().getQuantityString( R.plurals.notification_new_messages, convList.mMessageCount, convList.mMessageCount); mTickerText = state.mContent; // Create child notifications for each conversation, // which will be displayed (only) on a wearable device. for (int i = 0; i < convList.mConvInfos.size(); i++) { final ConversationLineInfo convInfo = convList.mConvInfos.get(i); if (!(convInfo.mLineInfos.get(0) instanceof MessageLineInfo)) { continue; } setPeopleForConversation(convInfo.mConversationId); final ConversationInfoList list = new ConversationInfoList( convInfo.mTotalMessageCount, Lists.newArrayList(convInfo)); mChildren.add(new BundledMessageNotificationState(list, i)); } } @Override public int getIcon() { return R.drawable.ic_sms_multi_light; } @Override protected NotificationCompat.Style build(final Builder builder) { builder.setContentTitle(mTitle); NotificationCompat.InboxStyle inboxStyle = null; inboxStyle = new NotificationCompat.InboxStyle(builder); final Context context = Factory.get().getApplicationContext(); // enumeration_comma is defined as ", " final String separator = context.getString(R.string.enumeration_comma); final StringBuilder senders = new StringBuilder(); long when = 0; for (int i = 0; i < mConvList.mConvInfos.size(); i++) { final ConversationLineInfo convInfo = mConvList.mConvInfos.get(i); if (convInfo.mReceivedTimestamp > when) { when = convInfo.mReceivedTimestamp; } String sender; CharSequence text; final NotificationLineInfo lineInfo = convInfo.mLineInfos.get(0); final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfo; if (convInfo.mIsGroup) { sender = (convInfo.mGroupConversationName.length() > MAX_CHARACTERS_IN_GROUP_NAME) ? truncateGroupMessageName(convInfo.mGroupConversationName) : convInfo.mGroupConversationName; } else { sender = messageLineInfo.mAuthorFullName; } text = messageLineInfo.mText; mAttachmentUri = messageLineInfo.mAttachmentUri; mAttachmentType = messageLineInfo.mAttachmentType; inboxStyle.addLine(BugleNotifications.formatInboxMessage( sender, text, mAttachmentUri, mAttachmentType)); if (sender != null) { if (senders.length() > 0) { senders.append(separator); } senders.append(sender); } } // for collapsed state mContent = senders; builder.setContentText(senders) .setTicker(getTicker()) .setWhen(when); return inboxStyle; } } /** * Truncate group conversation name to be displayed in the notifications. This either truncates * the entire group name or finds the last comma in the available length and truncates the name * at that point */ private static String truncateGroupMessageName(final String conversationName) { int endIndex = MAX_CHARACTERS_IN_GROUP_NAME; for (int i = MAX_CHARACTERS_IN_GROUP_NAME; i >= 0; i--) { // The dividing marker should stay consistent with ConversationListItemData.DIVIDER_TEXT if (conversationName.charAt(i) == ',') { endIndex = i; break; } } return conversationName.substring(0, endIndex) + '\u2026'; } /** * Notification for multiple messages in a single conversation. Also used if there is a single * message in a single conversation. */ public static class MultiMessageNotificationState extends MessageNotificationState { public MultiMessageNotificationState(final ConversationInfoList convList) { super(convList); // This conversation has been accepted. final ConversationLineInfo convInfo = convList.mConvInfos.get(0); setAvatarUrlsForConversation(convInfo.mConversationId); setPeopleForConversation(convInfo.mConversationId); final Context context = Factory.get().getApplicationContext(); MessageLineInfo messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0); // attached photo mAttachmentUri = messageInfo.mAttachmentUri; mAttachmentType = messageInfo.mAttachmentType; mContent = messageInfo.mText; if (mAttachmentUri != null) { // The default attachment type is an image, since that's what was originally // supported. When there's no content type, assume it's an image. int message = R.string.notification_picture; if (ContentType.isAudioType(mAttachmentType)) { message = R.string.notification_audio; } else if (ContentType.isVideoType(mAttachmentType)) { message = R.string.notification_video; } else if (ContentType.isVCardType(mAttachmentType)) { message = R.string.notification_vcard; } final String attachment = context.getString(message); final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); if (!TextUtils.isEmpty(mContent)) { spanBuilder.append(mContent).append(System.getProperty("line.separator")); } final int start = spanBuilder.length(); spanBuilder.append(attachment); spanBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, spanBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); mContent = spanBuilder; } if (convInfo.mIsGroup) { // When the message is part of a group, the sender's first name // is prepended to the message, but not for the ticker message. mTickerText = mContent; mTickerSender = messageInfo.mAuthorFullName; // append the bold name to the front of the message mContent = BugleNotifications.buildSpaceSeparatedMessage( messageInfo.mAuthorFullName, mContent, mAttachmentUri, mAttachmentType); mTitle = convInfo.mGroupConversationName; } else { // No matter how many messages there are, since this is a 1:1, just // get the author full name from the first one. messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0); mTitle = messageInfo.mAuthorFullName; } } @Override protected NotificationCompat.Style build(final Builder builder) { builder.setContentTitle(mTitle) .setTicker(getTicker()); NotificationCompat.Style notifStyle = null; final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0); final List lineInfos = convInfo.mLineInfos; final int messageCount = lineInfos.size(); // At this point, all the messages come from the same conversation. We need to load // the sender's avatar and then finish building the notification on a callback. builder.setContentText(mContent); // for collapsed state if (messageCount == 1) { final boolean shouldShowImage = ContentType.isImageType(mAttachmentType) || (ContentType.isVideoType(mAttachmentType) && VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()); if (mAttachmentUri != null && shouldShowImage) { // Show "Picture" as the content final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0); String authorFirstName = messageLineInfo.mAuthorFirstName; // For the collapsed state, just show "picture" unless this is a // group conversation. If it's a group, show the sender name and // "picture". final CharSequence tickerTag = BugleNotifications.formatAttachmentTag(authorFirstName, mAttachmentType); // For 1:1 notifications don't show first name in the notification, but // do show it in the ticker text CharSequence pictureTag = tickerTag; if (!convInfo.mIsGroup) { authorFirstName = null; pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName, mAttachmentType); } builder.setContentText(pictureTag); builder.setTicker(tickerTag); notifStyle = new NotificationCompat.BigPictureStyle(builder) .setSummaryText(BugleNotifications.formatInboxMessage( authorFirstName, null, null, null)); // expanded state, just show sender } else { notifStyle = new NotificationCompat.BigTextStyle(builder) .bigText(mContent); } } else { // We've got multiple messages for the same sender. // Starting with the oldest new message, display the full text of each message. // Begin a line for each subsequent message. final SpannableStringBuilder buf = new SpannableStringBuilder(); for (int i = lineInfos.size() - 1; i >= 0; --i) { final NotificationLineInfo info = lineInfos.get(i); final MessageLineInfo messageLineInfo = (MessageLineInfo) info; mAttachmentUri = messageLineInfo.mAttachmentUri; mAttachmentType = messageLineInfo.mAttachmentType; CharSequence text = messageLineInfo.mText; if (!TextUtils.isEmpty(text) || mAttachmentUri != null) { if (convInfo.mIsGroup) { // append the bold name to the front of the message text = BugleNotifications.buildSpaceSeparatedMessage( messageLineInfo.mAuthorFullName, text, mAttachmentUri, mAttachmentType); } else { text = BugleNotifications.buildSpaceSeparatedMessage( null, text, mAttachmentUri, mAttachmentType); } buf.append(text); if (i > 0) { buf.append('\n'); } } } // Show a single notification -- big style with the text of all the messages notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf); } builder.setWhen(convInfo.mReceivedTimestamp); return notifStyle; } } private static boolean firstNameUsedMoreThanOnce( final HashMap map, final String firstName) { if (map == null) { return false; } if (firstName == null) { return false; } final Integer count = map.get(firstName); if (count != null) { return count > 1; } else { return false; } } private static HashMap scanFirstNames(final String conversationId) { final Context context = Factory.get().getApplicationContext(); final Uri uri = MessagingContentProvider.buildConversationParticipantsUri(conversationId); final Cursor participantsCursor = context.getContentResolver().query( uri, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); final ConversationParticipantsData participantsData = new ConversationParticipantsData(); participantsData.bind(participantsCursor); final Iterator iter = participantsData.iterator(); final HashMap firstNames = new HashMap(); boolean seenSelf = false; while (iter.hasNext()) { final ParticipantData participant = iter.next(); // Make sure we only add the self participant once if (participant.isSelf()) { if (seenSelf) { continue; } else { seenSelf = true; } } final String firstName = participant.getFirstName(); if (firstName == null) { continue; } final int currentCount = firstNames.containsKey(firstName) ? firstNames.get(firstName) : 0; firstNames.put(firstName, currentCount + 1); } return firstNames; } // Essentially, we're building a list of the past 20 messages for this conversation to display // on the wearable. public static Notification buildConversationPageForWearable(final String conversationId, int participantCount) { final Context context = Factory.get().getApplicationContext(); // Limit the number of messages to show. We just want enough to provide context for the // notification. Fetch one more than we need, so we can tell if there are more messages // before the one we're showing. // TODO: in the query, a multipart message will contain a row for each part. // We might need a smarter GROUP_BY. On the other hand, we might want to show each of the // parts as separate messages on the wearable. final int limit = MAX_MESSAGES_IN_WEARABLE_PAGE + 1; final List messages = Lists.newArrayList(); boolean hasSeenMessagesBeforeNotification = false; Cursor convMessageCursor = null; try { final DatabaseWrapper db = DataModel.get().getDatabase(); final String[] queryArgs = { conversationId }; final String convPageSql = ConversationMessageData.getWearableQuerySql() + " LIMIT " + limit; convMessageCursor = db.rawQuery( convPageSql, queryArgs); if (convMessageCursor == null || !convMessageCursor.moveToFirst()) { return null; } final ConversationMessageData convMessageData = new ConversationMessageData(); final HashMap firstNames = scanFirstNames(conversationId); do { convMessageData.bind(convMessageCursor); final String authorFullName = convMessageData.getSenderFullName(); final String authorFirstName = convMessageData.getSenderFirstName(); String text = convMessageData.getText(); final boolean isSmsPushNotification = convMessageData.getIsMmsNotification(); // if auto-download was off to show a message to tap to download the message. We // might need to get that working again. if (isSmsPushNotification && text != null) { text = convertHtmlAndStripUrls(text).toString(); } // Skip messages without any content if (TextUtils.isEmpty(text) && !convMessageData.hasAttachments()) { continue; } // Track whether there are messages prior to the one(s) shown in the notification. if (convMessageData.getIsSeen()) { hasSeenMessagesBeforeNotification = true; } final boolean usedMoreThanOnce = firstNameUsedMoreThanOnce( firstNames, authorFirstName); String displayName = usedMoreThanOnce ? authorFullName : authorFirstName; if (TextUtils.isEmpty(displayName)) { if (convMessageData.getIsIncoming()) { displayName = convMessageData.getSenderDisplayDestination(); if (TextUtils.isEmpty(displayName)) { displayName = context.getString(R.string.unknown_sender); } } else { displayName = context.getString(R.string.unknown_self_participant); } } Uri attachmentUri = null; String attachmentType = null; final List attachments = convMessageData.getAttachments(); for (final MessagePartData messagePartData : attachments) { // Look for the first attachment that's not the text piece. if (!messagePartData.isText()) { attachmentUri = messagePartData.getContentUri(); attachmentType = messagePartData.getContentType(); break; } } final CharSequence message = BugleNotifications.buildSpaceSeparatedMessage( displayName, text, attachmentUri, attachmentType); messages.add(message); } while (convMessageCursor.moveToNext()); } finally { if (convMessageCursor != null) { convMessageCursor.close(); } } // If there is no conversation history prior to what is already visible in the main // notification, there's no need to include the conversation log, too. final int maxMessagesInNotification = getMaxMessagesInConversationNotification(); if (!hasSeenMessagesBeforeNotification && messages.size() <= maxMessagesInNotification) { return null; } final SpannableStringBuilder bigText = new SpannableStringBuilder(); // There is at least 1 message prior to the first one that we're going to show. // Indicate this by inserting an ellipsis at the beginning of the conversation log. if (convMessageCursor.getCount() == limit) { bigText.append(context.getString(R.string.ellipsis) + "\n\n"); if (messages.size() > MAX_MESSAGES_IN_WEARABLE_PAGE) { messages.remove(messages.size() - 1); } } // Messages are sorted in descending timestamp order, so iterate backwards // to get them back in ascending order for display purposes. for (int i = messages.size() - 1; i >= 0; --i) { bigText.append(messages.get(i)); if (i > 0) { bigText.append("\n\n"); } } ++participantCount; // Add in myself if (participantCount > 2) { final SpannableString statusText = new SpannableString( context.getResources().getQuantityString(R.plurals.wearable_participant_count, participantCount, participantCount)); statusText.setSpan(new ForegroundColorSpan(context.getResources().getColor( R.color.wearable_notification_participants_count)), 0, statusText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); bigText.append("\n\n").append(statusText); } final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context); final NotificationCompat.Style notifStyle = new NotificationCompat.BigTextStyle(notifBuilder).bigText(bigText); notifBuilder.setStyle(notifStyle); final WearableExtender wearableExtender = new WearableExtender(); wearableExtender.setStartScrollBottom(true); notifBuilder.extend(wearableExtender); return notifBuilder.build(); } /** * Notification for one or more messages in a single conversation, which is bundled together * with notifications for other conversations on a wearable device. */ public static class BundledMessageNotificationState extends MultiMessageNotificationState { public int mGroupOrder; public BundledMessageNotificationState(final ConversationInfoList convList, final int groupOrder) { super(convList); mGroupOrder = groupOrder; } } /** * Performs a query on the database. */ private static ConversationInfoList createConversationInfoList() { // Map key is conversation id. We use LinkedHashMap to ensure that entries are iterated in // the same order they were originally added. We scan unseen messages from newest to oldest, // so the corresponding conversations are added in that order, too. final Map convLineInfos = new LinkedHashMap<>(); int messageCount = 0; Cursor convMessageCursor = null; try { final Context context = Factory.get().getApplicationContext(); final DatabaseWrapper db = DataModel.get().getDatabase(); convMessageCursor = db.rawQuery( ConversationMessageData.getNotificationQuerySql(), null); if (convMessageCursor != null && convMessageCursor.moveToFirst()) { if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "MessageNotificationState: Found unseen message notifications."); } final ConversationMessageData convMessageData = new ConversationMessageData(); HashMap firstNames = null; String conversationIdForFirstNames = null; String groupConversationName = null; final int maxMessages = getMaxMessagesInConversationNotification(); do { convMessageData.bind(convMessageCursor); // First figure out if this is a valid message. String authorFullName = convMessageData.getSenderFullName(); String authorFirstName = convMessageData.getSenderFirstName(); final String messageText = convMessageData.getText(); final String convId = convMessageData.getConversationId(); final String messageId = convMessageData.getMessageId(); CharSequence text = messageText; final boolean isManualDownloadNeeded = convMessageData.getIsMmsNotification(); if (isManualDownloadNeeded) { // Don't try and convert the text from html if it's sms and not a sms push // notification. Assert.equals(MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD, convMessageData.getStatus()); text = context.getResources().getString( R.string.message_title_manual_download); } ConversationLineInfo currConvInfo = convLineInfos.get(convId); if (currConvInfo == null) { final ConversationListItemData convData = ConversationListItemData.getExistingConversation(db, convId); if (!convData.getNotificationEnabled()) { // Skip conversations that have notifications disabled. continue; } final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db, convData.getSelfId()); groupConversationName = convData.getName(); final Uri avatarUri = AvatarUriUtil.createAvatarUri( convMessageData.getSenderProfilePhotoUri(), convMessageData.getSenderFullName(), convMessageData.getSenderNormalizedDestination(), convMessageData.getSenderContactLookupKey()); currConvInfo = new ConversationLineInfo(convId, convData.getIsGroup(), groupConversationName, convData.getIncludeEmailAddress(), convMessageData.getReceivedTimeStamp(), convData.getSelfId(), convData.getNotificationSoundUri(), convData.getNotificationEnabled(), convData.getNotifiationVibrate(), avatarUri, convMessageData.getSenderContactLookupUri(), subId, convData.getParticipantCount()); convLineInfos.put(convId, currConvInfo); } // Prepare the message line if (currConvInfo.mTotalMessageCount < maxMessages) { if (currConvInfo.mIsGroup) { if (authorFirstName == null) { // authorFullName might be null as well. In that case, we won't // show an author. That is better than showing all the group // names again on the 2nd line. authorFirstName = authorFullName; } } else { // don't recompute this if we don't need to if (!TextUtils.equals(conversationIdForFirstNames, convId)) { firstNames = scanFirstNames(convId); conversationIdForFirstNames = convId; } if (firstNames != null) { final Integer count = firstNames.get(authorFirstName); if (count != null && count > 1) { authorFirstName = authorFullName; } } if (authorFullName == null) { authorFullName = groupConversationName; } if (authorFirstName == null) { authorFirstName = groupConversationName; } } final String subjectText = MmsUtils.cleanseMmsSubject( context.getResources(), convMessageData.getMmsSubject()); if (!TextUtils.isEmpty(subjectText)) { final String subjectLabel = context.getString(R.string.subject_label); final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); spanBuilder.append(context.getString(R.string.notification_subject, subjectLabel, subjectText)); spanBuilder.setSpan(new TextAppearanceSpan( context, R.style.NotificationSubjectText), 0, subjectLabel.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); if (!TextUtils.isEmpty(text)) { // Now add the actual message text below the subject header. spanBuilder.append(System.getProperty("line.separator") + text); } text = spanBuilder; } // If we've got attachments, find the best one. If one of the messages is // a photo, save the url so we'll display a big picture notification. // Otherwise, show the first one we find. Uri attachmentUri = null; String attachmentType = null; final MessagePartData messagePartData = getMostInterestingAttachment(convMessageData); if (messagePartData != null) { attachmentUri = messagePartData.getContentUri(); attachmentType = messagePartData.getContentType(); } currConvInfo.mLineInfos.add(new MessageLineInfo(currConvInfo.mIsGroup, authorFullName, authorFirstName, text, attachmentUri, attachmentType, isManualDownloadNeeded, messageId)); } messageCount++; currConvInfo.mTotalMessageCount++; } while (convMessageCursor.moveToNext()); } } finally { if (convMessageCursor != null) { convMessageCursor.close(); } } if (convLineInfos.isEmpty()) { return null; } else { return new ConversationInfoList(messageCount, Lists.newLinkedList(convLineInfos.values())); } } /** * Scans all the attachments for a message and returns the most interesting one that we'll * show in a notification. By order of importance, in case there are multiple attachments: * 1- an image (because we can show the image as a BigPictureNotification) * 2- a video (because we can show a video frame as a BigPictureNotification) * 3- a vcard * 4- an audio attachment * @return MessagePartData for the most interesting part. Can be null. */ private static MessagePartData getMostInterestingAttachment( final ConversationMessageData convMessageData) { final List attachments = convMessageData.getAttachments(); MessagePartData imagePart = null; MessagePartData audioPart = null; MessagePartData vcardPart = null; MessagePartData videoPart = null; // 99.99% of the time there will be 0 or 1 part, since receiving slideshows is so // uncommon. // Remember the first of each type of part. for (final MessagePartData messagePartData : attachments) { if (messagePartData.isImage() && imagePart == null) { imagePart = messagePartData; } if (messagePartData.isVideo() && videoPart == null) { videoPart = messagePartData; } if (messagePartData.isVCard() && vcardPart == null) { vcardPart = messagePartData; } if (messagePartData.isAudio() && audioPart == null) { audioPart = messagePartData; } } if (imagePart != null) { return imagePart; } else if (videoPart != null) { return videoPart; } else if (audioPart != null) { return audioPart; } else if (vcardPart != null) { return vcardPart; } return null; } private static int getMaxMessagesInConversationNotification() { if (!BugleNotifications.isWearCompanionAppInstalled()) { return BugleGservices.get().getInt( BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION, BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT); } return BugleGservices.get().getInt( BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE, BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT); } /** * Scans the database for messages that need to go into notifications. Creates the appropriate * MessageNotificationState depending on if there are multiple senders, or * messages from one sender. * @return NotificationState for the notification created. */ public static NotificationState getNotificationState() { MessageNotificationState state = null; final ConversationInfoList convList = createConversationInfoList(); if (convList == null || convList.mConvInfos.size() == 0) { if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "MessageNotificationState: No unseen notifications"); } } else { final ConversationLineInfo convInfo = convList.mConvInfos.get(0); state = new MultiMessageNotificationState(convList); if (convList.mConvInfos.size() > 1) { // We've got notifications across multiple conversations. Pass in the notification // we just built of the most recent notification so we can use that to show the // user the new message in the ticker. state = new MultiConversationNotificationState(convList, state); } else { // For now, only show avatars for notifications for a single conversation. if (convInfo.mAvatarUri != null) { if (state.mParticipantAvatarsUris == null) { state.mParticipantAvatarsUris = new ArrayList(1); } state.mParticipantAvatarsUris.add(convInfo.mAvatarUri); } if (convInfo.mContactUri != null) { if (state.mParticipantContactUris == null) { state.mParticipantContactUris = new ArrayList(1); } state.mParticipantContactUris.add(convInfo.mContactUri); } } } if (state != null && LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { LogUtil.v(TAG, "MessageNotificationState: Notification state created" + ", title = " + LogUtil.sanitizePII(state.mTitle) + ", content = " + LogUtil.sanitizePII(state.mContent.toString())); } return state; } protected String getTitle() { return mTitle; } @Override public int getLatestMessageNotificationType() { // This function is called to determine whether the most recent notification applies // to an sms conversation or a hangout conversation. We have different ringtone/vibrate // settings for both types of conversations. if (mConvList.mConvInfos.size() > 0) { final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0); return convInfo.getLatestMessageNotificationType(); } return BugleNotifications.LOCAL_SMS_NOTIFICATION; } @Override public String getRingtoneUri() { if (mConvList.mConvInfos.size() > 0) { return mConvList.mConvInfos.get(0).mRingtoneUri; } return null; } @Override public boolean getNotificationVibrate() { if (mConvList.mConvInfos.size() > 0) { return mConvList.mConvInfos.get(0).mNotificationVibrate; } return false; } protected CharSequence getTicker() { return BugleNotifications.buildColonSeparatedMessage( mTickerSender != null ? mTickerSender : mTitle, mTickerText != null ? mTickerText : (mTickerNoContent ? null : mContent), null, null); } private static CharSequence convertHtmlAndStripUrls(final String s) { final Spanned text = Html.fromHtml(s); if (text instanceof Spannable) { stripUrls((Spannable) text); } return text; } // Since we don't want to show URLs in notifications, a function // to remove them in place. private static void stripUrls(final Spannable text) { final URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class); for (final URLSpan span : spans) { text.removeSpan(span); } } /* private static void updateAlertStatusMessages(final long thresholdDeltaMs) { // TODO may need this when supporting error notifications final EsDatabaseHelper helper = EsDatabaseHelper.getDatabaseHelper(); final ContentValues values = new ContentValues(); final long nowMicros = System.currentTimeMillis() * 1000; values.put(MessageColumns.ALERT_STATUS, "1"); final String selection = MessageColumns.ALERT_STATUS + "=0 AND (" + MessageColumns.STATUS + "=" + EsProvider.MESSAGE_STATUS_FAILED_TO_SEND + " OR (" + MessageColumns.STATUS + "!=" + EsProvider.MESSAGE_STATUS_ON_SERVER + " AND " + MessageColumns.TIMESTAMP + "+" + thresholdDeltaMs*1000 + "<" + nowMicros + ")) "; final int updateCount = helper.getWritableDatabaseWrapper().update( EsProvider.MESSAGES_TABLE, values, selection, null); if (updateCount > 0) { EsConversationsData.notifyConversationsChanged(); } }*/ static CharSequence applyWarningTextColor(final Context context, final CharSequence text) { if (text == null) { return null; } final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); spanBuilder.append(text); spanBuilder.setSpan(new ForegroundColorSpan(context.getResources().getColor( R.color.notification_warning_color)), 0, text.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); return spanBuilder; } /** * Check for failed messages and post notifications as needed. * TODO: Rewrite this as a NotificationState. */ public static void checkFailedMessages() { final DatabaseWrapper db = DataModel.get().getDatabase(); final Cursor messageDataCursor = db.query(DatabaseHelper.MESSAGES_TABLE, MessageData.getProjection(), FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE, null /*selectionArgs*/, null /*groupBy*/, null /*having*/, FailedMessageQuery.FAILED_ORDER_BY); try { final Context context = Factory.get().getApplicationContext(); final Resources resources = context.getResources(); final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); if (messageDataCursor != null) { final MessageData messageData = new MessageData(); final HashSet conversationsWithFailedMessages = new HashSet(); // track row ids in case we want to display something that requires this // information final ArrayList failedMessages = new ArrayList(); int cursorPosition = -1; final long when = 0; messageDataCursor.moveToPosition(-1); while (messageDataCursor.moveToNext()) { messageData.bind(messageDataCursor); final String conversationId = messageData.getConversationId(); if (DataModel.get().isNewMessageObservable(conversationId)) { // Don't post a system notification for an observable conversation // because we already show an angry red annotation in the conversation // itself or in the conversation preview snippet. continue; } cursorPosition = messageDataCursor.getPosition(); failedMessages.add(cursorPosition); conversationsWithFailedMessages.add(conversationId); } if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { LogUtil.d(TAG, "Found " + failedMessages.size() + " failed messages"); } if (failedMessages.size() > 0) { final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); CharSequence line1; CharSequence line2; final boolean isRichContent = false; ConversationIdSet conversationIds = null; PendingIntent destinationIntent; if (failedMessages.size() == 1) { messageDataCursor.moveToPosition(cursorPosition); messageData.bind(messageDataCursor); final String conversationId = messageData.getConversationId(); // We have a single conversation, go directly to that conversation. destinationIntent = UIIntents.get() .getPendingIntentForConversationActivity(context, conversationId, null /*draft*/); conversationIds = ConversationIdSet.createSet(conversationId); final String failedMessgeSnippet = messageData.getMessageText(); int failureStringId; if (messageData.getStatus() == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) { failureStringId = R.string.notification_download_failures_line1_singular; } else { failureStringId = R.string.notification_send_failures_line1_singular; } line1 = resources.getString(failureStringId); line2 = failedMessgeSnippet; // Set rich text for non-SMS messages or MMS push notification messages // which we generate locally with rich text // TODO- fix this // if (messageData.isMmsInd()) { // isRichContent = true; // } } else { // We have notifications for multiple conversation, go to the conversation // list. destinationIntent = UIIntents.get() .getPendingIntentForConversationListActivity(context); int line1StringId; int line2PluralsId; if (messageData.getStatus() == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) { line1StringId = R.string.notification_download_failures_line1_plural; line2PluralsId = R.plurals.notification_download_failures; } else { line1StringId = R.string.notification_send_failures_line1_plural; line2PluralsId = R.plurals.notification_send_failures; } line1 = resources.getString(line1StringId); line2 = resources.getQuantityString( line2PluralsId, conversationsWithFailedMessages.size(), failedMessages.size(), conversationsWithFailedMessages.size()); } line1 = applyWarningTextColor(context, line1); line2 = applyWarningTextColor(context, line2); final PendingIntent pendingIntentForDelete = UIIntents.get().getPendingIntentForClearingNotifications( context, BugleNotifications.UPDATE_ERRORS, conversationIds, 0); builder .setContentTitle(line1) .setTicker(line1) .setWhen(when > 0 ? when : System.currentTimeMillis()) .setSmallIcon(R.drawable.ic_failed_light) .setDeleteIntent(pendingIntentForDelete) .setContentIntent(destinationIntent) .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure)); if (isRichContent && !TextUtils.isEmpty(line2)) { final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(builder); if (line2 != null) { inboxStyle.addLine(Html.fromHtml(line2.toString())); } builder.setStyle(inboxStyle); } else { builder.setContentText(line2); } if (builder != null) { notificationManager.notify( BugleNotifications.buildNotificationTag( PendingIntentConstants.MSG_SEND_ERROR, null), PendingIntentConstants.MSG_SEND_ERROR, builder.build()); } } else { notificationManager.cancel( BugleNotifications.buildNotificationTag( PendingIntentConstants.MSG_SEND_ERROR, null), PendingIntentConstants.MSG_SEND_ERROR); } } } finally { if (messageDataCursor != null) { messageDataCursor.close(); } } } }