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.util.LruCache;
28import android.util.Pair;
29
30import com.android.mail.R;
31import com.android.mail.providers.Conversation;
32import com.android.mail.providers.Folder;
33import com.android.mail.providers.ParticipantInfo;
34import com.android.mail.providers.UIProvider;
35import com.android.mail.utils.FolderUri;
36import com.google.common.annotations.VisibleForTesting;
37import com.google.common.base.Objects;
38
39import java.util.ArrayList;
40import 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 */
47public 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