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 34 import com.android.mail.R; 35 import com.android.mail.providers.Conversation; 36 import com.android.mail.providers.ConversationInfo; 37 import com.android.mail.providers.ParticipantInfo; 38 import com.android.mail.providers.UIProvider; 39 import com.android.mail.ui.DividedImageCanvas; 40 import com.android.mail.utils.ObjectCache; 41 import com.google.common.base.Objects; 42 import com.google.common.collect.Maps; 43 44 import java.util.ArrayList; 45 import java.util.Map; 46 47 public class SendersView { 48 private static final Integer DOES_NOT_EXIST = -5; 49 // FIXME(ath): make all of these statics instance variables, and have callers hold onto this 50 // instance as long as appropriate (e.g. activity lifetime). 51 // no need to listen for configuration changes. 52 private static String sSendersSplitToken; 53 private static CharSequence sDraftSingularString; 54 private static CharSequence sDraftPluralString; 55 private static CharSequence sSendingString; 56 private static CharSequence sRetryingString; 57 private static CharSequence sFailedString; 58 private static String sDraftCountFormatString; 59 private static CharacterStyle sDraftsStyleSpan; 60 private static CharacterStyle sSendingStyleSpan; 61 private static CharacterStyle sRetryingStyleSpan; 62 private static CharacterStyle sFailedStyleSpan; 63 private static TextAppearanceSpan sUnreadStyleSpan; 64 private static CharacterStyle sReadStyleSpan; 65 private static String sMeSubjectString; 66 private static String sMeObjectString; 67 private static String sToHeaderString; 68 private static String sMessageCountSpacerString; 69 public static CharSequence sElidedString; 70 private static BroadcastReceiver sConfigurationChangedReceiver; 71 private static TextAppearanceSpan sMessageInfoReadStyleSpan; 72 private static TextAppearanceSpan sMessageInfoUnreadStyleSpan; 73 private static BidiFormatter sBidiFormatter; 74 75 // We only want to have at most 2 Priority to length maps. This will handle the case where 76 // there is a widget installed on the launcher while the user is scrolling in the app 77 private static final int MAX_PRIORITY_LENGTH_MAP_LIST = 2; 78 79 // Cache of priority to length maps. We can't just use a single instance as it may be 80 // modified from different threads 81 private static final ObjectCache<Map<Integer, Integer>> PRIORITY_LENGTH_MAP_CACHE = 82 new ObjectCache<Map<Integer, Integer>>( 83 new ObjectCache.Callback<Map<Integer, Integer>>() { 84 @Override 85 public Map<Integer, Integer> newInstance() { 86 return Maps.newHashMap(); 87 } 88 @Override 89 public void onObjectReleased(Map<Integer, Integer> object) { 90 object.clear(); 91 } 92 }, MAX_PRIORITY_LENGTH_MAP_LIST); 93 94 public static Typeface getTypeface(boolean isUnread) { 95 return isUnread ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT; 96 } 97 98 private static synchronized void getSenderResources( 99 Context context, final boolean resourceCachingRequired) { 100 if (sConfigurationChangedReceiver == null && resourceCachingRequired) { 101 sConfigurationChangedReceiver = new BroadcastReceiver() { 102 @Override 103 public void onReceive(Context context, Intent intent) { 104 sDraftSingularString = null; 105 getSenderResources(context, true); 106 } 107 }; 108 context.registerReceiver(sConfigurationChangedReceiver, new IntentFilter( 109 Intent.ACTION_CONFIGURATION_CHANGED)); 110 } 111 if (sDraftSingularString == null) { 112 Resources res = context.getResources(); 113 sSendersSplitToken = res.getString(R.string.senders_split_token); 114 sElidedString = res.getString(R.string.senders_elided); 115 sDraftSingularString = res.getQuantityText(R.plurals.draft, 1); 116 sDraftPluralString = res.getQuantityText(R.plurals.draft, 2); 117 sDraftCountFormatString = res.getString(R.string.draft_count_format); 118 sMeSubjectString = res.getString(R.string.me_subject_pronoun); 119 sMeObjectString = res.getString(R.string.me_object_pronoun); 120 sToHeaderString = res.getString(R.string.to_heading); 121 sMessageInfoUnreadStyleSpan = new TextAppearanceSpan(context, 122 R.style.MessageInfoUnreadTextAppearance); 123 sMessageInfoReadStyleSpan = new TextAppearanceSpan(context, 124 R.style.MessageInfoReadTextAppearance); 125 sDraftsStyleSpan = new TextAppearanceSpan(context, R.style.DraftTextAppearance); 126 sUnreadStyleSpan = new TextAppearanceSpan(context, R.style.SendersAppearanceUnreadStyle); 127 sSendingStyleSpan = new TextAppearanceSpan(context, R.style.SendingTextAppearance); 128 sRetryingStyleSpan = new TextAppearanceSpan(context, R.style.RetryingTextAppearance); 129 sFailedStyleSpan = new TextAppearanceSpan(context, R.style.FailedTextAppearance); 130 sReadStyleSpan = new TextAppearanceSpan(context, R.style.SendersAppearanceReadStyle); 131 sMessageCountSpacerString = res.getString(R.string.message_count_spacer); 132 sSendingString = res.getString(R.string.sending); 133 sRetryingString = res.getString(R.string.message_retrying); 134 sFailedString = res.getString(R.string.message_failed); 135 sBidiFormatter = BidiFormatter.getInstance(); 136 } 137 } 138 139 public static SpannableStringBuilder createMessageInfo(Context context, Conversation conv, 140 final boolean resourceCachingRequired) { 141 SpannableStringBuilder messageInfo = new SpannableStringBuilder(); 142 143 try { 144 ConversationInfo conversationInfo = conv.conversationInfo; 145 int sendingStatus = conv.sendingState; 146 boolean hasSenders = false; 147 // This covers the case where the sender is "me" and this is a draft 148 // message, which means this will only run once most of the time. 149 for (ParticipantInfo p : conversationInfo.participantInfos) { 150 if (!TextUtils.isEmpty(p.name)) { 151 hasSenders = true; 152 break; 153 } 154 } 155 getSenderResources(context, resourceCachingRequired); 156 int count = conversationInfo.messageCount; 157 int draftCount = conversationInfo.draftCount; 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).append( 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 183 boolean showState = sendingStatus == UIProvider.ConversationSendingState.SENDING || 184 sendingStatus == UIProvider.ConversationSendingState.RETRYING || 185 sendingStatus == UIProvider.ConversationSendingState.SEND_ERROR; 186 if (showState) { 187 // If we are showing a message count or any draft text, prepend 188 // the sending state text with a comma. 189 if (count > 1 || draftCount > 0) { 190 messageInfo.append(sSendersSplitToken); 191 } 192 193 SpannableStringBuilder stateSpan = new SpannableStringBuilder(); 194 195 if (sendingStatus == UIProvider.ConversationSendingState.SENDING) { 196 stateSpan.append(sSendingString); 197 stateSpan.setSpan(sSendingStyleSpan, 0, stateSpan.length(), 0); 198 } else if (sendingStatus == UIProvider.ConversationSendingState.RETRYING) { 199 stateSpan.append(sRetryingString); 200 stateSpan.setSpan(sRetryingStyleSpan, 0, stateSpan.length(), 0); 201 } else if (sendingStatus == UIProvider.ConversationSendingState.SEND_ERROR) { 202 stateSpan.append(sFailedString); 203 stateSpan.setSpan(sFailedStyleSpan, 0, stateSpan.length(), 0); 204 } 205 messageInfo.append(stateSpan); 206 } 207 208 // Prepend a space if we are showing other message info text. 209 if (count > 1 || (draftCount > 0 && hasSenders) || showState) { 210 messageInfo.insert(0, sMessageCountSpacerString); 211 } 212 } finally { 213 if (!resourceCachingRequired) { 214 clearResourceCache(); 215 } 216 } 217 218 return messageInfo; 219 } 220 221 public static void format(Context context, ConversationInfo conversationInfo, 222 String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders, 223 ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails, 224 String account, final boolean showToHeader, final boolean resourceCachingRequired) { 225 try { 226 getSenderResources(context, resourceCachingRequired); 227 format(context, conversationInfo, messageInfo, maxChars, styledSenders, 228 displayableSenderNames, displayableSenderEmails, account, 229 sUnreadStyleSpan, sReadStyleSpan, showToHeader, resourceCachingRequired); 230 } finally { 231 if (!resourceCachingRequired) { 232 clearResourceCache(); 233 } 234 } 235 } 236 237 public static void format(Context context, ConversationInfo conversationInfo, 238 String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders, 239 ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails, 240 String account, final TextAppearanceSpan notificationUnreadStyleSpan, 241 final CharacterStyle notificationReadStyleSpan, final boolean showToHeader, 242 final boolean resourceCachingRequired) { 243 try { 244 getSenderResources(context, resourceCachingRequired); 245 handlePriority(maxChars, messageInfo, conversationInfo, styledSenders, 246 displayableSenderNames, displayableSenderEmails, account, 247 notificationUnreadStyleSpan, notificationReadStyleSpan, showToHeader); 248 } finally { 249 if (!resourceCachingRequired) { 250 clearResourceCache(); 251 } 252 } 253 } 254 255 private static void handlePriority(int maxChars, String messageInfoString, 256 ConversationInfo conversationInfo, ArrayList<SpannableString> styledSenders, 257 ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails, 258 String account, final TextAppearanceSpan unreadStyleSpan, 259 final CharacterStyle readStyleSpan, final boolean showToHeader) { 260 boolean shouldAddPhotos = displayableSenderEmails != null; 261 int maxPriorityToInclude = -1; // inclusive 262 int numCharsUsed = messageInfoString.length(); // draft, number drafts, 263 // count 264 int numSendersUsed = 0; 265 int numCharsToRemovePerWord = 0; 266 int maxFoundPriority = 0; 267 if (numCharsUsed > maxChars) { 268 numCharsToRemovePerWord = numCharsUsed - maxChars; 269 } 270 271 final Map<Integer, Integer> priorityToLength = PRIORITY_LENGTH_MAP_CACHE.get(); 272 try { 273 priorityToLength.clear(); 274 int senderLength; 275 for (ParticipantInfo info : conversationInfo.participantInfos) { 276 final String senderName = info.name; 277 senderLength = !TextUtils.isEmpty(senderName) ? senderName.length() : 0; 278 priorityToLength.put(info.priority, senderLength); 279 maxFoundPriority = Math.max(maxFoundPriority, info.priority); 280 } 281 while (maxPriorityToInclude < maxFoundPriority) { 282 if (priorityToLength.containsKey(maxPriorityToInclude + 1)) { 283 int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1); 284 if (numCharsUsed > 0) 285 length += 2; 286 // We must show at least two senders if they exist. If we don't 287 // have space for both 288 // then we will truncate names. 289 if (length > maxChars && numSendersUsed >= 2) { 290 break; 291 } 292 numCharsUsed = length; 293 numSendersUsed++; 294 } 295 maxPriorityToInclude++; 296 } 297 } finally { 298 PRIORITY_LENGTH_MAP_CACHE.release(priorityToLength); 299 } 300 // We want to include this entry if 301 // 1) The onlyShowUnread flags is not set 302 // 2) The above flag is set, and the message is unread 303 ParticipantInfo currentParticipant; 304 SpannableString spannableDisplay; 305 CharacterStyle style; 306 boolean appendedElided = false; 307 Map<String, Integer> displayHash = Maps.newHashMap(); 308 String firstDisplayableSenderEmail = null; 309 String firstDisplayableSender = null; 310 for (int i = 0; i < conversationInfo.participantInfos.size(); i++) { 311 currentParticipant = conversationInfo.participantInfos.get(i); 312 final String currentEmail = currentParticipant.email; 313 314 final String currentName = currentParticipant.name; 315 String nameString = !TextUtils.isEmpty(currentName) ? currentName : ""; 316 if (nameString.length() == 0) { 317 // if we're showing the To: header, show the object version of me. 318 nameString = getMe(showToHeader /* useObjectMe */); 319 } 320 if (numCharsToRemovePerWord != 0) { 321 nameString = nameString.substring(0, 322 Math.max(nameString.length() - numCharsToRemovePerWord, 0)); 323 } 324 325 final int priority = currentParticipant.priority; 326 style = CharacterStyle.wrap(currentParticipant.readConversation ? readStyleSpan : 327 unreadStyleSpan); 328 if (priority <= maxPriorityToInclude) { 329 spannableDisplay = new SpannableString(sBidiFormatter.unicodeWrap(nameString)); 330 // Don't duplicate senders; leave the first instance, unless the 331 // current instance is also unread. 332 int oldPos = displayHash.containsKey(currentName) ? displayHash 333 .get(currentName) : DOES_NOT_EXIST; 334 // If this sender doesn't exist OR the current message is 335 // unread, add the sender. 336 if (oldPos == DOES_NOT_EXIST || !currentParticipant.readConversation) { 337 // If the sender entry already existed, and is right next to the 338 // current sender, remove the old entry. 339 if (oldPos != DOES_NOT_EXIST && i > 0 && oldPos == i - 1 340 && oldPos < styledSenders.size()) { 341 // Remove the old one! 342 styledSenders.set(oldPos, null); 343 if (shouldAddPhotos && !TextUtils.isEmpty(currentEmail)) { 344 displayableSenderEmails.remove(currentEmail); 345 displayableSenderNames.remove(currentName); 346 } 347 } 348 displayHash.put(currentName, i); 349 spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0); 350 styledSenders.add(spannableDisplay); 351 } 352 } else { 353 if (!appendedElided) { 354 spannableDisplay = new SpannableString(sElidedString); 355 spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0); 356 appendedElided = true; 357 styledSenders.add(spannableDisplay); 358 } 359 } 360 if (shouldAddPhotos) { 361 String senderEmail = TextUtils.isEmpty(currentName) ? 362 account : 363 TextUtils.isEmpty(currentEmail) ? currentName : currentEmail; 364 if (i == 0) { 365 // Always add the first sender! 366 firstDisplayableSenderEmail = senderEmail; 367 firstDisplayableSender = currentName; 368 } else { 369 if (!Objects.equal(firstDisplayableSenderEmail, senderEmail)) { 370 int indexOf = displayableSenderEmails.indexOf(senderEmail); 371 if (indexOf > -1) { 372 displayableSenderEmails.remove(indexOf); 373 displayableSenderNames.remove(indexOf); 374 } 375 displayableSenderEmails.add(senderEmail); 376 displayableSenderNames.add(currentName); 377 if (displayableSenderEmails.size() > DividedImageCanvas.MAX_DIVISIONS) { 378 displayableSenderEmails.remove(0); 379 displayableSenderNames.remove(0); 380 } 381 } 382 } 383 } 384 } 385 if (shouldAddPhotos && !TextUtils.isEmpty(firstDisplayableSenderEmail)) { 386 if (displayableSenderEmails.size() < DividedImageCanvas.MAX_DIVISIONS) { 387 displayableSenderEmails.add(0, firstDisplayableSenderEmail); 388 displayableSenderNames.add(0, firstDisplayableSender); 389 } else { 390 displayableSenderEmails.set(0, firstDisplayableSenderEmail); 391 displayableSenderNames.set(0, firstDisplayableSender); 392 } 393 } 394 } 395 396 static String getMe(boolean useObjectMe) { 397 return useObjectMe ? sMeObjectString : sMeSubjectString; 398 } 399 400 public static SpannableString getFormattedToHeader() { 401 final SpannableString formattedToHeader = new SpannableString(sToHeaderString); 402 final CharacterStyle readStyle = CharacterStyle.wrap(sReadStyleSpan); 403 formattedToHeader.setSpan(readStyle, 0, formattedToHeader.length(), 0); 404 return formattedToHeader; 405 } 406 407 public static SpannableString getSingularDraftString(Context context) { 408 getSenderResources(context, true /* resourceCachingRequired */); 409 final SpannableString formattedDraftString = new SpannableString(sDraftSingularString); 410 final CharacterStyle readStyle = CharacterStyle.wrap(sDraftsStyleSpan); 411 formattedDraftString.setSpan(readStyle, 0, formattedDraftString.length(), 0); 412 return formattedDraftString; 413 } 414 415 private static void clearResourceCache() { 416 sDraftSingularString = null; 417 } 418 } 419