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.Context; 21 import android.graphics.Bitmap; 22 import android.text.SpannableString; 23 import android.text.SpannableStringBuilder; 24 import android.text.StaticLayout; 25 import android.text.TextUtils; 26 import android.text.format.DateUtils; 27 import android.text.style.CharacterStyle; 28 import android.util.LruCache; 29 import android.util.Pair; 30 31 import com.android.mail.R; 32 import com.android.mail.providers.Conversation; 33 import com.android.mail.providers.Folder; 34 import com.android.mail.providers.MessageInfo; 35 import com.android.mail.providers.UIProvider; 36 import com.android.mail.utils.FolderUri; 37 import com.google.common.annotations.VisibleForTesting; 38 import com.google.common.base.Objects; 39 import com.google.common.collect.Lists; 40 41 import java.util.ArrayList; 42 import java.util.List; 43 44 /** 45 * This is the view model for the conversation header. It includes all the 46 * information needed to layout a conversation header view. Each view model is 47 * associated with a conversation and is cached to improve the relayout time. 48 */ 49 public class ConversationItemViewModel { 50 private static final int MAX_CACHE_SIZE = 100; 51 52 int fontColor; 53 @VisibleForTesting 54 static LruCache<Pair<String, Long>, ConversationItemViewModel> sConversationHeaderMap 55 = new LruCache<Pair<String, Long>, ConversationItemViewModel>(MAX_CACHE_SIZE); 56 57 /** 58 * The Folder associated with the cache of models. 59 */ 60 private static Folder sCachedModelsFolder; 61 62 // The hashcode used to detect if the conversation has changed. 63 private int mDataHashCode; 64 private int mLayoutHashCode; 65 66 // Unread 67 public boolean unread; 68 69 // Date 70 CharSequence dateText; 71 public CharSequence dateOverrideText; 72 73 // Personal level 74 Bitmap personalLevelBitmap; 75 76 public Bitmap infoIcon; 77 78 // Paperclip 79 Bitmap paperclip; 80 81 /** If <code>true</code>, we will not apply any formatting to {@link #sendersText}. */ 82 public boolean preserveSendersText = false; 83 84 // Senders 85 public String sendersText; 86 87 // A list of all the fragments that cover sendersText 88 final ArrayList<SenderFragment> senderFragments; 89 90 SpannableStringBuilder sendersDisplayText; 91 StaticLayout sendersDisplayLayout; 92 93 boolean hasDraftMessage; 94 95 // Attachment Previews overflow 96 String overflowText; 97 98 // View Width 99 public int viewWidth; 100 101 // Standard scaled dimen used to detect if the scale of text has changed. 102 @Deprecated 103 public int standardScaledDimen; 104 105 public long maxMessageId; 106 107 public int gadgetMode; 108 109 public Conversation conversation; 110 111 public ConversationItemView.ConversationItemFolderDisplayer folderDisplayer; 112 113 public boolean hasBeenForwarded; 114 115 public boolean hasBeenRepliedTo; 116 117 public boolean isInvite; 118 119 public ArrayList<SpannableString> styledSenders; 120 121 public SpannableStringBuilder styledSendersString; 122 123 public SpannableStringBuilder messageInfoString; 124 125 public int styledMessageInfoStringOffset; 126 127 private String mContentDescription; 128 129 /** 130 * Email address corresponding to the senders that will be displayed in the 131 * senders field. 132 */ 133 public ArrayList<String> displayableSenderEmails; 134 135 /** 136 * Display names corresponding to the email address corresponding to the 137 * senders that will be displayed in the senders field. 138 */ 139 public ArrayList<String> displayableSenderNames; 140 141 /** 142 * Returns the view model for a conversation. If the model doesn't exist for this conversation 143 * null is returned. Note: this should only be called from the UI thread. 144 * 145 * @param account the account contains this conversation 146 * @param conversationId the Id of this conversation 147 * @return the view model for this conversation, or null 148 */ 149 @VisibleForTesting 150 static ConversationItemViewModel forConversationIdOrNull( 151 String account, long conversationId) { 152 final Pair<String, Long> key = new Pair<String, Long>(account, conversationId); 153 synchronized(sConversationHeaderMap) { 154 return sConversationHeaderMap.get(key); 155 } 156 } 157 158 static ConversationItemViewModel forConversation(String account, Conversation conv) { 159 ConversationItemViewModel header = ConversationItemViewModel.forConversationId(account, 160 conv.id); 161 header.conversation = conv; 162 header.unread = !conv.read; 163 header.hasBeenForwarded = 164 (conv.convFlags & UIProvider.ConversationFlags.FORWARDED) 165 == UIProvider.ConversationFlags.FORWARDED; 166 header.hasBeenRepliedTo = 167 (conv.convFlags & UIProvider.ConversationFlags.REPLIED) 168 == UIProvider.ConversationFlags.REPLIED; 169 header.isInvite = 170 (conv.convFlags & UIProvider.ConversationFlags.CALENDAR_INVITE) 171 == UIProvider.ConversationFlags.CALENDAR_INVITE; 172 return header; 173 } 174 175 /** 176 * Returns the view model for a conversation. If this is the first time 177 * call, a new view model will be returned. Note: this should only be called 178 * from the UI thread. 179 * 180 * @param account the account contains this conversation 181 * @param conversationId the Id of this conversation 182 * @param cursor the cursor to use in populating/ updating the model. 183 * @return the view model for this conversation 184 */ 185 static ConversationItemViewModel forConversationId(String account, long conversationId) { 186 synchronized(sConversationHeaderMap) { 187 ConversationItemViewModel header = 188 forConversationIdOrNull(account, conversationId); 189 if (header == null) { 190 final Pair<String, Long> key = new Pair<String, Long>(account, conversationId); 191 header = new ConversationItemViewModel(); 192 sConversationHeaderMap.put(key, header); 193 } 194 return header; 195 } 196 } 197 198 public ConversationItemViewModel() { 199 senderFragments = Lists.newArrayList(); 200 } 201 202 /** 203 * Adds a sender fragment. 204 * 205 * @param start the start position of this fragment 206 * @param end the start position of this fragment 207 * @param style the style of this fragment 208 * @param isFixed whether this fragment is fixed or not 209 */ 210 void addSenderFragment(int start, int end, CharacterStyle style, boolean isFixed) { 211 SenderFragment senderFragment = new SenderFragment(start, end, sendersText, style, isFixed); 212 senderFragments.add(senderFragment); 213 } 214 215 /** 216 * Returns the hashcode to compare if the data in the header is valid. 217 */ 218 private static int getHashCode(CharSequence dateText, Object convInfo, 219 List<Folder> rawFolders, boolean starred, boolean read, int priority, 220 int sendingState) { 221 if (dateText == null) { 222 return -1; 223 } 224 return Objects.hashCode(convInfo, dateText, rawFolders, starred, read, priority, 225 sendingState); 226 } 227 228 /** 229 * Returns the layout hashcode to compare to see if the layout state has changed. 230 */ 231 private int getLayoutHashCode() { 232 return Objects.hashCode(mDataHashCode, viewWidth, standardScaledDimen, gadgetMode); 233 } 234 235 private Object getConvInfo() { 236 return conversation.conversationInfo != null ? 237 conversation.conversationInfo : conversation.getSnippet(); 238 } 239 240 /** 241 * Marks this header as having valid data and layout. 242 */ 243 void validate() { 244 mDataHashCode = getHashCode(dateText, 245 getConvInfo(), conversation.getRawFolders(), conversation.starred, 246 conversation.read, conversation.priority, conversation.sendingState); 247 mLayoutHashCode = getLayoutHashCode(); 248 } 249 250 /** 251 * Returns if the data in this model is valid. 252 */ 253 boolean isDataValid() { 254 return mDataHashCode == getHashCode(dateText, 255 getConvInfo(), conversation.getRawFolders(), conversation.starred, 256 conversation.read, conversation.priority, conversation.sendingState); 257 } 258 259 /** 260 * Returns if the layout in this model is valid. 261 */ 262 boolean isLayoutValid() { 263 return isDataValid() && mLayoutHashCode == getLayoutHashCode(); 264 } 265 266 /** 267 * Describes the style of a Senders fragment. 268 */ 269 static class SenderFragment { 270 // Indices that determine which substring of mSendersText we are 271 // displaying. 272 int start; 273 int end; 274 275 // The style to apply to the TextPaint object. 276 CharacterStyle style; 277 278 // Width of the fragment. 279 int width; 280 281 // Ellipsized text. 282 String ellipsizedText; 283 284 // Whether the fragment is fixed or not. 285 boolean isFixed; 286 287 // Should the fragment be displayed or not. 288 boolean shouldDisplay; 289 290 SenderFragment(int start, int end, CharSequence sendersText, CharacterStyle style, 291 boolean isFixed) { 292 this.start = start; 293 this.end = end; 294 this.style = style; 295 this.isFixed = isFixed; 296 } 297 } 298 299 300 /** 301 * Reset the content description; enough content has changed that we need to 302 * regenerate it. 303 */ 304 public void resetContentDescription() { 305 mContentDescription = null; 306 } 307 308 /** 309 * Get conversation information to use for accessibility. 310 */ 311 public CharSequence getContentDescription(Context context) { 312 if (mContentDescription == null) { 313 // If any are unread, get the first unread sender. 314 // If all are unread, get the first sender. 315 // If all are read, get the last sender. 316 String sender = ""; 317 if (conversation.conversationInfo != null) { 318 String lastSender = ""; 319 int last = conversation.conversationInfo.messageInfos != null ? 320 conversation.conversationInfo.messageInfos.size() - 1 : -1; 321 if (last != -1) { 322 lastSender = conversation.conversationInfo.messageInfos.get(last).sender; 323 } 324 if (conversation.read) { 325 sender = TextUtils.isEmpty(lastSender) ? 326 SendersView.getMe(context) : lastSender; 327 } else { 328 MessageInfo firstUnread = null; 329 for (MessageInfo m : conversation.conversationInfo.messageInfos) { 330 if (!m.read) { 331 firstUnread = m; 332 break; 333 } 334 } 335 if (firstUnread != null) { 336 sender = TextUtils.isEmpty(firstUnread.sender) ? 337 SendersView.getMe(context) : firstUnread.sender; 338 } 339 } 340 if (TextUtils.isEmpty(sender)) { 341 // Just take the last sender 342 sender = lastSender; 343 } 344 } 345 boolean isToday = DateUtils.isToday(conversation.dateMs); 346 String date = DateUtils.getRelativeTimeSpanString(context, conversation.dateMs) 347 .toString(); 348 String readString = context.getString( 349 conversation.read ? R.string.read_string : R.string.unread_string); 350 int res = isToday ? R.string.content_description_today : R.string.content_description; 351 mContentDescription = context.getString(res, sender, 352 conversation.subject, conversation.getSnippet(), date, readString); 353 } 354 return mContentDescription; 355 } 356 357 /** 358 * Clear cached header model objects when accessibility changes. 359 */ 360 361 public static void onAccessibilityUpdated() { 362 sConversationHeaderMap.evictAll(); 363 } 364 365 /** 366 * Clear cached header model objects when the folder changes. 367 */ 368 public static void onFolderUpdated(Folder folder) { 369 final FolderUri old = sCachedModelsFolder != null 370 ? sCachedModelsFolder.folderUri : FolderUri.EMPTY; 371 final FolderUri newUri = folder != null ? folder.folderUri : FolderUri.EMPTY; 372 if (!old.equals(newUri)) { 373 sCachedModelsFolder = folder; 374 sConversationHeaderMap.evictAll(); 375 } 376 } 377 }