1 /* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.mail.browse; 19 20 import android.content.BroadcastReceiver; 21 import android.content.Context; 22 import android.content.Intent; 23 import android.content.IntentFilter; 24 import android.content.res.Resources; 25 import android.graphics.Typeface; 26 import android.support.v4.text.BidiFormatter; 27 import android.text.SpannableString; 28 import android.text.SpannableStringBuilder; 29 import android.text.Spanned; 30 import android.text.TextUtils; 31 import android.text.style.CharacterStyle; 32 import android.text.style.TextAppearanceSpan; 33 34 import com.android.mail.R; 35 import com.android.mail.providers.Account; 36 import com.android.mail.providers.Conversation; 37 import com.android.mail.providers.ConversationInfo; 38 import com.android.mail.providers.ParticipantInfo; 39 import com.android.mail.providers.UIProvider; 40 import com.android.mail.utils.ObjectCache; 41 import com.google.common.base.Objects; 42 import com.google.common.collect.Lists; 43 import com.google.common.collect.Maps; 44 45 import java.util.ArrayList; 46 import java.util.List; 47 import java.util.Map; 48 49 public class SendersView { 50 /** The maximum number of senders to display for a given conversation */ 51 private static final int MAX_SENDER_COUNT = 4; 52 53 private static final Integer DOES_NOT_EXIST = -5; 54 // FIXME(ath): make all of these statics instance variables, and have callers hold onto this 55 // instance as long as appropriate (e.g. activity lifetime). 56 // no need to listen for configuration changes. 57 private static String sSendersSplitToken; 58 private static CharSequence sDraftSingularString; 59 private static CharSequence sDraftPluralString; 60 private static CharSequence sSendingString; 61 private static CharSequence sRetryingString; 62 private static CharSequence sFailedString; 63 private static String sDraftCountFormatString; 64 private static CharacterStyle sDraftsStyleSpan; 65 private static CharacterStyle sSendingStyleSpan; 66 private static CharacterStyle sRetryingStyleSpan; 67 private static CharacterStyle sFailedStyleSpan; 68 private static TextAppearanceSpan sUnreadStyleSpan; 69 private static CharacterStyle sReadStyleSpan; 70 private static String sMeSubjectString; 71 private static String sMeObjectString; 72 private static String sToHeaderString; 73 private static String sMessageCountSpacerString; 74 public static CharSequence sElidedString; 75 private static BroadcastReceiver sConfigurationChangedReceiver; 76 private static TextAppearanceSpan sMessageInfoReadStyleSpan; 77 private static TextAppearanceSpan sMessageInfoUnreadStyleSpan; 78 private static BidiFormatter sBidiFormatter; 79 80 // We only want to have at most 2 Priority to length maps. This will handle the case where 81 // there is a widget installed on the launcher while the user is scrolling in the app 82 private static final int MAX_PRIORITY_LENGTH_MAP_LIST = 2; 83 84 // Cache of priority to length maps. We can't just use a single instance as it may be 85 // modified from different threads 86 private static final ObjectCache<Map<Integer, Integer>> PRIORITY_LENGTH_MAP_CACHE = 87 new ObjectCache<Map<Integer, Integer>>( 88 new ObjectCache.Callback<Map<Integer, Integer>>() { 89 @Override 90 public Map<Integer, Integer> newInstance() { 91 return Maps.newHashMap(); 92 } 93 @Override 94 public void onObjectReleased(Map<Integer, Integer> object) { 95 object.clear(); 96 } 97 }, MAX_PRIORITY_LENGTH_MAP_LIST); 98 99 public static Typeface getTypeface(boolean isUnread) { 100 return isUnread ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT; 101 } 102 103 private static synchronized void getSenderResources( 104 Context context, final boolean resourceCachingRequired) { 105 if (sConfigurationChangedReceiver == null && resourceCachingRequired) { 106 sConfigurationChangedReceiver = new BroadcastReceiver() { 107 @Override 108 public void onReceive(Context context, Intent intent) { 109 sDraftSingularString = null; 110 getSenderResources(context, true); 111 } 112 }; 113 context.registerReceiver(sConfigurationChangedReceiver, new IntentFilter( 114 Intent.ACTION_CONFIGURATION_CHANGED)); 115 } 116 if (sDraftSingularString == null) { 117 Resources res = context.getResources(); 118 sSendersSplitToken = res.getString(R.string.senders_split_token); 119 sElidedString = res.getString(R.string.senders_elided); 120 sDraftSingularString = res.getQuantityText(R.plurals.draft, 1); 121 sDraftPluralString = res.getQuantityText(R.plurals.draft, 2); 122 sDraftCountFormatString = res.getString(R.string.draft_count_format); 123 sMeSubjectString = res.getString(R.string.me_subject_pronoun); 124 sMeObjectString = res.getString(R.string.me_object_pronoun); 125 sToHeaderString = res.getString(R.string.to_heading); 126 sMessageInfoUnreadStyleSpan = new TextAppearanceSpan(context, 127 R.style.MessageInfoUnreadTextAppearance); 128 sMessageInfoReadStyleSpan = new TextAppearanceSpan(context, 129 R.style.MessageInfoReadTextAppearance); 130 sDraftsStyleSpan = new TextAppearanceSpan(context, R.style.DraftTextAppearance); 131 sUnreadStyleSpan = new TextAppearanceSpan(context, R.style.SendersAppearanceUnreadStyle); 132 sSendingStyleSpan = new TextAppearanceSpan(context, R.style.SendingTextAppearance); 133 sRetryingStyleSpan = new TextAppearanceSpan(context, R.style.RetryingTextAppearance); 134 sFailedStyleSpan = new TextAppearanceSpan(context, R.style.FailedTextAppearance); 135 sReadStyleSpan = new TextAppearanceSpan(context, R.style.SendersAppearanceReadStyle); 136 sMessageCountSpacerString = res.getString(R.string.message_count_spacer); 137 sSendingString = res.getString(R.string.sending); 138 sRetryingString = res.getString(R.string.message_retrying); 139 sFailedString = res.getString(R.string.message_failed); 140 sBidiFormatter = BidiFormatter.getInstance(); 141 } 142 } 143 144 public static SpannableStringBuilder createMessageInfo(Context context, Conversation conv, 145 final boolean resourceCachingRequired) { 146 SpannableStringBuilder messageInfo = new SpannableStringBuilder(); 147 148 try { 149 final ConversationInfo conversationInfo = conv.conversationInfo; 150 final int sendingStatus = conv.sendingState; 151 boolean hasSenders = false; 152 // This covers the case where the sender is "me" and this is a draft 153 // message, which means this will only run once most of the time. 154 for (ParticipantInfo p : conversationInfo.participantInfos) { 155 if (!TextUtils.isEmpty(p.name)) { 156 hasSenders = true; 157 break; 158 } 159 } 160 getSenderResources(context, resourceCachingRequired); 161 final int count = conversationInfo.messageCount; 162 final int draftCount = conversationInfo.draftCount; 163 if (count > 1) { 164 appendMessageInfo(messageInfo, Integer.toString(count), CharacterStyle.wrap( 165 conv.read ? sMessageInfoReadStyleSpan : sMessageInfoUnreadStyleSpan), 166 false, conv.read); 167 } 168 169 boolean appendSplitToken = hasSenders || count > 1; 170 if (draftCount > 0) { 171 final CharSequence draftText; 172 if (draftCount == 1) { 173 draftText = sDraftSingularString; 174 } else { 175 draftText = sDraftPluralString + 176 String.format(sDraftCountFormatString, draftCount); 177 } 178 179 appendMessageInfo(messageInfo, draftText, sDraftsStyleSpan, appendSplitToken, 180 conv.read); 181 } 182 183 final boolean showState = sendingStatus == UIProvider.ConversationSendingState.SENDING || 184 sendingStatus == UIProvider.ConversationSendingState.RETRYING || 185 sendingStatus == UIProvider.ConversationSendingState.SEND_ERROR; 186 if (showState) { 187 appendSplitToken |= draftCount > 0; 188 189 final CharSequence statusText; 190 final Object span; 191 if (sendingStatus == UIProvider.ConversationSendingState.SENDING) { 192 statusText = sSendingString; 193 span = sSendingStyleSpan; 194 } else if (sendingStatus == UIProvider.ConversationSendingState.RETRYING) { 195 statusText = sSendingString; 196 span = sSendingStyleSpan; 197 } else { 198 statusText = sFailedString; 199 span = sFailedStyleSpan; 200 } 201 202 appendMessageInfo(messageInfo, statusText, span, appendSplitToken, conv.read); 203 } 204 205 // Prepend a space if we are showing other message info text. 206 if (count > 1 || (draftCount > 0 && hasSenders) || showState) { 207 messageInfo.insert(0, sMessageCountSpacerString); 208 } 209 } finally { 210 if (!resourceCachingRequired) { 211 clearResourceCache(); 212 } 213 } 214 215 return messageInfo; 216 } 217 218 private static void appendMessageInfo(SpannableStringBuilder sb, CharSequence text, 219 Object span, boolean appendSplitToken, boolean convRead) { 220 int startIndex = sb.length(); 221 if (appendSplitToken) { 222 sb.append(sSendersSplitToken); 223 sb.setSpan(CharacterStyle.wrap(convRead ? 224 sMessageInfoReadStyleSpan : sMessageInfoUnreadStyleSpan), 225 startIndex, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 226 } 227 228 startIndex = sb.length(); 229 sb.append(text); 230 sb.setSpan(span, startIndex, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 231 } 232 233 public static void format(Context context, ConversationInfo conversationInfo, 234 String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders, 235 ArrayList<String> displayableSenderNames, 236 ConversationItemViewModel.SenderAvatarModel senderAvatarModel, 237 Account account, final boolean showToHeader, final boolean resourceCachingRequired) { 238 try { 239 getSenderResources(context, resourceCachingRequired); 240 format(context, conversationInfo, messageInfo, maxChars, styledSenders, 241 displayableSenderNames, senderAvatarModel, account, 242 sUnreadStyleSpan, sReadStyleSpan, showToHeader, resourceCachingRequired); 243 } finally { 244 if (!resourceCachingRequired) { 245 clearResourceCache(); 246 } 247 } 248 } 249 250 public static void format(Context context, ConversationInfo conversationInfo, 251 String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders, 252 ArrayList<String> displayableSenderNames, 253 ConversationItemViewModel.SenderAvatarModel senderAvatarModel, 254 Account account, final TextAppearanceSpan notificationUnreadStyleSpan, 255 final CharacterStyle notificationReadStyleSpan, final boolean showToHeader, 256 final boolean resourceCachingRequired) { 257 try { 258 getSenderResources(context, resourceCachingRequired); 259 handlePriority(maxChars, messageInfo, conversationInfo, styledSenders, 260 displayableSenderNames, senderAvatarModel, account, 261 notificationUnreadStyleSpan, notificationReadStyleSpan, showToHeader); 262 } finally { 263 if (!resourceCachingRequired) { 264 clearResourceCache(); 265 } 266 } 267 } 268 269 private static void handlePriority(int maxChars, String messageInfoString, 270 ConversationInfo conversationInfo, ArrayList<SpannableString> styledSenders, 271 ArrayList<String> displayableSenderNames, 272 ConversationItemViewModel.SenderAvatarModel senderAvatarModel, 273 Account account, final TextAppearanceSpan unreadStyleSpan, 274 final CharacterStyle readStyleSpan, final boolean showToHeader) { 275 final boolean shouldSelectSenders = displayableSenderNames != null; 276 final boolean shouldSelectAvatar = senderAvatarModel != null; 277 int maxPriorityToInclude = -1; // inclusive 278 int numCharsUsed = messageInfoString.length(); // draft, number drafts, 279 // count 280 int numSendersUsed = 0; 281 int numCharsToRemovePerWord = 0; 282 int maxFoundPriority = 0; 283 if (numCharsUsed > maxChars) { 284 numCharsToRemovePerWord = numCharsUsed - maxChars; 285 } 286 287 final Map<Integer, Integer> priorityToLength = PRIORITY_LENGTH_MAP_CACHE.get(); 288 try { 289 priorityToLength.clear(); 290 int senderLength; 291 for (ParticipantInfo info : conversationInfo.participantInfos) { 292 final String senderName = info.name; 293 senderLength = !TextUtils.isEmpty(senderName) ? senderName.length() : 0; 294 priorityToLength.put(info.priority, senderLength); 295 maxFoundPriority = Math.max(maxFoundPriority, info.priority); 296 } 297 while (maxPriorityToInclude < maxFoundPriority) { 298 if (priorityToLength.containsKey(maxPriorityToInclude + 1)) { 299 int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1); 300 if (numCharsUsed > 0) 301 length += 2; 302 // We must show at least two senders if they exist. If we don't 303 // have space for both 304 // then we will truncate names. 305 if (length > maxChars && numSendersUsed >= 2) { 306 break; 307 } 308 numCharsUsed = length; 309 numSendersUsed++; 310 } 311 maxPriorityToInclude++; 312 } 313 } finally { 314 PRIORITY_LENGTH_MAP_CACHE.release(priorityToLength); 315 } 316 317 SpannableString spannableDisplay; 318 boolean appendedElided = false; 319 final Map<String, Integer> displayHash = Maps.newHashMap(); 320 final List<String> senderEmails = Lists.newArrayListWithExpectedSize(MAX_SENDER_COUNT); 321 String firstSenderEmail = null; 322 String firstSenderName = null; 323 for (int i = 0; i < conversationInfo.participantInfos.size(); i++) { 324 final ParticipantInfo currentParticipant = conversationInfo.participantInfos.get(i); 325 final String currentEmail = currentParticipant.email; 326 327 final String currentName = currentParticipant.name; 328 String nameString = !TextUtils.isEmpty(currentName) ? currentName : ""; 329 if (nameString.length() == 0) { 330 // if we're showing the To: header, show the object version of me. 331 nameString = getMe(showToHeader /* useObjectMe */); 332 } 333 if (numCharsToRemovePerWord != 0) { 334 nameString = nameString.substring(0, 335 Math.max(nameString.length() - numCharsToRemovePerWord, 0)); 336 } 337 338 final int priority = currentParticipant.priority; 339 final CharacterStyle style = CharacterStyle.wrap(currentParticipant.readConversation ? 340 readStyleSpan : unreadStyleSpan); 341 if (priority <= maxPriorityToInclude) { 342 spannableDisplay = new SpannableString(sBidiFormatter.unicodeWrap(nameString)); 343 // Don't duplicate senders; leave the first instance, unless the 344 // current instance is also unread. 345 int oldPos = displayHash.containsKey(currentName) ? displayHash 346 .get(currentName) : DOES_NOT_EXIST; 347 // If this sender doesn't exist OR the current message is 348 // unread, add the sender. 349 if (oldPos == DOES_NOT_EXIST || !currentParticipant.readConversation) { 350 // If the sender entry already existed, and is right next to the 351 // current sender, remove the old entry. 352 if (oldPos != DOES_NOT_EXIST && i > 0 && oldPos == i - 1 353 && oldPos < styledSenders.size()) { 354 // Remove the old one! 355 styledSenders.set(oldPos, null); 356 if (shouldSelectSenders && !TextUtils.isEmpty(currentEmail)) { 357 senderEmails.remove(currentEmail); 358 displayableSenderNames.remove(currentName); 359 } 360 } 361 displayHash.put(currentName, i); 362 spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0); 363 styledSenders.add(spannableDisplay); 364 } 365 } else { 366 if (!appendedElided) { 367 spannableDisplay = new SpannableString(sElidedString); 368 spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0); 369 appendedElided = true; 370 styledSenders.add(spannableDisplay); 371 } 372 } 373 374 final String senderEmail = TextUtils.isEmpty(currentName) ? account.getEmailAddress() : 375 TextUtils.isEmpty(currentEmail) ? currentName : currentEmail; 376 377 if (shouldSelectSenders) { 378 if (i == 0) { 379 // Always add the first sender! 380 firstSenderEmail = senderEmail; 381 firstSenderName = currentName; 382 } else { 383 if (!Objects.equal(firstSenderEmail, senderEmail)) { 384 int indexOf = senderEmails.indexOf(senderEmail); 385 if (indexOf > -1) { 386 senderEmails.remove(indexOf); 387 displayableSenderNames.remove(indexOf); 388 } 389 senderEmails.add(senderEmail); 390 displayableSenderNames.add(currentName); 391 if (senderEmails.size() > MAX_SENDER_COUNT) { 392 senderEmails.remove(0); 393 displayableSenderNames.remove(0); 394 } 395 } 396 } 397 } 398 399 // if the corresponding message from this participant is unread and no sender avatar 400 // is yet chosen, choose this one 401 if (shouldSelectAvatar && senderAvatarModel.isNotPopulated() && 402 !currentParticipant.readConversation) { 403 senderAvatarModel.populate(currentName, senderEmail); 404 } 405 } 406 407 // always add the first sender to the display 408 if (shouldSelectSenders && !TextUtils.isEmpty(firstSenderEmail)) { 409 if (displayableSenderNames.size() < MAX_SENDER_COUNT) { 410 displayableSenderNames.add(0, firstSenderName); 411 } else { 412 displayableSenderNames.set(0, firstSenderName); 413 } 414 } 415 416 // if all messages in the thread were read, we must search for an appropriate avatar 417 if (shouldSelectAvatar && senderAvatarModel.isNotPopulated()) { 418 // search for the last sender that is not the current account 419 for (int i = conversationInfo.participantInfos.size() - 1; i >= 0; i--) { 420 final ParticipantInfo participant = conversationInfo.participantInfos.get(i); 421 // empty name implies it is the current account and should not be chosen 422 if (!TextUtils.isEmpty(participant.name)) { 423 // use the participant name in place of unusable email addresses 424 final String senderEmail = TextUtils.isEmpty(participant.email) ? 425 participant.name : participant.email; 426 senderAvatarModel.populate(participant.name, senderEmail); 427 break; 428 } 429 } 430 431 // if we still don't have an avatar, the account is emailing itself 432 if (senderAvatarModel.isNotPopulated()) { 433 senderAvatarModel.populate(account.getDisplayName(), account.getEmailAddress()); 434 } 435 } 436 } 437 438 static String getMe(boolean useObjectMe) { 439 return useObjectMe ? sMeObjectString : sMeSubjectString; 440 } 441 442 public static SpannableString getFormattedToHeader() { 443 final SpannableString formattedToHeader = new SpannableString(sToHeaderString); 444 final CharacterStyle readStyle = CharacterStyle.wrap(sReadStyleSpan); 445 formattedToHeader.setSpan(readStyle, 0, formattedToHeader.length(), 0); 446 return formattedToHeader; 447 } 448 449 public static SpannableString getSingularDraftString(Context context) { 450 getSenderResources(context, true /* resourceCachingRequired */); 451 final SpannableString formattedDraftString = new SpannableString(sDraftSingularString); 452 final CharacterStyle readStyle = CharacterStyle.wrap(sDraftsStyleSpan); 453 formattedDraftString.setSpan(readStyle, 0, formattedDraftString.length(), 0); 454 return formattedDraftString; 455 } 456 457 private static void clearResourceCache() { 458 sDraftSingularString = null; 459 } 460 } 461