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