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.Spannable; 28 import android.text.SpannableString; 29 import android.text.SpannableStringBuilder; 30 import android.text.TextUtils; 31 import android.text.style.CharacterStyle; 32 import android.text.style.TextAppearanceSpan; 33 import android.text.util.Rfc822Token; 34 import android.text.util.Rfc822Tokenizer; 35 36 import com.android.mail.R; 37 import com.android.mail.providers.Address; 38 import com.android.mail.providers.Conversation; 39 import com.android.mail.providers.ConversationInfo; 40 import com.android.mail.providers.MessageInfo; 41 import com.android.mail.providers.UIProvider; 42 import com.android.mail.ui.DividedImageCanvas; 43 import com.android.mail.utils.ObjectCache; 44 import com.google.common.base.Objects; 45 import com.google.common.collect.Maps; 46 47 import java.util.ArrayList; 48 import java.util.Locale; 49 import java.util.Map; 50 51 import java.util.regex.Pattern; 52 53 public class SendersView { 54 public static final int DEFAULT_FORMATTING = 0; 55 public static final int MERGED_FORMATTING = 1; 56 private static final Integer DOES_NOT_EXIST = -5; 57 // FIXME(ath): make all of these statics instance variables, and have callers hold onto this 58 // instance as long as appropriate (e.g. activity lifetime). 59 // no need to listen for configuration changes. 60 private static String sSendersSplitToken; 61 public static String SENDERS_VERSION_SEPARATOR = "^**^"; 62 public static Pattern SENDERS_VERSION_SEPARATOR_PATTERN = Pattern.compile("\\^\\*\\*\\^"); 63 private static CharSequence sDraftSingularString; 64 private static CharSequence sDraftPluralString; 65 private static CharSequence sSendingString; 66 private static String sDraftCountFormatString; 67 private static CharacterStyle sDraftsStyleSpan; 68 private static CharacterStyle sSendingStyleSpan; 69 private static TextAppearanceSpan sUnreadStyleSpan; 70 private static CharacterStyle sReadStyleSpan; 71 private static String sMeString; 72 private static Locale sMeStringLocale; 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 sMessageInfoUnreadStyleSpan = new TextAppearanceSpan(context, 124 R.style.MessageInfoUnreadTextAppearance); 125 sMessageInfoReadStyleSpan = new TextAppearanceSpan(context, 126 R.style.MessageInfoReadTextAppearance); 127 sDraftsStyleSpan = new TextAppearanceSpan(context, R.style.DraftTextAppearance); 128 sUnreadStyleSpan = new TextAppearanceSpan(context, R.style.SendersUnreadTextAppearance); 129 sSendingStyleSpan = new TextAppearanceSpan(context, R.style.SendingTextAppearance); 130 sReadStyleSpan = new TextAppearanceSpan(context, R.style.SendersReadTextAppearance); 131 sMessageCountSpacerString = res.getString(R.string.message_count_spacer); 132 sSendingString = res.getString(R.string.sending); 133 sBidiFormatter = BidiFormatter.getInstance(); 134 } 135 } 136 137 public static SpannableStringBuilder createMessageInfo(Context context, Conversation conv, 138 final boolean resourceCachingRequired) { 139 SpannableStringBuilder messageInfo = new SpannableStringBuilder(); 140 141 try { 142 ConversationInfo conversationInfo = conv.conversationInfo; 143 int sendingStatus = conv.sendingState; 144 boolean hasSenders = false; 145 // This covers the case where the sender is "me" and this is a draft 146 // message, which means this will only run once most of the time. 147 for (MessageInfo m : conversationInfo.messageInfos) { 148 if (!TextUtils.isEmpty(m.sender)) { 149 hasSenders = true; 150 break; 151 } 152 } 153 getSenderResources(context, resourceCachingRequired); 154 if (conversationInfo != null) { 155 int count = conversationInfo.messageCount; 156 int draftCount = conversationInfo.draftCount; 157 boolean showSending = sendingStatus == UIProvider.ConversationSendingState.SENDING; 158 if (count > 1) { 159 messageInfo.append(count + ""); 160 } 161 messageInfo.setSpan(CharacterStyle.wrap( 162 conv.read ? sMessageInfoReadStyleSpan : sMessageInfoUnreadStyleSpan), 163 0, messageInfo.length(), 0); 164 if (draftCount > 0) { 165 // If we are showing a message count or any draft text and there 166 // is at least 1 sender, prepend the sending state text with a 167 // comma. 168 if (hasSenders || count > 1) { 169 messageInfo.append(sSendersSplitToken); 170 } 171 SpannableStringBuilder draftString = new SpannableStringBuilder(); 172 if (draftCount == 1) { 173 draftString.append(sDraftSingularString); 174 } else { 175 draftString.append(sDraftPluralString 176 + String.format(sDraftCountFormatString, draftCount)); 177 } 178 draftString.setSpan(CharacterStyle.wrap(sDraftsStyleSpan), 0, 179 draftString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 180 messageInfo.append(draftString); 181 } 182 if (showSending) { 183 // If we are showing a message count or any draft text, prepend 184 // the sending state text with a comma. 185 if (count > 1 || draftCount > 0) { 186 messageInfo.append(sSendersSplitToken); 187 } 188 SpannableStringBuilder sending = new SpannableStringBuilder(); 189 sending.append(sSendingString); 190 sending.setSpan(sSendingStyleSpan, 0, sending.length(), 0); 191 messageInfo.append(sending); 192 } 193 // Prepend a space if we are showing other message info text. 194 if (count > 1 || (draftCount > 0 && hasSenders) || showSending) { 195 messageInfo.insert(0, sMessageCountSpacerString); 196 } 197 } 198 } finally { 199 if (!resourceCachingRequired) { 200 clearResourceCache(); 201 } 202 } 203 204 return messageInfo; 205 } 206 207 public static void format(Context context, ConversationInfo conversationInfo, 208 String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders, 209 ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails, 210 String account, final boolean resourceCachingRequired) { 211 try { 212 getSenderResources(context, resourceCachingRequired); 213 format(context, conversationInfo, messageInfo, maxChars, styledSenders, 214 displayableSenderNames, displayableSenderEmails, account, 215 sUnreadStyleSpan, sReadStyleSpan, resourceCachingRequired); 216 } finally { 217 if (!resourceCachingRequired) { 218 clearResourceCache(); 219 } 220 } 221 } 222 223 public static void format(Context context, ConversationInfo conversationInfo, 224 String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders, 225 ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails, 226 String account, final TextAppearanceSpan notificationUnreadStyleSpan, 227 final CharacterStyle notificationReadStyleSpan, final boolean resourceCachingRequired) { 228 try { 229 getSenderResources(context, resourceCachingRequired); 230 handlePriority(context, maxChars, messageInfo, conversationInfo, styledSenders, 231 displayableSenderNames, displayableSenderEmails, account, 232 notificationUnreadStyleSpan, notificationReadStyleSpan); 233 } finally { 234 if (!resourceCachingRequired) { 235 clearResourceCache(); 236 } 237 } 238 } 239 240 public static void handlePriority(Context context, int maxChars, String messageInfoString, 241 ConversationInfo conversationInfo, ArrayList<SpannableString> styledSenders, 242 ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails, 243 String account, final TextAppearanceSpan unreadStyleSpan, 244 final CharacterStyle readStyleSpan) { 245 boolean shouldAddPhotos = displayableSenderEmails != null; 246 int maxPriorityToInclude = -1; // inclusive 247 int numCharsUsed = messageInfoString.length(); // draft, number drafts, 248 // count 249 int numSendersUsed = 0; 250 int numCharsToRemovePerWord = 0; 251 int maxFoundPriority = 0; 252 if (numCharsUsed > maxChars) { 253 numCharsToRemovePerWord = numCharsUsed - maxChars; 254 } 255 256 final Map<Integer, Integer> priorityToLength = PRIORITY_LENGTH_MAP_CACHE.get(); 257 try { 258 priorityToLength.clear(); 259 int senderLength; 260 for (MessageInfo info : conversationInfo.messageInfos) { 261 senderLength = !TextUtils.isEmpty(info.sender) ? info.sender.length() : 0; 262 priorityToLength.put(info.priority, senderLength); 263 maxFoundPriority = Math.max(maxFoundPriority, info.priority); 264 } 265 while (maxPriorityToInclude < maxFoundPriority) { 266 if (priorityToLength.containsKey(maxPriorityToInclude + 1)) { 267 int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1); 268 if (numCharsUsed > 0) 269 length += 2; 270 // We must show at least two senders if they exist. If we don't 271 // have space for both 272 // then we will truncate names. 273 if (length > maxChars && numSendersUsed >= 2) { 274 break; 275 } 276 numCharsUsed = length; 277 numSendersUsed++; 278 } 279 maxPriorityToInclude++; 280 } 281 } finally { 282 PRIORITY_LENGTH_MAP_CACHE.release(priorityToLength); 283 } 284 // We want to include this entry if 285 // 1) The onlyShowUnread flags is not set 286 // 2) The above flag is set, and the message is unread 287 MessageInfo currentMessage; 288 SpannableString spannableDisplay; 289 String nameString; 290 CharacterStyle style; 291 boolean appendedElided = false; 292 Map<String, Integer> displayHash = Maps.newHashMap(); 293 String firstDisplayableSenderEmail = null; 294 String firstDisplayableSender = null; 295 for (int i = 0; i < conversationInfo.messageInfos.size(); i++) { 296 currentMessage = conversationInfo.messageInfos.get(i); 297 nameString = !TextUtils.isEmpty(currentMessage.sender) ? currentMessage.sender : ""; 298 if (nameString.length() == 0) { 299 nameString = getMe(context); 300 } 301 if (numCharsToRemovePerWord != 0) { 302 nameString = nameString.substring(0, 303 Math.max(nameString.length() - numCharsToRemovePerWord, 0)); 304 } 305 final int priority = currentMessage.priority; 306 style = !currentMessage.read ? getWrappedStyleSpan(unreadStyleSpan) 307 : getWrappedStyleSpan(readStyleSpan); 308 if (priority <= maxPriorityToInclude) { 309 spannableDisplay = new SpannableString(sBidiFormatter.unicodeWrap(nameString)); 310 // Don't duplicate senders; leave the first instance, unless the 311 // current instance is also unread. 312 int oldPos = displayHash.containsKey(currentMessage.sender) ? displayHash 313 .get(currentMessage.sender) : DOES_NOT_EXIST; 314 // If this sender doesn't exist OR the current message is 315 // unread, add the sender. 316 if (oldPos == DOES_NOT_EXIST || !currentMessage.read) { 317 // If the sender entry already existed, and is right next to the 318 // current sender, remove the old entry. 319 if (oldPos != DOES_NOT_EXIST && i > 0 && oldPos == i - 1 320 && oldPos < styledSenders.size()) { 321 // Remove the old one! 322 styledSenders.set(oldPos, null); 323 if (shouldAddPhotos && !TextUtils.isEmpty(currentMessage.senderEmail)) { 324 displayableSenderEmails.remove(currentMessage.senderEmail); 325 displayableSenderNames.remove(currentMessage.sender); 326 } 327 } 328 displayHash.put(currentMessage.sender, i); 329 spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0); 330 styledSenders.add(spannableDisplay); 331 } 332 } else { 333 if (!appendedElided) { 334 spannableDisplay = new SpannableString(sElidedString); 335 spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0); 336 appendedElided = true; 337 styledSenders.add(spannableDisplay); 338 } 339 } 340 if (shouldAddPhotos) { 341 String senderEmail = TextUtils.isEmpty(currentMessage.sender) ? 342 account : 343 TextUtils.isEmpty(currentMessage.senderEmail) ? 344 currentMessage.sender : currentMessage.senderEmail; 345 if (i == 0) { 346 // Always add the first sender! 347 firstDisplayableSenderEmail = senderEmail; 348 firstDisplayableSender = currentMessage.sender; 349 } else { 350 if (!Objects.equal(firstDisplayableSenderEmail, senderEmail)) { 351 int indexOf = displayableSenderEmails.indexOf(senderEmail); 352 if (indexOf > -1) { 353 displayableSenderEmails.remove(indexOf); 354 displayableSenderNames.remove(indexOf); 355 } 356 displayableSenderEmails.add(senderEmail); 357 displayableSenderNames.add(currentMessage.sender); 358 if (displayableSenderEmails.size() > DividedImageCanvas.MAX_DIVISIONS) { 359 displayableSenderEmails.remove(0); 360 displayableSenderNames.remove(0); 361 } 362 } 363 } 364 } 365 } 366 if (shouldAddPhotos && !TextUtils.isEmpty(firstDisplayableSenderEmail)) { 367 if (displayableSenderEmails.size() < DividedImageCanvas.MAX_DIVISIONS) { 368 displayableSenderEmails.add(0, firstDisplayableSenderEmail); 369 displayableSenderNames.add(0, firstDisplayableSender); 370 } else { 371 displayableSenderEmails.set(0, firstDisplayableSenderEmail); 372 displayableSenderNames.set(0, firstDisplayableSender); 373 } 374 } 375 } 376 377 private static CharacterStyle getWrappedStyleSpan(final CharacterStyle characterStyle) { 378 return CharacterStyle.wrap(characterStyle); 379 } 380 381 static String getMe(Context context) { 382 final Resources resources = context.getResources(); 383 final Locale locale = resources.getConfiguration().locale; 384 385 if (sMeString == null || !locale.equals(sMeStringLocale)) { 386 sMeString = resources.getString(R.string.me_subject_pronun); 387 sMeStringLocale = locale; 388 } 389 return sMeString; 390 } 391 392 private static void formatDefault(ConversationItemViewModel header, String sendersString, 393 Context context, final CharacterStyle readStyleSpan, 394 final boolean resourceCachingRequired) { 395 try { 396 getSenderResources(context, resourceCachingRequired); 397 // Clear any existing sender fragments; we must re-make all of them. 398 header.senderFragments.clear(); 399 // TODO: unify this with ConversationItemView.calculateTextsAndBitmaps's tokenization 400 final Rfc822Token[] senders = Rfc822Tokenizer.tokenize(sendersString); 401 final String[] namesOnly = new String[senders.length]; 402 String display; 403 for (int i = 0; i < senders.length; i++) { 404 display = Address.decodeAddressName(senders[i].getName()); 405 if (TextUtils.isEmpty(display)) { 406 display = senders[i].getAddress(); 407 } 408 namesOnly[i] = display; 409 } 410 generateSenderFragments(header, namesOnly, readStyleSpan); 411 } finally { 412 if (!resourceCachingRequired) { 413 clearResourceCache(); 414 } 415 } 416 } 417 418 private static void generateSenderFragments(ConversationItemViewModel header, String[] names, 419 final CharacterStyle readStyleSpan) { 420 header.sendersText = TextUtils.join(Address.ADDRESS_DELIMETER + " ", names); 421 header.addSenderFragment(0, header.sendersText.length(), getWrappedStyleSpan(readStyleSpan), 422 true); 423 } 424 425 public static void formatSenders(ConversationItemViewModel header, Context context, 426 final boolean resourceCachingRequired) { 427 try { 428 getSenderResources(context, resourceCachingRequired); 429 formatSenders(header, context, sReadStyleSpan, resourceCachingRequired); 430 } finally { 431 if (!resourceCachingRequired) { 432 clearResourceCache(); 433 } 434 } 435 } 436 437 public static void formatSenders(ConversationItemViewModel header, Context context, 438 final CharacterStyle readStyleSpan, final boolean resourceCachingRequired) { 439 try { 440 formatDefault(header, header.conversation.senders, context, readStyleSpan, 441 resourceCachingRequired); 442 } finally { 443 if (!resourceCachingRequired) { 444 clearResourceCache(); 445 } 446 } 447 } 448 449 private static void clearResourceCache() { 450 sDraftSingularString = null; 451 } 452 } 453