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}