ConversationViewFragment.java revision 3bcf180f8104bc27319086a9a6ece5a3c2917c37
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.ui;
19
20import android.animation.Animator;
21import android.animation.AnimatorInflater;
22import android.animation.Animator.AnimatorListener;
23import android.app.Activity;
24import android.app.Fragment;
25import android.app.LoaderManager;
26import android.content.ActivityNotFoundException;
27import android.content.Context;
28import android.content.CursorLoader;
29import android.content.Intent;
30import android.content.Loader;
31import android.content.res.Resources;
32import android.database.Cursor;
33import android.database.DataSetObservable;
34import android.database.DataSetObserver;
35import android.net.Uri;
36import android.os.AsyncTask;
37import android.os.Bundle;
38import android.os.Handler;
39import android.os.SystemClock;
40import android.provider.Browser;
41import android.text.Spannable;
42import android.text.SpannableStringBuilder;
43import android.text.TextUtils;
44import android.text.style.ForegroundColorSpan;
45import android.view.LayoutInflater;
46import android.view.Menu;
47import android.view.MenuInflater;
48import android.view.MenuItem;
49import android.view.View;
50import android.view.ViewGroup;
51import android.webkit.ConsoleMessage;
52import android.webkit.CookieManager;
53import android.webkit.CookieSyncManager;
54import android.webkit.WebChromeClient;
55import android.webkit.WebSettings;
56import android.webkit.WebView;
57import android.webkit.WebViewClient;
58import android.widget.TextView;
59
60import com.android.mail.ContactInfo;
61import com.android.mail.ContactInfoSource;
62import com.android.mail.FormattedDateBuilder;
63import com.android.mail.R;
64import com.android.mail.SenderInfoLoader;
65import com.android.mail.browse.ConversationContainer;
66import com.android.mail.browse.ConversationOverlayItem;
67import com.android.mail.browse.ConversationViewAdapter;
68import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController;
69import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem;
70import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
71import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
72import com.android.mail.browse.ConversationViewHeader;
73import com.android.mail.browse.ConversationWebView;
74import com.android.mail.browse.MessageCursor;
75import com.android.mail.browse.MessageCursor.ConversationController;
76import com.android.mail.browse.MessageCursor.ConversationMessage;
77import com.android.mail.browse.MessageHeaderView;
78import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
79import com.android.mail.browse.SuperCollapsedBlock;
80import com.android.mail.browse.WebViewContextMenu;
81import com.android.mail.providers.Account;
82import com.android.mail.providers.AccountObserver;
83import com.android.mail.providers.Address;
84import com.android.mail.providers.Conversation;
85import com.android.mail.providers.Folder;
86import com.android.mail.providers.ListParams;
87import com.android.mail.providers.Message;
88import com.android.mail.providers.UIProvider;
89import com.android.mail.providers.UIProvider.AccountCapabilities;
90import com.android.mail.providers.UIProvider.FolderCapabilities;
91import com.android.mail.providers.UIProvider.ViewProxyExtras;
92import com.android.mail.ui.ConversationViewState.ExpansionState;
93import com.android.mail.utils.LogTag;
94import com.android.mail.utils.LogUtils;
95import com.android.mail.utils.Utils;
96import com.google.common.collect.ImmutableMap;
97import com.google.common.collect.Lists;
98import com.google.common.collect.Maps;
99import com.google.common.collect.Sets;
100
101import java.util.Arrays;
102import java.util.List;
103import java.util.Map;
104import java.util.Set;
105
106
107/**
108 * The conversation view UI component.
109 */
110public final class ConversationViewFragment extends Fragment implements
111        ConversationViewHeader.ConversationViewHeaderCallbacks,
112        MessageHeaderViewCallbacks,
113        SuperCollapsedBlock.OnClickListener,
114        ConversationController,
115        ConversationAccountController {
116
117    private static final String LOG_TAG = LogTag.getLogTag();
118    public static final String LAYOUT_TAG = "ConvLayout";
119
120    private static final int MESSAGE_LOADER_ID = 0;
121    private static final int CONTACT_LOADER_ID = 1;
122
123    /** Do not auto load data when create this {@link ConversationView}. */
124    public static final int NO_AUTO_LOAD = 0;
125    /** Auto load data but do not show any animation. */
126    public static final int AUTO_LOAD_BACKGROUND = 1;
127    /** Auto load data and show animation. */
128    public static final int AUTO_LOAD_VISIBLE = 2;
129
130    private ControllableActivity mActivity;
131
132    private Context mContext;
133
134    private Conversation mConversation;
135
136    private ConversationContainer mConversationContainer;
137
138    private Account mAccount;
139
140    private ConversationWebView mWebView;
141
142    private View mNewMessageBar;
143
144    private View mBackgroundView;
145
146    private View mInfoView;
147
148    private TextView mSendersView;
149
150    private TextView mSubjectView;
151
152    private View mProgressView;
153
154    private HtmlConversationTemplates mTemplates;
155
156    private String mBaseUri;
157
158    private final Handler mHandler = new Handler();
159
160    private final MailJsBridge mJsBridge = new MailJsBridge();
161
162    private final WebViewClient mWebViewClient = new ConversationWebViewClient();
163
164    private ConversationViewAdapter mAdapter;
165    private MessageCursor mCursor;
166
167    private boolean mViewsCreated;
168
169    private MenuItem mChangeFoldersMenuItem;
170    /**
171     * Folder is used to help determine valid menu actions for this conversation.
172     */
173    private Folder mFolder;
174
175    private final Map<String, Address> mAddressCache = Maps.newHashMap();
176
177    /**
178     * Temporary string containing the message bodies of the messages within a super-collapsed
179     * block, for one-time use during block expansion. We cannot easily pass the body HTML
180     * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it
181     * using {@link MailJsBridge}.
182     */
183    private String mTempBodiesHtml;
184
185    private boolean mUserVisible;
186
187    private int  mMaxAutoLoadMessages;
188
189    private boolean mDeferredConversationLoad;
190
191    /**
192     * Handles a deferred 'mark read' operation, necessary when the conversation view has finished
193     * loading before the conversation cursor. Normally null unless this situation occurs.
194     * When finally able to 'mark read', this observer will also be unregistered and cleaned up.
195     */
196    private MarkReadObserver mMarkReadObserver;
197
198    /**
199     * Parcelable state of the conversation view. Can safely be used without null checking any time
200     * after {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)}.
201     */
202    private ConversationViewState mViewState;
203
204    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
205    private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks();
206
207    private final AccountObserver mAccountObserver = new AccountObserver() {
208        @Override
209        public void onChanged(Account newAccount) {
210            mAccount = newAccount;
211
212            // settings may have been updated; refresh views that are known to depend on settings
213            mConversationContainer.getSnapHeader().onAccountChanged();
214            mAdapter.notifyDataSetChanged();
215        }
216    };
217    private boolean mEnableContentReadySignal;
218
219    private static final String ARG_ACCOUNT = "account";
220    public static final String ARG_CONVERSATION = "conversation";
221    private static final String ARG_FOLDER = "folder";
222    private static final String BUNDLE_VIEW_STATE = "viewstate";
223    private static int sSubjectColor = Integer.MIN_VALUE;
224    private static int sSnippetColor = Integer.MIN_VALUE;
225
226    private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
227    private static final boolean DISABLE_OFFSCREEN_LOADING = false;
228    protected static final String AUTO_LOAD_KEY = "auto-load";
229
230    /**
231     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
232     */
233    public ConversationViewFragment() {
234        super();
235    }
236
237    /**
238     * Creates a new instance of {@link ConversationViewFragment}, initialized
239     * to display a conversation with other parameters inherited/copied from an existing bundle,
240     * typically one created using {@link #makeBasicArgs}.
241     */
242    public static ConversationViewFragment newInstance(Bundle existingArgs,
243            Conversation conversation) {
244        ConversationViewFragment f = new ConversationViewFragment();
245        Bundle args = new Bundle(existingArgs);
246        args.putParcelable(ARG_CONVERSATION, conversation);
247        f.setArguments(args);
248        return f;
249    }
250
251    public static Bundle makeBasicArgs(Account account, Folder folder) {
252        Bundle args = new Bundle();
253        args.putParcelable(ARG_ACCOUNT, account);
254        args.putParcelable(ARG_FOLDER, folder);
255        return args;
256    }
257
258    @Override
259    public void onActivityCreated(Bundle savedInstanceState) {
260        LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s subj=%s", this,
261                mConversation.subject);
262        super.onActivityCreated(savedInstanceState);
263        // Strictly speaking, we get back an android.app.Activity from getActivity. However, the
264        // only activity creating a ConversationListContext is a MailActivity which is of type
265        // ControllableActivity, so this cast should be safe. If this cast fails, some other
266        // activity is creating ConversationListFragments. This activity must be of type
267        // ControllableActivity.
268        final Activity activity = getActivity();
269        if (!(activity instanceof ControllableActivity)) {
270            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
271                    + "create it. Cannot proceed.");
272        }
273        mActivity = (ControllableActivity) activity;
274        mContext = mActivity.getApplicationContext();
275        if (mActivity.isFinishing()) {
276            // Activity is finishing, just bail.
277            return;
278        }
279        mTemplates = new HtmlConversationTemplates(mContext);
280        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
281
282        final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(mContext);
283
284        mAdapter = new ConversationViewAdapter(mActivity.getActivityContext(), this,
285                getLoaderManager(), this, mContactLoaderCallbacks, this, this, mAddressCache,
286                dateBuilder);
287        mConversationContainer.setOverlayAdapter(mAdapter);
288
289        // set up snap header (the adapter usually does this with the other ones)
290        final MessageHeaderView snapHeader = mConversationContainer.getSnapHeader();
291        snapHeader.initialize(dateBuilder, this, mAddressCache);
292        snapHeader.setCallbacks(this);
293        snapHeader.setContactInfoSource(mContactLoaderCallbacks);
294
295        mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages);
296
297        mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(activity));
298
299        showConversation();
300
301        if (mConversation.conversationBaseUri != null &&
302                !TextUtils.isEmpty(mConversation.conversationCookie)) {
303            // Set the cookie for this base url
304            new SetCookieTask(mConversation.conversationBaseUri.toString(),
305                    mConversation.conversationCookie).execute();
306        }
307    }
308
309    @Override
310    public void onCreate(Bundle savedState) {
311        super.onCreate(savedState);
312
313        final Bundle args = getArguments();
314        mAccount = args.getParcelable(ARG_ACCOUNT);
315        mConversation = args.getParcelable(ARG_CONVERSATION);
316        mFolder = args.getParcelable(ARG_FOLDER);
317        // Since the uri specified in the conversation base uri may not be unique, we specify a
318        // base uri that us guaranteed to be unique for this conversation.
319        mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
320        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
321
322        // Not really, we just want to get a crack to store a reference to the change_folder item
323        setHasOptionsMenu(true);
324    }
325
326    private CharSequence createSubjectSnippet(CharSequence subject, CharSequence snippet) {
327        SpannableStringBuilder subjectText = new SpannableStringBuilder(mContext.getString(
328                R.string.subject_and_snippet, subject, snippet));
329        ensureSubjectSnippetColors();
330        int snippetStart = 0;
331        int fontColor = sSubjectColor;
332        if (subject != null) {
333            subjectText.setSpan(new ForegroundColorSpan(fontColor), 0, subject.length(),
334                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
335            snippetStart = subject.length() + 1;
336        }
337        if (snippet != null) {
338            fontColor = sSnippetColor;
339            subjectText.setSpan(new ForegroundColorSpan(fontColor), snippetStart, subjectText
340                    .length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
341        }
342        return subjectText;
343    }
344
345    private void ensureSubjectSnippetColors() {
346        if (sSubjectColor == Integer.MIN_VALUE) {
347            Resources res = mContext.getResources();
348            sSubjectColor = res.getColor(R.color.subject_text_color_read);
349            sSnippetColor = res.getColor(R.color.snippet_text_color_read);
350        }
351    }
352
353    @Override
354    public View onCreateView(LayoutInflater inflater,
355            ViewGroup container, Bundle savedInstanceState) {
356
357        if (savedInstanceState != null) {
358            mViewState = savedInstanceState.getParcelable(BUNDLE_VIEW_STATE);
359        } else {
360            mViewState = new ConversationViewState();
361        }
362
363        View rootView = inflater.inflate(R.layout.conversation_view, container, false);
364        mConversationContainer = (ConversationContainer) rootView
365                .findViewById(R.id.conversation_container);
366
367        mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar);
368        mNewMessageBar.setOnClickListener(new View.OnClickListener() {
369            @Override
370            public void onClick(View v) {
371                onNewMessageBarClick();
372            }
373        });
374
375        mBackgroundView = rootView.findViewById(R.id.background_view);
376        mInfoView = rootView.findViewById(R.id.info_view);
377        mSendersView = (TextView) rootView.findViewById(R.id.senders_view);
378        mSubjectView = (TextView) rootView.findViewById(R.id.info_subject_view);
379        mProgressView = rootView.findViewById(R.id.loading_progress);
380
381        mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
382
383        mWebView.addJavascriptInterface(mJsBridge, "mail");
384        // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
385        // Below JB, try to speed up initial render by having the webview do supplemental draws to
386        // custom a software canvas.
387        mEnableContentReadySignal = Utils.isRunningJellybeanOrLater();
388        mWebView.setWebViewClient(mWebViewClient);
389        mWebView.setWebChromeClient(new WebChromeClient() {
390            @Override
391            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
392                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
393                        consoleMessage.sourceId(), consoleMessage.lineNumber());
394                return true;
395            }
396        });
397        mWebView.setContentSizeChangeListener(new ConversationWebView.ContentSizeChangeListener() {
398            @Override
399            public void onHeightChange(int h) {
400                // When WebKit says the DOM height has changed, re-measure bodies and re-position
401                // their headers.
402                // This is separate from the typical JavaScript DOM change listeners because
403                // cases like NARROW_COLUMNS text reflow do not trigger DOM events.
404                mWebView.loadUrl("javascript:measurePositions();");
405            }
406        });
407
408        final WebSettings settings = mWebView.getSettings();
409
410        settings.setJavaScriptEnabled(true);
411        settings.setUseWideViewPort(true);
412        settings.setLoadWithOverviewMode(true);
413
414        settings.setSupportZoom(true);
415        settings.setBuiltInZoomControls(true);
416        settings.setDisplayZoomControls(false);
417
418        final float fontScale = getResources().getConfiguration().fontScale;
419        final int desiredFontSizePx = getResources()
420                .getInteger(R.integer.conversation_desired_font_size_px);
421        final int unstyledFontSizePx = getResources()
422                .getInteger(R.integer.conversation_unstyled_font_size_px);
423
424        int textZoom = settings.getTextZoom();
425        // apply a correction to the default body text style to get regular text to the size we want
426        textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx;
427        // then apply any system font scaling
428        textZoom = (int) (textZoom * fontScale);
429        settings.setTextZoom(textZoom);
430
431        mViewsCreated = true;
432
433        return rootView;
434    }
435
436    @Override
437    public void onResume() {
438        super.onResume();
439
440        // Hacky workaround for http://b/6946182
441        Utils.fixSubTreeLayoutIfOrphaned(getView(), "ConversationViewFragment");
442    }
443
444    @Override
445    public void onSaveInstanceState(Bundle outState) {
446        if (mViewState != null) {
447            outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
448        }
449    }
450
451    @Override
452    public void onDestroyView() {
453        super.onDestroyView();
454        mConversationContainer.setOverlayAdapter(null);
455        mAdapter = null;
456        if (mMarkReadObserver != null) {
457            mActivity.getConversationUpdater().unregisterConversationListObserver(
458                    mMarkReadObserver);
459            mMarkReadObserver = null;
460        }
461        mViewsCreated = false;
462        mAccountObserver.unregisterAndDestroy();
463    }
464
465    @Override
466    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
467        super.onCreateOptionsMenu(menu, inflater);
468
469        mChangeFoldersMenuItem = menu.findItem(R.id.change_folder);
470    }
471
472    @Override
473    public void onPrepareOptionsMenu(Menu menu) {
474        super.onPrepareOptionsMenu(menu);
475        final boolean showMarkImportant = !mConversation.isImportant();
476        Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant
477                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
478        Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant
479                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
480        final boolean showDelete = mFolder != null &&
481                mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE);
482        Utils.setMenuItemVisibility(menu, R.id.delete, showDelete);
483        // We only want to show the discard drafts menu item if we are not showing the delete menu
484        // item, and the current folder is a draft folder and the account supports discarding
485        // drafts for a conversation
486        final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() &&
487                mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS);
488        Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts);
489        final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE)
490                && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE)
491                && !mFolder.isTrash();
492        Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible);
493        Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null
494                && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)
495                && !mFolder.isProviderFolder());
496        final MenuItem removeFolder = menu.findItem(R.id.remove_folder);
497        if (removeFolder != null) {
498            removeFolder.setTitle(getString(R.string.remove_folder, mFolder.name));
499        }
500        Utils.setMenuItemVisibility(menu, R.id.report_spam,
501                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
502                        && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM)
503                        && !mConversation.spam);
504        Utils.setMenuItemVisibility(menu, R.id.mark_not_spam,
505                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
506                        && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM)
507                        && mConversation.spam);
508        Utils.setMenuItemVisibility(menu, R.id.report_phishing,
509                mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null
510                        && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING)
511                        && !mConversation.phishing);
512        Utils.setMenuItemVisibility(menu, R.id.mute,
513                        mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null
514                        && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)
515                        && !mConversation.muted);
516    }
517
518    @Override
519    public boolean onOptionsItemSelected(MenuItem item) {
520        boolean handled = false;
521
522        switch (item.getItemId()) {
523            case R.id.inside_conversation_unread:
524                markUnread();
525                handled = true;
526                break;
527        }
528
529        return handled;
530    }
531
532    @Override
533    public ConversationUpdater getListController() {
534        final ControllableActivity activity = (ControllableActivity) getActivity();
535        return activity != null ? activity.getConversationUpdater() : null;
536    }
537
538    @Override
539    public MessageCursor getMessageCursor() {
540        return mCursor;
541    }
542
543    private void markUnread() {
544        // Ignore unsafe calls made after a fragment is detached from an activity
545        final ControllableActivity activity = (ControllableActivity) getActivity();
546        if (activity == null) {
547            LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
548            return;
549        }
550
551        if (mViewState == null) {
552            LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
553                    mConversation.id);
554            return;
555        }
556        activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
557                mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
558    }
559
560    /**
561     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
562     * reliability on older platforms.
563     */
564    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
565        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
566
567        if (mUserVisible != isVisibleToUser) {
568            mUserVisible = isVisibleToUser;
569
570            if (isVisibleToUser && mViewsCreated) {
571
572                if (mCursor == null && mDeferredConversationLoad) {
573                    // load
574                    LogUtils.v(LOG_TAG, "Fragment is now user-visible, showing conversation: %s",
575                            mConversation.uri);
576                    showConversation();
577                    mDeferredConversationLoad = false;
578                } else {
579                    onConversationSeen();
580                }
581
582            }
583        }
584    }
585
586    /**
587     * Handles a request to show a new conversation list, either from a search
588     * query or for viewing a folder. This will initiate a data load, and hence
589     * must be called on the UI thread.
590     */
591    private void showConversation() {
592        final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
593                || (mConversation.isRemote
594                        || mConversation.getNumMessages() > mMaxAutoLoadMessages);
595        if (!mUserVisible && disableOffscreenLoading) {
596            LogUtils.v(LOG_TAG, "Fragment not user-visible, not showing conversation: %s",
597                    mConversation.uri);
598            mDeferredConversationLoad = true;
599            return;
600        }
601        LogUtils.v(LOG_TAG,
602                "Fragment is short or user-visible, immediately rendering conversation: %s",
603                mConversation.uri);
604        mWebView.setVisibility(View.VISIBLE);
605        getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, mMessageLoaderCallbacks);
606        if (mUserVisible) {
607            final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
608            if (sdc != null) {
609                sdc.setSubject(mConversation.subject);
610            }
611        }
612        // TODO(mindyp): don't show loading status for a previously rendered
613        // conversation. Ielieve this is better done by making sure don't show loading status
614        // until XX ms have passed without loading completed.
615        showLoadingStatus();
616    }
617
618    public Conversation getConversation() {
619        return mConversation;
620    }
621
622    private void renderConversation(MessageCursor messageCursor) {
623        final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal);
624
625        if (DEBUG_DUMP_CONVERSATION_HTML) {
626            java.io.FileWriter fw = null;
627            try {
628                fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id
629                        + ".html");
630                fw.write(convHtml);
631            } catch (java.io.IOException e) {
632                e.printStackTrace();
633            } finally {
634                if (fw != null) {
635                    try {
636                        fw.close();
637                    } catch (java.io.IOException e) {
638                        e.printStackTrace();
639                    }
640                }
641            }
642        }
643
644        mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
645        mCursor = messageCursor;
646    }
647
648    /**
649     * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
650     * conversation header), and return an HTML document with spacer divs inserted for all overlays.
651     *
652     */
653    private String renderMessageBodies(MessageCursor messageCursor,
654            boolean enableContentReadySignal) {
655        int pos = -1;
656
657        LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this);
658        boolean allowNetworkImages = false;
659
660        // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
661
662        // Walk through the cursor and build up an overlay adapter as you go.
663        // Each overlay has an entry in the adapter for easy scroll handling in the container.
664        // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
665        // When adding adapter items, also add their heights to help the container later determine
666        // overlay dimensions.
667
668        // When re-rendering, prevent ConversationContainer from laying out overlays until after
669        // the new spacers are positioned by WebView.
670        mConversationContainer.invalidateSpacerGeometry();
671
672        mAdapter.clear();
673
674        // re-evaluate the message parts of the view state, since the messages may have changed
675        // since the previous render
676        final ConversationViewState prevState = mViewState;
677        mViewState = new ConversationViewState(prevState);
678
679        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
680        // a pixel is an mdpi pixel, unless you set device-dpi.
681
682        // add a single conversation header item
683        final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
684        final int convHeaderPx = measureOverlayHeight(convHeaderPos);
685
686        final int sideMarginPx = getResources().getDimensionPixelOffset(
687                R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset(
688                R.dimen.conversation_message_content_margin_side);
689
690        mTemplates.startConversation(mWebView.screenPxToWebPx(sideMarginPx),
691                mWebView.screenPxToWebPx(convHeaderPx));
692
693        int collapsedStart = -1;
694        ConversationMessage prevCollapsedMsg = null;
695        boolean prevSafeForImages = false;
696
697        while (messageCursor.moveToPosition(++pos)) {
698            final ConversationMessage msg = messageCursor.getMessage();
699
700            // TODO: save/restore 'show pics' state
701            final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
702            allowNetworkImages |= safeForImages;
703
704            final Integer savedExpanded = prevState.getExpansionState(msg);
705            final int expandedState;
706            if (savedExpanded != null) {
707                if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) {
708                    // override saved state when this is now the new last message
709                    // this happens to the second-to-last message when you discard a draft
710                    expandedState = ExpansionState.EXPANDED;
711                } else {
712                    expandedState = savedExpanded;
713                }
714            } else {
715                // new messages that are not expanded default to being eligible for super-collapse
716                expandedState = (!msg.read || msg.starred || messageCursor.isLast()) ?
717                        ExpansionState.EXPANDED : ExpansionState.SUPER_COLLAPSED;
718            }
719            mViewState.setExpansionState(msg, expandedState);
720
721            // save off "read" state from the cursor
722            // later, the view may not match the cursor (e.g. conversation marked read on open)
723            // however, if a previous state indicated this message was unread, trust that instead
724            // so "mark unread" marks all originally unread messages
725            mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg));
726
727            // We only want to consider this for inclusion in the super collapsed block if
728            // 1) The we don't have previous state about this message  (The first time that the
729            //    user opens a conversation)
730            // 2) The previously saved state for this message indicates that this message is
731            //    in the super collapsed block.
732            if (ExpansionState.isSuperCollapsed(expandedState)) {
733                // contribute to a super-collapsed block that will be emitted just before the
734                // next expanded header
735                if (collapsedStart < 0) {
736                    collapsedStart = pos;
737                }
738                prevCollapsedMsg = msg;
739                prevSafeForImages = safeForImages;
740                continue;
741            }
742
743            // resolve any deferred decisions on previous collapsed items
744            if (collapsedStart >= 0) {
745                if (pos - collapsedStart == 1) {
746                    // special-case for a single collapsed message: no need to super-collapse it
747                    renderMessage(prevCollapsedMsg, false /* expanded */,
748                            prevSafeForImages);
749                } else {
750                    renderSuperCollapsedBlock(collapsedStart, pos - 1);
751                }
752                prevCollapsedMsg = null;
753                collapsedStart = -1;
754            }
755
756            renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages);
757        }
758
759        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
760
761        // If the conversation has specified a base uri, use it here, use mBaseUri
762        final String conversationBaseUri = mConversation.conversationBaseUri != null ?
763                mConversation.conversationBaseUri.toString() : mBaseUri;
764        return mTemplates.endConversation(mBaseUri, conversationBaseUri, 320,
765                mWebView.getViewportWidth(), enableContentReadySignal);
766    }
767
768    private void renderSuperCollapsedBlock(int start, int end) {
769        final int blockPos = mAdapter.addSuperCollapsedBlock(start, end);
770        final int blockPx = measureOverlayHeight(blockPos);
771        mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
772    }
773
774    private void renderMessage(ConversationMessage msg, boolean expanded,
775            boolean safeForImages) {
776        final int headerPos = mAdapter.addMessageHeader(msg, expanded);
777        final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
778
779        final int footerPos = mAdapter.addMessageFooter(headerItem);
780
781        // Measure item header and footer heights to allocate spacers in HTML
782        // But since the views themselves don't exist yet, render each item temporarily into
783        // a host view for measurement.
784        final int headerPx = measureOverlayHeight(headerPos);
785        final int footerPx = measureOverlayHeight(footerPos);
786
787        mTemplates.appendMessageHtml(msg, expanded, safeForImages,
788                mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
789    }
790
791    private String renderCollapsedHeaders(MessageCursor cursor,
792            SuperCollapsedBlockItem blockToReplace) {
793        final List<ConversationOverlayItem> replacements = Lists.newArrayList();
794
795        mTemplates.reset();
796
797        for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
798            cursor.moveToPosition(i);
799            final ConversationMessage msg = cursor.getMessage();
800            final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg,
801                    false /* expanded */);
802            final MessageFooterItem footer = mAdapter.newMessageFooterItem(header);
803
804            final int headerPx = measureOverlayHeight(header);
805            final int footerPx = measureOverlayHeight(footer);
806
807            mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages,
808                    mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
809            replacements.add(header);
810            replacements.add(footer);
811
812            mViewState.setExpansionState(msg, ExpansionState.COLLAPSED);
813        }
814
815        mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
816
817        return mTemplates.emit();
818    }
819
820    private int measureOverlayHeight(int position) {
821        return measureOverlayHeight(mAdapter.getItem(position));
822    }
823
824    /**
825     * Measure the height of an adapter view by rendering an adapter item into a temporary
826     * host view, and asking the view to immediately measure itself. This method will reuse
827     * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
828     * earlier.
829     * <p>
830     * After measuring the height, this method also saves the height in the
831     * {@link ConversationOverlayItem} for later use in overlay positioning.
832     *
833     * @param convItem adapter item with data to render and measure
834     * @return height of the rendered view in screen px
835     */
836    private int measureOverlayHeight(ConversationOverlayItem convItem) {
837        final int type = convItem.getType();
838
839        final View convertView = mConversationContainer.getScrapView(type);
840        final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
841                true /* measureOnly */);
842        if (convertView == null) {
843            mConversationContainer.addScrapView(type, hostView);
844        }
845
846        final int heightPx = mConversationContainer.measureOverlay(hostView);
847        convItem.setHeight(heightPx);
848        convItem.markMeasurementValid();
849
850        return heightPx;
851    }
852
853    private void onConversationSeen() {
854        // Ignore unsafe calls made after a fragment is detached from an activity
855        final ControllableActivity activity = (ControllableActivity) getActivity();
856        if (activity == null) {
857            LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id);
858            return;
859        }
860
861        mViewState.setInfoForConversation(mConversation);
862
863        // mark viewed/read if not previously marked viewed by this conversation view,
864        // or if unread messages still exist in the message list cursor
865        // we don't want to keep marking viewed on rotation or restore
866        // but we do want future re-renders to mark read (e.g. "New message from X" case)
867        if (!mConversation.isViewed() || (mCursor != null && !mCursor.isConversationRead())) {
868            final ConversationUpdater listController = activity.getConversationUpdater();
869            // The conversation cursor may not have finished loading by now (when launched via
870            // notification), so watch for when it finishes and mark it read then.
871            if (listController.getConversationListCursor() == null) {
872                LogUtils.i(LOG_TAG, "deferring conv mark read on open for id=%d",
873                        mConversation.id);
874                mMarkReadObserver = new MarkReadObserver(listController);
875                listController.registerConversationListObserver(mMarkReadObserver);
876            } else {
877                markReadOnSeen(listController);
878            }
879        }
880
881        activity.getListHandler().onConversationSeen(mConversation);
882    }
883
884    private void markReadOnSeen(ConversationUpdater listController) {
885        // Mark the conversation viewed and read.
886        listController.markConversationsRead(Arrays.asList(mConversation), true /* read */,
887                true /* viewed */);
888
889        // and update the Message objects in the cursor so the next time a cursor update happens
890        // with these messages marked read, we know to ignore it
891        if (mCursor != null) {
892            mCursor.markMessagesRead();
893        }
894    }
895
896    // BEGIN conversation header callbacks
897    @Override
898    public void onFoldersClicked() {
899        if (mChangeFoldersMenuItem == null) {
900            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
901            return;
902        }
903        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
904    }
905
906    @Override
907    public void onConversationViewHeaderHeightChange(int newHeight) {
908        // TODO: propagate the new height to the header's HTML spacer. This can happen when labels
909        // are added/removed
910    }
911
912    @Override
913    public String getSubjectRemainder(String subject) {
914        final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
915        if (sdc == null) {
916            return subject;
917        }
918        return sdc.getUnshownSubject(subject);
919    }
920    // END conversation header callbacks
921
922    // START message header callbacks
923    @Override
924    public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
925        mConversationContainer.invalidateSpacerGeometry();
926
927        // update message HTML spacer height
928        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
929        LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
930                newSpacerHeightPx);
931        mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %d);",
932                mTemplates.getMessageDomId(item.message), h));
933    }
934
935    @Override
936    public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
937        mConversationContainer.invalidateSpacerGeometry();
938
939        // show/hide the HTML message body and update the spacer height
940        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
941        LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
942                item.isExpanded(), h, newSpacerHeightPx);
943        mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %d);",
944                mTemplates.getMessageDomId(item.message), item.isExpanded(), h));
945
946        mViewState.setExpansionState(item.message,
947                item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED);
948    }
949
950    @Override
951    public void showExternalResources(Message msg) {
952        mWebView.getSettings().setBlockNetworkImage(false);
953        mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');");
954    }
955    // END message header callbacks
956
957    @Override
958    public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
959        if (mCursor == null || !mViewsCreated) {
960            return;
961        }
962
963        mTempBodiesHtml = renderCollapsedHeaders(mCursor, item);
964        mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
965    }
966
967    private void showNewMessageNotification(NewMessagesInfo info) {
968        final TextView descriptionView = (TextView) mNewMessageBar.findViewById(
969                R.id.new_message_description);
970        descriptionView.setText(info.getNotificationText());
971        mNewMessageBar.setVisibility(View.VISIBLE);
972    }
973
974    private void onNewMessageBarClick() {
975        mNewMessageBar.setVisibility(View.GONE);
976
977        renderConversation(mCursor); // mCursor is already up-to-date per onLoadFinished()
978    }
979
980    private static class MessageLoader extends CursorLoader {
981        private boolean mDeliveredFirstResults = false;
982        private final Conversation mConversation;
983        private final ConversationController mController;
984
985        public MessageLoader(Context c, Conversation conv, ConversationController controller) {
986            super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
987            mConversation = conv;
988            mController = controller;
989        }
990
991        @Override
992        public Cursor loadInBackground() {
993            return new MessageCursor(super.loadInBackground(), mConversation, mController);
994        }
995
996        @Override
997        public void deliverResult(Cursor result) {
998            // We want to deliver these results, and then we want to make sure that any subsequent
999            // queries do not hit the network
1000            super.deliverResult(result);
1001
1002            if (!mDeliveredFirstResults) {
1003                mDeliveredFirstResults = true;
1004                Uri uri = getUri();
1005
1006                // Create a ListParams that tells the provider to not hit the network
1007                final ListParams listParams =
1008                        new ListParams(ListParams.NO_LIMIT, false /* useNetwork */);
1009
1010                // Build the new uri with this additional parameter
1011                uri = uri.buildUpon().appendQueryParameter(
1012                        UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build();
1013                setUri(uri);
1014            }
1015        }
1016    }
1017
1018    private static int[] parseInts(final String[] stringArray) {
1019        final int len = stringArray.length;
1020        final int[] ints = new int[len];
1021        for (int i = 0; i < len; i++) {
1022            ints[i] = Integer.parseInt(stringArray[i]);
1023        }
1024        return ints;
1025    }
1026
1027    @Override
1028    public String toString() {
1029        // log extra info at DEBUG level or finer
1030        final String s = super.toString();
1031        if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
1032            return s;
1033        }
1034        return "(" + s + " subj=" + mConversation.subject + ")";
1035    }
1036
1037    private Address getAddress(String rawFrom) {
1038        Address addr = mAddressCache.get(rawFrom);
1039        if (addr == null) {
1040            addr = Address.getEmailAddress(rawFrom);
1041            mAddressCache.put(rawFrom, addr);
1042        }
1043        return addr;
1044    }
1045
1046    @Override
1047    public Account getAccount() {
1048        return mAccount;
1049    }
1050
1051    private class ConversationWebViewClient extends WebViewClient {
1052
1053        @Override
1054        public void onPageFinished(WebView view, String url) {
1055            // Ignore unsafe calls made after a fragment is detached from an activity
1056            final ControllableActivity activity = (ControllableActivity) getActivity();
1057            if (activity == null || !mViewsCreated) {
1058                LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
1059                        ConversationViewFragment.this);
1060                return;
1061            }
1062
1063            LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s act=%s", url,
1064                    ConversationViewFragment.this, getActivity());
1065
1066            super.onPageFinished(view, url);
1067
1068            // TODO: save off individual message unread state (here, or in onLoadFinished?) so
1069            // 'mark unread' restores the original unread state for each individual message
1070
1071            if (mUserVisible) {
1072                onConversationSeen();
1073            }
1074            if (!mEnableContentReadySignal) {
1075                notifyConversationLoaded(mConversation);
1076                dismissLoadingStatus();
1077            }
1078            final Set<String> emailAddresses = Sets.newHashSet();
1079            for (Address addr : mAddressCache.values()) {
1080                emailAddresses.add(addr.getAddress());
1081            }
1082            mContactLoaderCallbacks.setSenders(emailAddresses);
1083            getLoaderManager().restartLoader(CONTACT_LOADER_ID, Bundle.EMPTY,
1084                    mContactLoaderCallbacks);
1085        }
1086
1087        @Override
1088        public boolean shouldOverrideUrlLoading(WebView view, String url) {
1089            final Activity activity = getActivity();
1090            if (!mViewsCreated || activity == null) {
1091                return false;
1092            }
1093
1094            boolean result = false;
1095            final Intent intent;
1096            Uri uri = Uri.parse(url);
1097            if (!Utils.isEmpty(mAccount.viewIntentProxyUri)) {
1098                intent = new Intent(Intent.ACTION_VIEW, mAccount.viewIntentProxyUri);
1099                intent.putExtra(ViewProxyExtras.EXTRA_ORIGINAL_URI, uri);
1100                intent.putExtra(ViewProxyExtras.EXTRA_ACCOUNT, mAccount);
1101            } else {
1102                intent = new Intent(Intent.ACTION_VIEW, uri);
1103                intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName());
1104            }
1105
1106            try {
1107                intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
1108                activity.startActivity(intent);
1109                result = true;
1110            } catch (ActivityNotFoundException ex) {
1111                // If no application can handle the URL, assume that the
1112                // caller can handle it.
1113            }
1114
1115            return result;
1116        }
1117
1118    }
1119
1120    /**
1121     * Notifies the {@link ConversationViewable.ConversationCallbacks} that the conversation has
1122     * been loaded.
1123     */
1124    public void notifyConversationLoaded(Conversation c) {
1125        // Do nothing.
1126    }
1127
1128    /**
1129     * Notifies the {@link ConversationViewable.ConversationCallbacks} that the conversation has
1130     * failed to load.
1131     */
1132    protected void notifyConversationLoadError(Conversation c) {
1133        mActivity.onConversationLoadError();
1134    }
1135
1136    private void showLoadingStatus() {
1137        mBackgroundView.setVisibility(View.VISIBLE);
1138        String senders = mConversation.getSenders(mContext);
1139        if (!TextUtils.isEmpty(senders) && mConversation.subject != null) {
1140            mInfoView.setVisibility(View.VISIBLE);
1141            mSendersView.setText(senders);
1142            mSubjectView.setText(createSubjectSnippet(mConversation.subject,
1143                    mConversation.getSnippet()));
1144        } else {
1145            mProgressView.setVisibility(View.VISIBLE);
1146        }
1147    }
1148
1149    private void dismissLoadingStatus() {
1150        // Fade out the info view.
1151        if (mBackgroundView.getVisibility() == View.VISIBLE) {
1152            Animator animator = AnimatorInflater.loadAnimator(mContext, R.anim.fade_out);
1153            animator.setTarget(mBackgroundView);
1154            animator.addListener(new AnimatorListener() {
1155                @Override
1156                public void onAnimationStart(Animator animation) {
1157                    if (mProgressView.getVisibility() != View.VISIBLE) {
1158                        mProgressView.setVisibility(View.GONE);
1159                    }
1160                }
1161
1162                @Override
1163                public void onAnimationEnd(Animator animation) {
1164                    mBackgroundView.setVisibility(View.GONE);
1165                    mInfoView.setVisibility(View.GONE);
1166                    mProgressView.setVisibility(View.GONE);
1167                }
1168
1169                @Override
1170                public void onAnimationCancel(Animator animation) {
1171                    // Do nothing.
1172                }
1173
1174                @Override
1175                public void onAnimationRepeat(Animator animation) {
1176                    // Do nothing.
1177                }
1178            });
1179            animator.start();
1180        } else {
1181            mBackgroundView.setVisibility(View.GONE);
1182            mInfoView.setVisibility(View.GONE);
1183            mProgressView.setVisibility(View.GONE);
1184        }
1185    }
1186
1187    /**
1188     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
1189     * via reflection and not stripped.
1190     *
1191     */
1192    private class MailJsBridge {
1193
1194        @SuppressWarnings("unused")
1195        public void onWebContentGeometryChange(final String[] overlayBottomStrs) {
1196            try {
1197                mHandler.post(new Runnable() {
1198                    @Override
1199                    public void run() {
1200                        if (!mViewsCreated) {
1201                            LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" +
1202                                    " are gone, %s", ConversationViewFragment.this);
1203                            return;
1204                        }
1205
1206                        mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs));
1207                    }
1208                });
1209            } catch (Throwable t) {
1210                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
1211            }
1212        }
1213
1214        @SuppressWarnings("unused")
1215        public String getTempMessageBodies() {
1216            try {
1217                if (!mViewsCreated) {
1218                    return "";
1219                }
1220
1221                final String s = mTempBodiesHtml;
1222                mTempBodiesHtml = null;
1223                return s;
1224            } catch (Throwable t) {
1225                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
1226                return "";
1227            }
1228        }
1229
1230        private void showConversation(Conversation conv) {
1231            notifyConversationLoaded(conv);
1232            dismissLoadingStatus();
1233        }
1234
1235        @SuppressWarnings("unused")
1236        public void onContentReady() {
1237            final Conversation conv = mConversation;
1238            try {
1239                mHandler.post(new Runnable() {
1240                    @Override
1241                    public void run() {
1242                        LogUtils.d(LOG_TAG, "ANIMATION STARTED, ready to draw. t=%s",
1243                                SystemClock.uptimeMillis());
1244                        showConversation(conv);
1245                    }
1246                });
1247            } catch (Throwable t) {
1248                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
1249                // Still try to show the conversation.
1250                showConversation(conv);
1251            }
1252        }
1253    }
1254
1255    private class NewMessagesInfo {
1256        int count;
1257        String senderAddress;
1258
1259        /**
1260         * Return the display text for the new message notification overlay. It will be formatted
1261         * appropriately for a single new message vs. multiple new messages.
1262         *
1263         * @return display text
1264         */
1265        public String getNotificationText() {
1266            final Object param;
1267            if (count > 1) {
1268                param = count;
1269            } else {
1270                final Address addr = getAddress(senderAddress);
1271                param = TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName();
1272            }
1273            return getResources().getQuantityString(R.plurals.new_incoming_messages, count, param);
1274        }
1275    }
1276
1277    private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
1278
1279        @Override
1280        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
1281            return new MessageLoader(mContext, mConversation, ConversationViewFragment.this);
1282        }
1283
1284        @Override
1285        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
1286            MessageCursor messageCursor = (MessageCursor) data;
1287
1288            // ignore truly duplicate results
1289            // this can happen when restoring after rotation
1290            if (mCursor == messageCursor) {
1291                return;
1292            }
1293
1294            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
1295                LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
1296            }
1297
1298            // TODO: handle ERROR status
1299
1300            // When the last cursor had message(s), and the new version has no messages,
1301            // we need to exit conversation view.
1302            if (messageCursor.getCount() == 0 && mCursor != null) {
1303
1304                if (mUserVisible) {
1305                    // need to exit this view- conversation may have been deleted, or for
1306                    // whatever reason is now invalid (e.g. discard single draft)
1307                    //
1308                    // N.B. this may involve a fragment transaction, which FragmentManager will
1309                    // refuse to execute directly within onLoadFinished. Make sure the controller
1310                    // knows.
1311                    LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode");
1312                    mActivity.getListHandler().onConversationSelected(null,
1313                            true /* inLoaderCallbacks */);
1314                } else {
1315                    // we expect that the pager adapter will remove this conversation fragment
1316                    // on its own due to a separate conversation cursor update
1317                    // (we might get here if the message list update fires first. nothing to do
1318                    // because we expect to be torn down soon.)
1319                    LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update"
1320                            + " in anticipation of conv cursor update. c=%s", mConversation.uri);
1321                }
1322
1323                return;
1324            }
1325
1326            // ignore cursors that are still loading results
1327            if (!messageCursor.isLoaded()) {
1328                return;
1329            }
1330
1331            /*
1332             * what kind of changes affect the MessageCursor?
1333             * 1. new message(s)
1334             * 2. read/unread state change
1335             * 3. deleted message, either regular or draft
1336             * 4. updated message, either from self or from others, updated in content or state
1337             * or sender
1338             * 5. star/unstar of message (technically similar to #1)
1339             * 6. other label change
1340             *
1341             * Use MessageCursor.hashCode() to sort out interesting vs. no-op cursor updates.
1342             */
1343
1344            if (mCursor == null) {
1345                LogUtils.i(LOG_TAG, "CONV RENDER: existing cursor is null, rendering from scratch");
1346            } else {
1347                final NewMessagesInfo info = getNewIncomingMessagesInfo(messageCursor);
1348
1349                if (info.count > 0 || messageCursor.hashCode() == mCursor.hashCode()) {
1350
1351                    if (info.count > 0) {
1352                        // don't immediately render new incoming messages from other senders
1353                        // (to avoid a new message from losing the user's focus)
1354                        LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1355                                + ", holding cursor for new incoming message");
1356                        showNewMessageNotification(info);
1357                    } else {
1358                        LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update"
1359                                + ", ignoring this conversation update");
1360                    }
1361
1362                    // update mCursor reference because the old one is about to be closed by
1363                    // CursorLoader
1364                    mCursor = messageCursor;
1365                    return;
1366                }
1367
1368                // cursors are different, and not due to an incoming message. fall through and
1369                // render.
1370                LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1371                        + ", but not due to incoming message. rendering.");
1372            }
1373
1374            renderConversation(messageCursor);
1375
1376            // TODO: if this is not user-visible, delay render until user-visible fragment is done.
1377            // This is needed in addition to the showConversation() delay to speed up rotation and
1378            // restoration.
1379        }
1380
1381        @Override
1382        public void onLoaderReset(Loader<Cursor> loader) {
1383            mCursor = null;
1384        }
1385
1386        private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
1387            final NewMessagesInfo info = new NewMessagesInfo();
1388
1389            int pos = -1;
1390            while (newCursor.moveToPosition(++pos)) {
1391                final Message m = newCursor.getMessage();
1392                if (!mViewState.contains(m)) {
1393                    LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
1394
1395                    final Address from = getAddress(m.from);
1396                    // distinguish ours from theirs
1397                    // new messages from the account owner should not trigger a notification
1398                    if (mAccount.ownsFromAddress(from.getAddress())) {
1399                        LogUtils.i(LOG_TAG, "found message from self: %s", m.uri);
1400                        continue;
1401                    }
1402
1403                    info.count++;
1404                    info.senderAddress = m.from;
1405                }
1406            }
1407            return info;
1408        }
1409
1410    }
1411
1412    /**
1413     * Inner class to to asynchronously load contact data for all senders in the conversation,
1414     * and notify observers when the data is ready.
1415     *
1416     */
1417    private class ContactLoaderCallbacks implements ContactInfoSource,
1418            LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> {
1419
1420        private Set<String> mSenders;
1421        private ImmutableMap<String, ContactInfo> mContactInfoMap;
1422        private DataSetObservable mObservable = new DataSetObservable();
1423
1424        public void setSenders(Set<String> emailAddresses) {
1425            mSenders = emailAddresses;
1426        }
1427
1428        @Override
1429        public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) {
1430            return new SenderInfoLoader(mContext, mSenders);
1431        }
1432
1433        @Override
1434        public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader,
1435                ImmutableMap<String, ContactInfo> data) {
1436            mContactInfoMap = data;
1437            mObservable.notifyChanged();
1438        }
1439
1440        @Override
1441        public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) {
1442        }
1443
1444        @Override
1445        public ContactInfo getContactInfo(String email) {
1446            if (mContactInfoMap == null) {
1447                return null;
1448            }
1449            return mContactInfoMap.get(email);
1450        }
1451
1452        @Override
1453        public void registerObserver(DataSetObserver observer) {
1454            mObservable.registerObserver(observer);
1455        }
1456
1457        @Override
1458        public void unregisterObserver(DataSetObserver observer) {
1459            mObservable.unregisterObserver(observer);
1460        }
1461
1462    }
1463
1464    private class MarkReadObserver extends DataSetObserver {
1465        private final ConversationUpdater mListController;
1466
1467        private MarkReadObserver(ConversationUpdater listController) {
1468            mListController = listController;
1469        }
1470
1471        @Override
1472        public void onChanged() {
1473            if (mListController.getConversationListCursor() == null) {
1474                // nothing yet, keep watching
1475                return;
1476            }
1477            // done loading, safe to mark read now
1478            mListController.unregisterConversationListObserver(this);
1479            mMarkReadObserver = null;
1480            LogUtils.i(LOG_TAG, "running deferred conv mark read on open, id=%d", mConversation.id);
1481            markReadOnSeen(mListController);
1482        }
1483    }
1484
1485    private class SetCookieTask extends AsyncTask<Void, Void, Void> {
1486        final String mUri;
1487        final String mCookie;
1488
1489        SetCookieTask(String uri, String cookie) {
1490            mUri = uri;
1491            mCookie = cookie;
1492        }
1493
1494        @Override
1495        public Void doInBackground(Void... args) {
1496            final CookieSyncManager csm =
1497                CookieSyncManager.createInstance(mContext);
1498            CookieManager.getInstance().setCookie(mUri, mCookie);
1499            csm.sync();
1500            return null;
1501        }
1502    }
1503}
1504