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