ConversationViewFragment.java revision 91fa034d624d8690905f30a13ae3ffb9601cf948
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.content.ContentResolver;
21import android.content.Context;
22import android.content.Loader;
23import android.content.res.Resources;
24import android.database.Cursor;
25import android.database.DataSetObserver;
26import android.graphics.Rect;
27import android.net.Uri;
28import android.os.AsyncTask;
29import android.os.Bundle;
30import android.os.SystemClock;
31import android.support.v4.text.BidiFormatter;
32import android.support.v4.util.ArrayMap;
33import android.text.TextUtils;
34import android.view.KeyEvent;
35import android.view.LayoutInflater;
36import android.view.View;
37import android.view.View.OnLayoutChangeListener;
38import android.view.ViewGroup;
39import android.webkit.ConsoleMessage;
40import android.webkit.CookieManager;
41import android.webkit.CookieSyncManager;
42import android.webkit.JavascriptInterface;
43import android.webkit.WebChromeClient;
44import android.webkit.WebSettings;
45import android.webkit.WebView;
46import android.widget.Button;
47
48import com.android.emailcommon.mail.Address;
49import com.android.mail.FormattedDateBuilder;
50import com.android.mail.R;
51import com.android.mail.analytics.Analytics;
52import com.android.mail.analytics.AnalyticsTimer;
53import com.android.mail.browse.ConversationContainer;
54import com.android.mail.browse.ConversationContainer.OverlayPosition;
55import com.android.mail.browse.ConversationFooterView.ConversationFooterCallbacks;
56import com.android.mail.browse.ConversationMessage;
57import com.android.mail.browse.ConversationOverlayItem;
58import com.android.mail.browse.ConversationViewAdapter;
59import com.android.mail.browse.ConversationViewAdapter.ConversationFooterItem;
60import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem;
61import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
62import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
63import com.android.mail.browse.ConversationViewHeader;
64import com.android.mail.browse.ConversationWebView;
65import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreator;
66import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreatorHolder;
67import com.android.mail.browse.MailWebView.ContentSizeChangeListener;
68import com.android.mail.browse.MessageCursor;
69import com.android.mail.browse.MessageFooterView;
70import com.android.mail.browse.MessageHeaderView;
71import com.android.mail.browse.ScrollIndicatorsView;
72import com.android.mail.browse.SuperCollapsedBlock;
73import com.android.mail.browse.WebViewContextMenu;
74import com.android.mail.content.ObjectCursor;
75import com.android.mail.print.PrintUtils;
76import com.android.mail.providers.Account;
77import com.android.mail.providers.Conversation;
78import com.android.mail.providers.Message;
79import com.android.mail.providers.Settings;
80import com.android.mail.providers.UIProvider;
81import com.android.mail.ui.ConversationViewState.ExpansionState;
82import com.android.mail.utils.ConversationViewUtils;
83import com.android.mail.utils.LogTag;
84import com.android.mail.utils.LogUtils;
85import com.android.mail.utils.Utils;
86import com.google.common.collect.ImmutableList;
87import com.google.common.collect.Lists;
88import com.google.common.collect.Maps;
89import com.google.common.collect.Sets;
90
91import java.util.ArrayList;
92import java.util.List;
93import java.util.Map;
94import java.util.Set;
95
96/**
97 * The conversation view UI component.
98 */
99public class ConversationViewFragment extends AbstractConversationViewFragment implements
100        SuperCollapsedBlock.OnClickListener, OnLayoutChangeListener,
101        MessageHeaderView.MessageHeaderViewCallbacks, MessageFooterView.MessageFooterCallbacks,
102        WebViewContextMenu.Callbacks, ConversationFooterCallbacks, View.OnKeyListener {
103
104    private static final String LOG_TAG = LogTag.getLogTag();
105    public static final String LAYOUT_TAG = "ConvLayout";
106
107    /**
108     * Difference in the height of the message header whose details have been expanded/collapsed
109     */
110    private int mDiff = 0;
111
112    /**
113     * Default value for {@link #mLoadWaitReason}. Conversation load will happen immediately.
114     */
115    private final int LOAD_NOW = 0;
116    /**
117     * Value for {@link #mLoadWaitReason} that means we are offscreen and waiting for the visible
118     * conversation to finish loading before beginning our load.
119     * <p>
120     * When this value is set, the fragment should register with {@link ConversationListCallbacks}
121     * to know when the visible conversation is loaded. When it is unset, it should unregister.
122     */
123    private final int LOAD_WAIT_FOR_INITIAL_CONVERSATION = 1;
124    /**
125     * Value for {@link #mLoadWaitReason} used when a conversation is too heavyweight to load at
126     * all when not visible (e.g. requires network fetch, or too complex). Conversation load will
127     * wait until this fragment is visible.
128     */
129    private final int LOAD_WAIT_UNTIL_VISIBLE = 2;
130
131    // Keyboard navigation
132    private KeyboardNavigationController mNavigationController;
133    // Since we manually control navigation for most of the conversation view due to problems
134    // with two-pane layout but still rely on the system for SOME navigation, we need to keep track
135    // of the view that had focus when KeyEvent.ACTION_DOWN was fired. This is because we only
136    // manually change focus on KeyEvent.ACTION_UP (to prevent holding down the DOWN button and
137    // lagging the app), however, the view in focus might have changed between ACTION_UP and
138    // ACTION_DOWN since the system might have handled the ACTION_DOWN and moved focus.
139    private View mOriginalKeyedView;
140    private int mMaxScreenHeight;
141    private int mTopOfVisibleScreen;
142
143    protected ConversationContainer mConversationContainer;
144
145    protected ConversationWebView mWebView;
146
147    private ViewGroup mTopmostOverlay;
148
149    private ConversationViewProgressController mProgressController;
150
151    private Button mNewMessageBar;
152
153    protected HtmlConversationTemplates mTemplates;
154
155    private final MailJsBridge mJsBridge = new MailJsBridge();
156
157    protected ConversationViewAdapter mAdapter;
158
159    protected boolean mViewsCreated;
160    // True if we attempted to render before the views were laid out
161    // We will render immediately once layout is done
162    private boolean mNeedRender;
163
164    /**
165     * Temporary string containing the message bodies of the messages within a super-collapsed
166     * block, for one-time use during block expansion. We cannot easily pass the body HTML
167     * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it
168     * using {@link MailJsBridge}.
169     */
170    private String mTempBodiesHtml;
171
172    private int  mMaxAutoLoadMessages;
173
174    protected int mSideMarginPx;
175
176    /**
177     * If this conversation fragment is not visible, and it's inappropriate to load up front,
178     * this is the reason we are waiting. This flag should be cleared once it's okay to load
179     * the conversation.
180     */
181    private int mLoadWaitReason = LOAD_NOW;
182
183    private boolean mEnableContentReadySignal;
184
185    private ContentSizeChangeListener mWebViewSizeChangeListener;
186
187    private float mWebViewYPercent;
188
189    /**
190     * Has loadData been called on the WebView yet?
191     */
192    private boolean mWebViewLoadedData;
193
194    private long mWebViewLoadStartMs;
195
196    private final Map<String, String> mMessageTransforms = Maps.newHashMap();
197
198    private final DataSetObserver mLoadedObserver = new DataSetObserver() {
199        @Override
200        public void onChanged() {
201            getHandler().post(new FragmentRunnable("delayedConversationLoad",
202                    ConversationViewFragment.this) {
203                @Override
204                public void go() {
205                    LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s",
206                            ConversationViewFragment.this);
207                    handleDelayedConversationLoad();
208                }
209            });
210        }
211    };
212
213    private final Runnable mOnProgressDismiss = new FragmentRunnable("onProgressDismiss", this) {
214        @Override
215        public void go() {
216            LogUtils.d(LOG_TAG, "onProgressDismiss go() - isUserVisible() = %b", isUserVisible());
217            if (isUserVisible()) {
218                onConversationSeen();
219            }
220            mWebView.onRenderComplete();
221        }
222    };
223
224    private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
225    private static final boolean DISABLE_OFFSCREEN_LOADING = false;
226    private static final boolean DEBUG_DUMP_CURSOR_CONTENTS = false;
227
228    private static final String BUNDLE_KEY_WEBVIEW_Y_PERCENT =
229            ConversationViewFragment.class.getName() + "webview-y-percent";
230
231    private BidiFormatter mBidiFormatter;
232
233    /**
234     * Contains a mapping between inline image attachments and their local message id.
235     */
236    private Map<String, String> mUrlToMessageIdMap;
237
238    /**
239     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
240     */
241    public ConversationViewFragment() {}
242
243    /**
244     * Creates a new instance of {@link ConversationViewFragment}, initialized
245     * to display a conversation with other parameters inherited/copied from an existing bundle,
246     * typically one created using {@link #makeBasicArgs}.
247     */
248    public static ConversationViewFragment newInstance(Bundle existingArgs,
249            Conversation conversation) {
250        ConversationViewFragment f = new ConversationViewFragment();
251        Bundle args = new Bundle(existingArgs);
252        args.putParcelable(ARG_CONVERSATION, conversation);
253        f.setArguments(args);
254        return f;
255    }
256
257    @Override
258    public void onAccountChanged(Account newAccount, Account oldAccount) {
259        // if overview mode has changed, re-render completely (no need to also update headers)
260        if (isOverviewMode(newAccount) != isOverviewMode(oldAccount)) {
261            setupOverviewMode();
262            final MessageCursor c = getMessageCursor();
263            if (c != null) {
264                renderConversation(c);
265            } else {
266                // Null cursor means this fragment is either waiting to load or in the middle of
267                // loading. Either way, a future render will happen anyway, and the new setting
268                // will take effect when that happens.
269            }
270            return;
271        }
272
273        // settings may have been updated; refresh views that are known to
274        // depend on settings
275        mAdapter.notifyDataSetChanged();
276    }
277
278    @Override
279    public void onActivityCreated(Bundle savedInstanceState) {
280        LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s visible=%s", this, isUserVisible());
281        super.onActivityCreated(savedInstanceState);
282
283        if (mActivity == null || mActivity.isFinishing()) {
284            // Activity is finishing, just bail.
285            return;
286        }
287
288        Context context = getContext();
289        mTemplates = new HtmlConversationTemplates(context);
290
291        final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(context);
292
293        mNavigationController = mActivity.getKeyboardNavigationController();
294
295        mAdapter = new ConversationViewAdapter(mActivity, this,
296                getLoaderManager(), this, this, getContactInfoSource(), this, this,
297                getListController(), this, mAddressCache, dateBuilder, mBidiFormatter, this);
298        mConversationContainer.setOverlayAdapter(mAdapter);
299
300        // set up snap header (the adapter usually does this with the other ones)
301        mConversationContainer.getSnapHeader().initialize(
302                this, mAddressCache, this, getContactInfoSource(),
303                mActivity.getAccountController().getVeiledAddressMatcher());
304
305        final Resources resources = getResources();
306        mMaxAutoLoadMessages = resources.getInteger(R.integer.max_auto_load_messages);
307
308        mSideMarginPx = resources.getDimensionPixelOffset(
309                R.dimen.conversation_message_content_margin_side);
310
311        mUrlToMessageIdMap = new ArrayMap<String, String>();
312        final InlineAttachmentViewIntentBuilderCreator creator =
313                InlineAttachmentViewIntentBuilderCreatorHolder.
314                getInlineAttachmentViewIntentCreator();
315        final WebViewContextMenu contextMenu = new WebViewContextMenu(getActivity(),
316                creator.createInlineAttachmentViewIntentBuilder(mAccount,
317                mConversation != null ? mConversation.id : -1));
318        contextMenu.setCallbacks(this);
319        mWebView.setOnCreateContextMenuListener(contextMenu);
320
321        // set this up here instead of onCreateView to ensure the latest Account is loaded
322        setupOverviewMode();
323
324        // Defer the call to initLoader with a Handler.
325        // We want to wait until we know which fragments are present and their final visibility
326        // states before going off and doing work. This prevents extraneous loading from occurring
327        // as the ViewPager shifts about before the initial position is set.
328        //
329        // e.g. click on item #10
330        // ViewPager.setAdapter() actually first loads #0 and #1 under the assumption that #0 is
331        // the initial primary item
332        // Then CPC immediately sets the primary item to #10, which tears down #0/#1 and sets up
333        // #9/#10/#11.
334        getHandler().post(new FragmentRunnable("showConversation", this) {
335            @Override
336            public void go() {
337                showConversation();
338            }
339        });
340
341        if (mConversation != null && mConversation.conversationBaseUri != null &&
342                !Utils.isEmpty(mAccount.accountCookieQueryUri)) {
343            // Set the cookie for this base url
344            new SetCookieTask(getContext(), mConversation.conversationBaseUri.toString(),
345                    mAccount.accountCookieQueryUri).execute();
346        }
347
348        // Find the height of the screen for manually scrolling the webview via keyboard.
349        final Rect screen = new Rect();
350        mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(screen);
351        mMaxScreenHeight = screen.bottom;
352        mTopOfVisibleScreen = screen.top + mActivity.getSupportActionBar().getHeight();
353    }
354
355    @Override
356    public void onCreate(Bundle savedState) {
357        super.onCreate(savedState);
358
359        mWebViewClient = createConversationWebViewClient();
360
361        if (savedState != null) {
362            mWebViewYPercent = savedState.getFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT);
363        }
364
365        mBidiFormatter = BidiFormatter.getInstance();
366    }
367
368    protected ConversationWebViewClient createConversationWebViewClient() {
369        return new ConversationWebViewClient(mAccount);
370    }
371
372    @Override
373    public View onCreateView(LayoutInflater inflater,
374            ViewGroup container, Bundle savedInstanceState) {
375
376        View rootView = inflater.inflate(R.layout.conversation_view, container, false);
377        mConversationContainer = (ConversationContainer) rootView
378                .findViewById(R.id.conversation_container);
379        mConversationContainer.setAccountController(this);
380
381        mTopmostOverlay =
382                (ViewGroup) mConversationContainer.findViewById(R.id.conversation_topmost_overlay);
383        mTopmostOverlay.setOnKeyListener(this);
384        inflateSnapHeader(mTopmostOverlay, inflater);
385        mConversationContainer.setupSnapHeader();
386
387        setupNewMessageBar();
388
389        mProgressController = new ConversationViewProgressController(this, getHandler());
390        mProgressController.instantiateProgressIndicators(rootView);
391
392        mWebView = (ConversationWebView)
393                mConversationContainer.findViewById(R.id.conversation_webview);
394
395        mWebView.addJavascriptInterface(mJsBridge, "mail");
396        // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
397        // Below JB, try to speed up initial render by having the webview do supplemental draws to
398        // custom a software canvas.
399        // TODO(mindyp):
400        //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
401        // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
402        // animation that immediately runs on page load. The app uses this as a signal that the
403        // content is loaded and ready to draw, since WebView delays firing this event until the
404        // layers are composited and everything is ready to draw.
405        // This signal does not seem to be reliable, so just use the old method for now.
406        final boolean isJBOrLater = Utils.isRunningJellybeanOrLater();
407        final boolean isUserVisible = isUserVisible();
408        mWebView.setUseSoftwareLayer(!isJBOrLater);
409        mEnableContentReadySignal = isJBOrLater && isUserVisible;
410        mWebView.onUserVisibilityChanged(isUserVisible);
411        mWebView.setWebViewClient(mWebViewClient);
412        final WebChromeClient wcc = new WebChromeClient() {
413            @Override
414            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
415                if (consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
416                    LogUtils.wtf(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(),
417                            consoleMessage.sourceId(), consoleMessage.lineNumber(),
418                            ConversationViewFragment.this);
419                } else {
420                    LogUtils.i(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(),
421                            consoleMessage.sourceId(), consoleMessage.lineNumber(),
422                            ConversationViewFragment.this);
423                }
424                return true;
425            }
426        };
427        mWebView.setWebChromeClient(wcc);
428
429        final WebSettings settings = mWebView.getSettings();
430
431        final ScrollIndicatorsView scrollIndicators =
432                (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators);
433        scrollIndicators.setSourceView(mWebView);
434
435        settings.setJavaScriptEnabled(true);
436
437        ConversationViewUtils.setTextZoom(getResources(), settings);
438
439        mViewsCreated = true;
440        mWebViewLoadedData = false;
441
442        return rootView;
443    }
444
445    protected void inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater) {
446        inflater.inflate(R.layout.conversation_topmost_overlay_items, topmostOverlay, true);
447    }
448
449    protected void setupNewMessageBar() {
450        mNewMessageBar = (Button) mConversationContainer.findViewById(
451                R.id.new_message_notification_bar);
452        mNewMessageBar.setOnClickListener(new View.OnClickListener() {
453            @Override
454            public void onClick(View v) {
455                onNewMessageBarClick();
456            }
457        });
458    }
459
460    @Override
461    public void onResume() {
462        super.onResume();
463        if (mWebView != null) {
464            mWebView.onResume();
465        }
466    }
467
468    @Override
469    public void onPause() {
470        super.onPause();
471        if (mWebView != null) {
472            mWebView.onPause();
473        }
474    }
475
476    @Override
477    public void onDestroyView() {
478        super.onDestroyView();
479        mConversationContainer.setOverlayAdapter(null);
480        mAdapter = null;
481        resetLoadWaiting(); // be sure to unregister any active load observer
482        mViewsCreated = false;
483    }
484
485    @Override
486    public void onSaveInstanceState(Bundle outState) {
487        super.onSaveInstanceState(outState);
488
489        outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent());
490    }
491
492    private float calculateScrollYPercent() {
493        final float p;
494        if (mWebView == null) {
495            // onCreateView hasn't been called, return 0 as the user hasn't scrolled the view.
496            return 0;
497        }
498
499        final int scrollY = mWebView.getScrollY();
500        final int viewH = mWebView.getHeight();
501        final int webH = (int) (mWebView.getContentHeight() * mWebView.getScale());
502
503        if (webH == 0 || webH <= viewH) {
504            p = 0;
505        } else if (scrollY + viewH >= webH) {
506            // The very bottom is a special case, it acts as a stronger anchor than the scroll top
507            // at that point.
508            p = 1.0f;
509        } else {
510            p = (float) scrollY / webH;
511        }
512        return p;
513    }
514
515    private void resetLoadWaiting() {
516        if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) {
517            getListController().unregisterConversationLoadedObserver(mLoadedObserver);
518        }
519        mLoadWaitReason = LOAD_NOW;
520    }
521
522    @Override
523    protected void markUnread() {
524        super.markUnread();
525        // Ignore unsafe calls made after a fragment is detached from an activity
526        final ControllableActivity activity = (ControllableActivity) getActivity();
527        if (activity == null) {
528            LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
529            return;
530        }
531
532        if (mViewState == null) {
533            LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
534                    mConversation.id);
535            return;
536        }
537        activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
538                mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
539    }
540
541    @Override
542    public void onUserVisibleHintChanged() {
543        final boolean userVisible = isUserVisible();
544        LogUtils.d(LOG_TAG, "ConversationViewFragment#onUserVisibleHintChanged(), userVisible = %b",
545                userVisible);
546
547        if (!userVisible) {
548            mProgressController.dismissLoadingStatus();
549        } else if (mViewsCreated) {
550            String loadTag = null;
551            final boolean isInitialLoading;
552            if (mActivity != null) {
553                isInitialLoading = mActivity.getConversationUpdater()
554                    .isInitialConversationLoading();
555            } else {
556                isInitialLoading = true;
557            }
558
559            if (getMessageCursor() != null) {
560                LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this);
561                if (!isInitialLoading) {
562                    loadTag = "preloaded";
563                }
564                onConversationSeen();
565            } else if (isLoadWaiting()) {
566                LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this);
567                if (!isInitialLoading) {
568                    loadTag = "load_deferred";
569                }
570                handleDelayedConversationLoad();
571            }
572
573            if (loadTag != null) {
574                // pager swipes are visibility transitions to 'visible' except during initial
575                // pager load on A) enter conversation mode B) rotate C) 2-pane conv-mode list-tap
576              Analytics.getInstance().sendEvent("pager_swipe", loadTag,
577                      getCurrentFolderTypeDesc(), 0);
578            }
579        }
580
581        if (mWebView != null) {
582            mWebView.onUserVisibilityChanged(userVisible);
583        }
584    }
585
586    /**
587     * Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do
588     * nothing (in which case you should later call {@link #handleDelayedConversationLoad()}).
589     */
590    private void showConversation() {
591        final int reason;
592
593        if (isUserVisible()) {
594            LogUtils.i(LOG_TAG,
595                    "SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this);
596            reason = LOAD_NOW;
597            timerMark("CVF.showConversation");
598        } else {
599            final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
600                    || Utils.isLowRamDevice(getContext())
601                    || (mConversation != null && (mConversation.isRemote
602                            || mConversation.getNumMessages() > mMaxAutoLoadMessages));
603
604            // When not visible, we should not immediately load if either this conversation is
605            // too heavyweight, or if the main/initial conversation is busy loading.
606            if (disableOffscreenLoading) {
607                reason = LOAD_WAIT_UNTIL_VISIBLE;
608                LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this);
609            } else if (getListController().isInitialConversationLoading()) {
610                reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION;
611                LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this);
612                getListController().registerConversationLoadedObserver(mLoadedObserver);
613            } else {
614                LogUtils.i(LOG_TAG,
615                        "SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)",
616                        this);
617                reason = LOAD_NOW;
618            }
619        }
620
621        mLoadWaitReason = reason;
622        if (mLoadWaitReason == LOAD_NOW) {
623            startConversationLoad();
624        }
625    }
626
627    private void handleDelayedConversationLoad() {
628        resetLoadWaiting();
629        startConversationLoad();
630    }
631
632    private void startConversationLoad() {
633        mWebView.setVisibility(View.VISIBLE);
634        loadContent();
635        // TODO(mindyp): don't show loading status for a previously rendered
636        // conversation. Ielieve this is better done by making sure don't show loading status
637        // until XX ms have passed without loading completed.
638        mProgressController.showLoadingStatus(isUserVisible());
639    }
640
641    /**
642     * Can be overridden in case a subclass needs to load something other than
643     * the messages of a conversation.
644     */
645    protected void loadContent() {
646        getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks());
647    }
648
649    private void revealConversation() {
650        timerMark("revealing conversation");
651        mProgressController.dismissLoadingStatus(mOnProgressDismiss);
652        if (isUserVisible()) {
653            AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST,
654                    true /* isDestructive */, "open_conversation", "from_list", null);
655        }
656    }
657
658    private boolean isLoadWaiting() {
659        return mLoadWaitReason != LOAD_NOW;
660    }
661
662    private void renderConversation(MessageCursor messageCursor) {
663        final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal);
664        timerMark("rendered conversation");
665
666        if (DEBUG_DUMP_CONVERSATION_HTML) {
667            java.io.FileWriter fw = null;
668            try {
669                fw = new java.io.FileWriter(getSdCardFilePath());
670                fw.write(convHtml);
671            } catch (java.io.IOException e) {
672                e.printStackTrace();
673            } finally {
674                if (fw != null) {
675                    try {
676                        fw.close();
677                    } catch (java.io.IOException e) {
678                        e.printStackTrace();
679                    }
680                }
681            }
682        }
683
684        // save off existing scroll position before re-rendering
685        if (mWebViewLoadedData) {
686            mWebViewYPercent = calculateScrollYPercent();
687        }
688
689        mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
690        mWebViewLoadedData = true;
691        mWebViewLoadStartMs = SystemClock.uptimeMillis();
692    }
693
694    protected String getSdCardFilePath() {
695        return "/sdcard/conv" + mConversation.id + ".html";
696    }
697
698    /**
699     * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
700     * conversation header), and return an HTML document with spacer divs inserted for all overlays.
701     *
702     */
703    protected String renderMessageBodies(MessageCursor messageCursor,
704            boolean enableContentReadySignal) {
705        int pos = -1;
706
707        LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this);
708        boolean allowNetworkImages = false;
709
710        // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
711
712        // Walk through the cursor and build up an overlay adapter as you go.
713        // Each overlay has an entry in the adapter for easy scroll handling in the container.
714        // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
715        // When adding adapter items, also add their heights to help the container later determine
716        // overlay dimensions.
717
718        // When re-rendering, prevent ConversationContainer from laying out overlays until after
719        // the new spacers are positioned by WebView.
720        mConversationContainer.invalidateSpacerGeometry();
721
722        mAdapter.clear();
723
724        // re-evaluate the message parts of the view state, since the messages may have changed
725        // since the previous render
726        final ConversationViewState prevState = mViewState;
727        mViewState = new ConversationViewState(prevState);
728
729        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
730        // a pixel is an mdpi pixel, unless you set device-dpi.
731
732        // add a single conversation header item
733        final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
734        final int convHeaderPx = measureOverlayHeight(convHeaderPos);
735
736        mTemplates.startConversation(mWebView.getViewportWidth(),
737                mWebView.screenPxToWebPx(mSideMarginPx), mWebView.screenPxToWebPx(convHeaderPx));
738
739        int collapsedStart = -1;
740        ConversationMessage prevCollapsedMsg = null;
741
742        final boolean alwaysShowImages = shouldAlwaysShowImages();
743
744        boolean prevSafeForImages = alwaysShowImages;
745
746        boolean hasDraft = false;
747        while (messageCursor.moveToPosition(++pos)) {
748            final ConversationMessage msg = messageCursor.getMessage();
749
750            final boolean safeForImages = alwaysShowImages ||
751                    msg.alwaysShowImages || prevState.getShouldShowImages(msg);
752            allowNetworkImages |= safeForImages;
753
754            final Integer savedExpanded = prevState.getExpansionState(msg);
755            final int expandedState;
756            if (savedExpanded != null) {
757                if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) {
758                    // override saved state when this is now the new last message
759                    // this happens to the second-to-last message when you discard a draft
760                    expandedState = ExpansionState.EXPANDED;
761                } else {
762                    expandedState = savedExpanded;
763                }
764            } else {
765                // new messages that are not expanded default to being eligible for super-collapse
766                if (!msg.read || messageCursor.isLast()) {
767                    expandedState = ExpansionState.EXPANDED;
768                } else if (messageCursor.isFirst()) {
769                    expandedState = ExpansionState.COLLAPSED;
770                } else {
771                    expandedState = ExpansionState.SUPER_COLLAPSED;
772                    hasDraft |= msg.isDraft();
773                }
774            }
775            mViewState.setShouldShowImages(msg, prevState.getShouldShowImages(msg));
776            mViewState.setExpansionState(msg, expandedState);
777
778            // save off "read" state from the cursor
779            // later, the view may not match the cursor (e.g. conversation marked read on open)
780            // however, if a previous state indicated this message was unread, trust that instead
781            // so "mark unread" marks all originally unread messages
782            mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg));
783
784            // We only want to consider this for inclusion in the super collapsed block if
785            // 1) The we don't have previous state about this message  (The first time that the
786            //    user opens a conversation)
787            // 2) The previously saved state for this message indicates that this message is
788            //    in the super collapsed block.
789            if (ExpansionState.isSuperCollapsed(expandedState)) {
790                // contribute to a super-collapsed block that will be emitted just before the
791                // next expanded header
792                if (collapsedStart < 0) {
793                    collapsedStart = pos;
794                }
795                prevCollapsedMsg = msg;
796                prevSafeForImages = safeForImages;
797
798                // This line puts the from address in the address cache so that
799                // we get the sender image for it if it's in a super-collapsed block.
800                getAddress(msg.getFrom());
801                continue;
802            }
803
804            // resolve any deferred decisions on previous collapsed items
805            if (collapsedStart >= 0) {
806                if (pos - collapsedStart == 1) {
807                    // Special-case for a single collapsed message: no need to super-collapse it.
808                    renderMessage(prevCollapsedMsg, false /* expanded */, prevSafeForImages);
809                } else {
810                    renderSuperCollapsedBlock(collapsedStart, pos - 1, hasDraft);
811                }
812                hasDraft = false; // reset hasDraft
813                prevCollapsedMsg = null;
814                collapsedStart = -1;
815            }
816
817            renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages);
818        }
819
820        final MessageHeaderItem lastHeaderItem = getLastMessageHeaderItem();
821        final int convFooterPos = mAdapter.addConversationFooter(lastHeaderItem);
822        final int convFooterPx = measureOverlayHeight(convFooterPos);
823
824        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
825
826        final boolean applyTransforms = shouldApplyTransforms();
827
828        // If the conversation has specified a base uri, use it here, otherwise use mBaseUri
829        return mTemplates.endConversation(convFooterPx, mBaseUri,
830                mConversation.getBaseUri(mBaseUri),
831                mWebView.getViewportWidth(), mWebView.getWidthInDp(mSideMarginPx),
832                enableContentReadySignal, isOverviewMode(mAccount), applyTransforms,
833                applyTransforms);
834    }
835
836    private MessageHeaderItem getLastMessageHeaderItem() {
837        final int count = mAdapter.getCount();
838        if (count < 3) {
839            LogUtils.wtf(LOG_TAG, "not enough items in the adapter. count: %s", count);
840            return null;
841        }
842        return (MessageHeaderItem) mAdapter.getItem(count - 2);
843    }
844
845    private void renderSuperCollapsedBlock(int start, int end, boolean hasDraft) {
846        final int blockPos = mAdapter.addSuperCollapsedBlock(start, end, hasDraft);
847        final int blockPx = measureOverlayHeight(blockPos);
848        mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
849    }
850
851    private void renderMessage(ConversationMessage msg, boolean expanded, boolean safeForImages) {
852
853        final int headerPos = mAdapter.addMessageHeader(msg, expanded,
854                mViewState.getShouldShowImages(msg));
855        final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
856
857        final int footerPos = mAdapter.addMessageFooter(headerItem);
858
859        // Measure item header and footer heights to allocate spacers in HTML
860        // But since the views themselves don't exist yet, render each item temporarily into
861        // a host view for measurement.
862        final int headerPx = measureOverlayHeight(headerPos);
863        final int footerPx = measureOverlayHeight(footerPos);
864
865        mTemplates.appendMessageHtml(msg, expanded, safeForImages,
866                mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
867        timerMark("rendered message");
868    }
869
870    private String renderCollapsedHeaders(MessageCursor cursor,
871            SuperCollapsedBlockItem blockToReplace) {
872        final List<ConversationOverlayItem> replacements = Lists.newArrayList();
873
874        mTemplates.reset();
875
876        final boolean alwaysShowImages = (mAccount != null) &&
877                (mAccount.settings.showImages == Settings.ShowImages.ALWAYS);
878
879        // In devices with non-integral density multiplier, screen pixels translate to non-integral
880        // web pixels. Keep track of the error that occurs when we cast all heights to int
881        float error = 0f;
882        boolean first = true;
883        for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
884            cursor.moveToPosition(i);
885            final ConversationMessage msg = cursor.getMessage();
886
887            final MessageHeaderItem header = ConversationViewAdapter.newMessageHeaderItem(
888                    mAdapter, mAdapter.getDateBuilder(), msg, false /* expanded */,
889                    alwaysShowImages || mViewState.getShouldShowImages(msg));
890            final MessageFooterItem footer = mAdapter.newMessageFooterItem(mAdapter, header);
891
892            final int headerPx = measureOverlayHeight(header);
893            final int footerPx = measureOverlayHeight(footer);
894            error += mWebView.screenPxToWebPxError(headerPx)
895                    + mWebView.screenPxToWebPxError(footerPx);
896
897            // When the error becomes greater than 1 pixel, make the next header 1 pixel taller
898            int correction = 0;
899            if (error >= 1) {
900                correction = 1;
901                error -= 1;
902            }
903
904            mTemplates.appendMessageHtml(msg, false /* expanded */,
905                    alwaysShowImages || msg.alwaysShowImages,
906                    mWebView.screenPxToWebPx(headerPx) + correction,
907                    mWebView.screenPxToWebPx(footerPx));
908            replacements.add(header);
909            replacements.add(footer);
910
911            mViewState.setExpansionState(msg, ExpansionState.COLLAPSED);
912        }
913
914        mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
915        mAdapter.notifyDataSetChanged();
916
917        return mTemplates.emit();
918    }
919
920    protected int measureOverlayHeight(int position) {
921        return measureOverlayHeight(mAdapter.getItem(position));
922    }
923
924    /**
925     * Measure the height of an adapter view by rendering an adapter item into a temporary
926     * host view, and asking the view to immediately measure itself. This method will reuse
927     * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
928     * earlier.
929     * <p>
930     * After measuring the height, this method also saves the height in the
931     * {@link ConversationOverlayItem} for later use in overlay positioning.
932     *
933     * @param convItem adapter item with data to render and measure
934     * @return height of the rendered view in screen px
935     */
936    private int measureOverlayHeight(ConversationOverlayItem convItem) {
937        final int type = convItem.getType();
938
939        final View convertView = mConversationContainer.getScrapView(type);
940        final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
941                true /* measureOnly */);
942        if (convertView == null) {
943            mConversationContainer.addScrapView(type, hostView);
944        }
945
946        final int heightPx = mConversationContainer.measureOverlay(hostView);
947        convItem.setHeight(heightPx);
948        convItem.markMeasurementValid();
949
950        return heightPx;
951    }
952
953    @Override
954    public void onConversationViewHeaderHeightChange(int newHeight) {
955        final int h = mWebView.screenPxToWebPx(newHeight);
956
957        mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h));
958    }
959
960    // END conversation header callbacks
961
962    // START conversation footer callbacks
963
964    @Override
965    public void onConversationFooterHeightChange(int newHeight) {
966        final int h = mWebView.screenPxToWebPx(newHeight);
967
968        mWebView.loadUrl(String.format("javascript:setConversationFooterSpacerHeight(%s);", h));
969    }
970
971    // END conversation footer callbacks
972
973    // START message header callbacks
974    @Override
975    public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
976        mConversationContainer.invalidateSpacerGeometry();
977
978        // update message HTML spacer height
979        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
980        LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
981                newSpacerHeightPx);
982        mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);",
983                mTemplates.getMessageDomId(item.getMessage()), h));
984    }
985
986    @Override
987    public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
988        mConversationContainer.invalidateSpacerGeometry();
989
990        // show/hide the HTML message body and update the spacer height
991        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
992        LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
993                item.isExpanded(), h, newSpacerHeightPx);
994        mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);",
995                mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h));
996
997        mViewState.setExpansionState(item.getMessage(),
998                item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED);
999    }
1000
1001    @Override
1002    public void showExternalResources(final Message msg) {
1003        mViewState.setShouldShowImages(msg, true);
1004        mWebView.getSettings().setBlockNetworkImage(false);
1005        mWebView.loadUrl("javascript:unblockImages(['" + mTemplates.getMessageDomId(msg) + "']);");
1006    }
1007
1008    @Override
1009    public void showExternalResources(final String senderRawAddress) {
1010        mWebView.getSettings().setBlockNetworkImage(false);
1011
1012        final Address sender = getAddress(senderRawAddress);
1013        final MessageCursor cursor = getMessageCursor();
1014
1015        final List<String> messageDomIds = new ArrayList<String>();
1016
1017        int pos = -1;
1018        while (cursor.moveToPosition(++pos)) {
1019            final ConversationMessage message = cursor.getMessage();
1020            if (sender.equals(getAddress(message.getFrom()))) {
1021                message.alwaysShowImages = true;
1022
1023                mViewState.setShouldShowImages(message, true);
1024                messageDomIds.add(mTemplates.getMessageDomId(message));
1025            }
1026        }
1027
1028        final String url = String.format(
1029                "javascript:unblockImages(['%s']);", TextUtils.join("','", messageDomIds));
1030        mWebView.loadUrl(url);
1031    }
1032
1033    @Override
1034    public boolean supportsMessageTransforms() {
1035        return true;
1036    }
1037
1038    @Override
1039    public String getMessageTransforms(final Message msg) {
1040        final String domId = mTemplates.getMessageDomId(msg);
1041        return (domId == null) ? null : mMessageTransforms.get(domId);
1042    }
1043
1044    @Override
1045    public boolean isSecure() {
1046        return false;
1047    }
1048
1049    // END message header callbacks
1050
1051    @Override
1052    public void showUntransformedConversation() {
1053        super.showUntransformedConversation();
1054        renderConversation(getMessageCursor());
1055    }
1056
1057    @Override
1058    public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
1059        MessageCursor cursor = getMessageCursor();
1060        if (cursor == null || !mViewsCreated) {
1061            return;
1062        }
1063
1064        mTempBodiesHtml = renderCollapsedHeaders(cursor, item);
1065        mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
1066        mConversationContainer.focusFirstMessageHeader();
1067    }
1068
1069    private void showNewMessageNotification(NewMessagesInfo info) {
1070        mNewMessageBar.setText(info.getNotificationText());
1071        mNewMessageBar.setVisibility(View.VISIBLE);
1072    }
1073
1074    private void onNewMessageBarClick() {
1075        mNewMessageBar.setVisibility(View.GONE);
1076
1077        renderConversation(getMessageCursor()); // mCursor is already up-to-date
1078                                                // per onLoadFinished()
1079    }
1080
1081    private static OverlayPosition[] parsePositions(final int[] topArray, final int[] bottomArray) {
1082        final int len = topArray.length;
1083        final OverlayPosition[] positions = new OverlayPosition[len];
1084        for (int i = 0; i < len; i++) {
1085            positions[i] = new OverlayPosition(topArray[i], bottomArray[i]);
1086        }
1087        return positions;
1088    }
1089
1090    protected Address getAddress(String rawFrom) {
1091        return Utils.getAddress(mAddressCache, rawFrom);
1092    }
1093
1094    private void ensureContentSizeChangeListener() {
1095        if (mWebViewSizeChangeListener == null) {
1096            mWebViewSizeChangeListener = new ContentSizeChangeListener() {
1097                @Override
1098                public void onHeightChange(int h) {
1099                    // When WebKit says the DOM height has changed, re-measure
1100                    // bodies and re-position their headers.
1101                    // This is separate from the typical JavaScript DOM change
1102                    // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM
1103                    // events.
1104                    mWebView.loadUrl("javascript:measurePositions();");
1105                }
1106            };
1107        }
1108        mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener);
1109    }
1110
1111    public static boolean isOverviewMode(Account acct) {
1112        return acct.settings.isOverviewMode();
1113    }
1114
1115    private void setupOverviewMode() {
1116        // for now, overview mode means use the built-in WebView zoom and disable custom scale
1117        // gesture handling
1118        final boolean overviewMode = isOverviewMode(mAccount);
1119        final WebSettings settings = mWebView.getSettings();
1120        final WebSettings.LayoutAlgorithm layout;
1121        settings.setUseWideViewPort(overviewMode);
1122        settings.setSupportZoom(overviewMode);
1123        settings.setBuiltInZoomControls(overviewMode);
1124        settings.setLoadWithOverviewMode(overviewMode);
1125        if (overviewMode) {
1126            settings.setDisplayZoomControls(false);
1127            layout = WebSettings.LayoutAlgorithm.NORMAL;
1128        } else {
1129            layout = WebSettings.LayoutAlgorithm.NARROW_COLUMNS;
1130        }
1131        settings.setLayoutAlgorithm(layout);
1132    }
1133
1134    @Override
1135    public ConversationMessage getMessageForClickedUrl(String url) {
1136        final String domMessageId = mUrlToMessageIdMap.get(url);
1137        if (domMessageId == null) {
1138            return null;
1139        }
1140        final String messageId = mTemplates.getMessageIdForDomId(domMessageId);
1141        return getMessageCursor().getMessageForId(Long.parseLong(messageId));
1142    }
1143
1144    @Override
1145    public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
1146        if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) {
1147            mOriginalKeyedView = view;
1148        }
1149
1150        if (mOriginalKeyedView != null) {
1151            final int id = mOriginalKeyedView.getId();
1152            final boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP;
1153            final boolean isLeft = keyCode == KeyEvent.KEYCODE_DPAD_LEFT;
1154            final boolean isRight = keyCode == KeyEvent.KEYCODE_DPAD_RIGHT;
1155            final boolean isUp = keyCode == KeyEvent.KEYCODE_DPAD_UP;
1156            final boolean isDown = keyCode == KeyEvent.KEYCODE_DPAD_DOWN;
1157
1158            // First we run the event by the controller
1159            // We manually check if the view+direction combination should shift focus away from the
1160            // conversation view to the thread list in two-pane landscape mode.
1161            final boolean isTwoPaneLand = mNavigationController.isTwoPaneLandscape();
1162            final boolean navigateAway = mConversationContainer.shouldNavigateAway(id, isLeft,
1163                    isTwoPaneLand);
1164            if (mNavigationController.onInterceptKeyFromCV(keyCode, keyEvent, navigateAway)) {
1165                return true;
1166            }
1167
1168            // If controller didn't handle the event, check directional interception.
1169            if ((isLeft || isRight) && mConversationContainer.shouldInterceptLeftRightEvents(
1170                    id, isLeft, isRight, isTwoPaneLand)) {
1171                return true;
1172            } else if (isUp || isDown) {
1173                // We don't do anything on up/down for overlay
1174                if (id == R.id.conversation_topmost_overlay) {
1175                    return true;
1176                }
1177
1178                // We manually handle up/down navigation through the overlay items because the
1179                // system's default isn't optimal for two-pane landscape since it's not a real list.
1180                final int position = mConversationContainer.getViewPosition(mOriginalKeyedView);
1181                final View next = mConversationContainer.getNextOverlayView(position, isDown);
1182                if (next != null) {
1183                    if (isActionUp) {
1184                        next.requestFocus();
1185
1186                        // Make sure that v is in view
1187                        final int[] coords = new int[2];
1188                        next.getLocationOnScreen(coords);
1189                        final int bottom = coords[1] + next.getHeight();
1190                        if (bottom > mMaxScreenHeight) {
1191                            mWebView.scrollBy(0, bottom - mMaxScreenHeight);
1192                        } else if (coords[1] < mTopOfVisibleScreen) {
1193                            mWebView.scrollBy(0, coords[1] - mTopOfVisibleScreen);
1194                        }
1195                    }
1196                    return true;
1197                } else {
1198                    // Special case two end points
1199                    // Start is marked as index 1 because we are currently not allowing focus on
1200                    // conversation view header.
1201                    if ((position == mConversationContainer.getOverlayCount() - 1 && isDown) ||
1202                            (position == 1 && isUp)) {
1203                        mTopmostOverlay.requestFocus();
1204                        // Scroll to the the top if we hit the first item
1205                        if (isUp) {
1206                            mWebView.scrollTo(0, 0);
1207                        }
1208                        return true;
1209                    }
1210                }
1211            }
1212
1213            // Finally we handle the special keys
1214            if (keyCode == KeyEvent.KEYCODE_BACK && id != R.id.conversation_topmost_overlay) {
1215                if (isActionUp) {
1216                    mTopmostOverlay.requestFocus();
1217                }
1218                return true;
1219            } else if (keyCode == KeyEvent.KEYCODE_ENTER &&
1220                    id == R.id.conversation_topmost_overlay) {
1221                if (isActionUp) {
1222                    mConversationContainer.focusFirstMessageHeader();
1223                    mWebView.scrollTo(0, 0);
1224                }
1225                return true;
1226            }
1227        }
1228        return false;
1229    }
1230
1231    public class ConversationWebViewClient extends AbstractConversationWebViewClient {
1232        public ConversationWebViewClient(Account account) {
1233            super(account);
1234        }
1235
1236        @Override
1237        public void onPageFinished(WebView view, String url) {
1238            // Ignore unsafe calls made after a fragment is detached from an activity.
1239            // This method needs to, for example, get at the loader manager, which needs
1240            // the fragment to be added.
1241            if (!isAdded() || !mViewsCreated) {
1242                LogUtils.d(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
1243                        ConversationViewFragment.this);
1244                return;
1245            }
1246
1247            LogUtils.d(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s wv=%s t=%sms", url,
1248                    ConversationViewFragment.this, view,
1249                    (SystemClock.uptimeMillis() - mWebViewLoadStartMs));
1250
1251            ensureContentSizeChangeListener();
1252
1253            if (!mEnableContentReadySignal) {
1254                revealConversation();
1255            }
1256
1257            final Set<String> emailAddresses = Sets.newHashSet();
1258            final List<Address> cacheCopy;
1259            synchronized (mAddressCache) {
1260                cacheCopy = ImmutableList.copyOf(mAddressCache.values());
1261            }
1262            for (Address addr : cacheCopy) {
1263                emailAddresses.add(addr.getAddress());
1264            }
1265            final ContactLoaderCallbacks callbacks = getContactInfoSource();
1266            callbacks.setSenders(emailAddresses);
1267            getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks);
1268        }
1269
1270        @Override
1271        public boolean shouldOverrideUrlLoading(WebView view, String url) {
1272            return mViewsCreated && super.shouldOverrideUrlLoading(view, url);
1273        }
1274    }
1275
1276    /**
1277     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
1278     * via reflection and not stripped.
1279     *
1280     */
1281    private class MailJsBridge {
1282        @JavascriptInterface
1283        public void onWebContentGeometryChange(final int[] overlayTopStrs,
1284                final int[] overlayBottomStrs) {
1285            try {
1286                getHandler().post(new FragmentRunnable("onWebContentGeometryChange",
1287                        ConversationViewFragment.this) {
1288                    @Override
1289                    public void go() {
1290                        if (!mViewsCreated) {
1291                            LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views"
1292                                    + " are gone, %s", ConversationViewFragment.this);
1293                            return;
1294                        }
1295                        mConversationContainer.onGeometryChange(
1296                                parsePositions(overlayTopStrs, overlayBottomStrs));
1297                        if (mDiff != 0) {
1298                            // SCROLL!
1299                            int scale = (int) (mWebView.getScale() / mWebView.getInitialScale());
1300                            if (scale > 1) {
1301                                mWebView.scrollBy(0, (mDiff * (scale - 1)));
1302                            }
1303                            mDiff = 0;
1304                        }
1305                    }
1306                });
1307            } catch (Throwable t) {
1308                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
1309            }
1310        }
1311
1312        @JavascriptInterface
1313        public String getTempMessageBodies() {
1314            try {
1315                if (!mViewsCreated) {
1316                    return "";
1317                }
1318
1319                final String s = mTempBodiesHtml;
1320                mTempBodiesHtml = null;
1321                return s;
1322            } catch (Throwable t) {
1323                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
1324                return "";
1325            }
1326        }
1327
1328        @JavascriptInterface
1329        public String getMessageBody(String domId) {
1330            try {
1331                final MessageCursor cursor = getMessageCursor();
1332                if (!mViewsCreated || cursor == null) {
1333                    return "";
1334                }
1335
1336                int pos = -1;
1337                while (cursor.moveToPosition(++pos)) {
1338                    final ConversationMessage msg = cursor.getMessage();
1339                    if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
1340                        return HtmlConversationTemplates.wrapMessageBody(msg.getBodyAsHtml());
1341                    }
1342                }
1343
1344                return "";
1345
1346            } catch (Throwable t) {
1347                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody");
1348                return "";
1349            }
1350        }
1351
1352        @JavascriptInterface
1353        public String getMessageSender(String domId) {
1354            try {
1355                final MessageCursor cursor = getMessageCursor();
1356                if (!mViewsCreated || cursor == null) {
1357                    return "";
1358                }
1359
1360                int pos = -1;
1361                while (cursor.moveToPosition(++pos)) {
1362                    final ConversationMessage msg = cursor.getMessage();
1363                    if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
1364                        return getAddress(msg.getFrom()).getAddress();
1365                    }
1366                }
1367
1368                return "";
1369
1370            } catch (Throwable t) {
1371                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageSender");
1372                return "";
1373            }
1374        }
1375
1376        @JavascriptInterface
1377        public void onContentReady() {
1378            try {
1379                getHandler().post(new FragmentRunnable("onContentReady",
1380                        ConversationViewFragment.this) {
1381                    @Override
1382                    public void go() {
1383                        try {
1384                            if (mWebViewLoadStartMs != 0) {
1385                                LogUtils.i(LOG_TAG, "IN CVF.onContentReady, f=%s vis=%s t=%sms",
1386                                        ConversationViewFragment.this,
1387                                        isUserVisible(),
1388                                        (SystemClock.uptimeMillis() - mWebViewLoadStartMs));
1389                            }
1390                            revealConversation();
1391                        } catch (Throwable t) {
1392                            LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
1393                            // Still try to show the conversation.
1394                            revealConversation();
1395                        }
1396                    }
1397                });
1398            } catch (Throwable t) {
1399                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
1400            }
1401        }
1402
1403        @JavascriptInterface
1404        public float getScrollYPercent() {
1405            try {
1406                return mWebViewYPercent;
1407            } catch (Throwable t) {
1408                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent");
1409                return 0f;
1410            }
1411        }
1412
1413        @JavascriptInterface
1414        public void onMessageTransform(String messageDomId, String transformText) {
1415            try {
1416                LogUtils.i(LOG_TAG, "TRANSFORM: (%s) %s", messageDomId, transformText);
1417                mMessageTransforms.put(messageDomId, transformText);
1418                onConversationTransformed();
1419            } catch (Throwable t) {
1420                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onMessageTransform");
1421            }
1422        }
1423
1424        @JavascriptInterface
1425        public void onInlineAttachmentsParsed(final String[] urls, final String[] messageIds) {
1426            try {
1427                getHandler().post(new FragmentRunnable("onInlineAttachmentsParsed",
1428                        ConversationViewFragment.this) {
1429                    @Override
1430                    public void go() {
1431                        try {
1432                            for (int i = 0, size = urls.length; i < size; i++) {
1433                                mUrlToMessageIdMap.put(urls[i], messageIds[i]);
1434                            }
1435                        } catch (ArrayIndexOutOfBoundsException e) {
1436                            LogUtils.e(LOG_TAG, e,
1437                                    "Number of urls does not match number of message ids - %s:%s",
1438                                    urls.length, messageIds.length);
1439                        }
1440                    }
1441                });
1442            } catch (Throwable t) {
1443                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onInlineAttachmentsParsed");
1444            }
1445        }
1446    }
1447
1448    private class NewMessagesInfo {
1449        int count;
1450        int countFromSelf;
1451        String senderAddress;
1452
1453        /**
1454         * Return the display text for the new message notification overlay. It will be formatted
1455         * appropriately for a single new message vs. multiple new messages.
1456         *
1457         * @return display text
1458         */
1459        public String getNotificationText() {
1460            Resources res = getResources();
1461            if (count > 1) {
1462                return res.getQuantityString(R.plurals.new_incoming_messages_many, count, count);
1463            } else {
1464                final Address addr = getAddress(senderAddress);
1465                return res.getString(R.string.new_incoming_messages_one,
1466                        mBidiFormatter.unicodeWrap(TextUtils.isEmpty(addr.getPersonal())
1467                                ? addr.getAddress() : addr.getPersonal()));
1468            }
1469        }
1470    }
1471
1472    @Override
1473    public void onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader,
1474            MessageCursor newCursor, MessageCursor oldCursor) {
1475        /*
1476         * what kind of changes affect the MessageCursor? 1. new message(s) 2.
1477         * read/unread state change 3. deleted message, either regular or draft
1478         * 4. updated message, either from self or from others, updated in
1479         * content or state or sender 5. star/unstar of message (technically
1480         * similar to #1) 6. other label change Use MessageCursor.hashCode() to
1481         * sort out interesting vs. no-op cursor updates.
1482         */
1483
1484        if (oldCursor != null && !oldCursor.isClosed()) {
1485            final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor);
1486
1487            if (info.count > 0) {
1488                // don't immediately render new incoming messages from other
1489                // senders
1490                // (to avoid a new message from losing the user's focus)
1491                LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1492                        + ", holding cursor for new incoming message (%s)", this);
1493                showNewMessageNotification(info);
1494                return;
1495            }
1496
1497            final int oldState = oldCursor.getStateHashCode();
1498            final boolean changed = newCursor.getStateHashCode() != oldState;
1499
1500            if (!changed) {
1501                final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor);
1502                if (processedInPlace) {
1503                    LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this);
1504                } else {
1505                    LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update"
1506                            + ", ignoring this conversation update (%s)", this);
1507                }
1508                return;
1509            } else if (info.countFromSelf == 1) {
1510                // Special-case the very common case of a new cursor that is the same as the old
1511                // one, except that there is a new message from yourself. This happens upon send.
1512                final boolean sameExceptNewLast = newCursor.getStateHashCode(1) == oldState;
1513                if (sameExceptNewLast) {
1514                    LogUtils.i(LOG_TAG, "CONV RENDER: update is a single new message from self"
1515                            + " (%s)", this);
1516                    newCursor.moveToLast();
1517                    processNewOutgoingMessage(newCursor.getMessage());
1518                    return;
1519                }
1520            }
1521            // cursors are different, and not due to an incoming message. fall
1522            // through and render.
1523            LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1524                    + ", but not due to incoming message. rendering. (%s)", this);
1525
1526            if (DEBUG_DUMP_CURSOR_CONTENTS) {
1527                LogUtils.i(LOG_TAG, "old cursor: %s", oldCursor.getDebugDump());
1528                LogUtils.i(LOG_TAG, "new cursor: %s", newCursor.getDebugDump());
1529            }
1530        } else {
1531            LogUtils.i(LOG_TAG, "CONV RENDER: initial render. (%s)", this);
1532            timerMark("message cursor load finished");
1533        }
1534
1535        renderContent(newCursor);
1536    }
1537
1538    protected void renderContent(MessageCursor messageCursor) {
1539        // if layout hasn't happened, delay render
1540        // This is needed in addition to the showConversation() delay to speed
1541        // up rotation and restoration.
1542        if (mConversationContainer.getWidth() == 0) {
1543            mNeedRender = true;
1544            mConversationContainer.addOnLayoutChangeListener(this);
1545        } else {
1546            renderConversation(messageCursor);
1547        }
1548    }
1549
1550    private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
1551        final NewMessagesInfo info = new NewMessagesInfo();
1552
1553        int pos = -1;
1554        while (newCursor.moveToPosition(++pos)) {
1555            final Message m = newCursor.getMessage();
1556            if (!mViewState.contains(m)) {
1557                LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
1558
1559                final Address from = getAddress(m.getFrom());
1560                // distinguish ours from theirs
1561                // new messages from the account owner should not trigger a
1562                // notification
1563                if (from == null || mAccount.ownsFromAddress(from.getAddress())) {
1564                    LogUtils.i(LOG_TAG, "found message from self: %s", m.uri);
1565                    info.countFromSelf++;
1566                    continue;
1567                }
1568
1569                info.count++;
1570                info.senderAddress = m.getFrom();
1571            }
1572        }
1573        return info;
1574    }
1575
1576    private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) {
1577        final Set<String> idsOfChangedBodies = Sets.newHashSet();
1578        final List<Integer> changedOverlayPositions = Lists.newArrayList();
1579
1580        boolean changed = false;
1581
1582        int pos = 0;
1583        while (true) {
1584            if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) {
1585                break;
1586            }
1587
1588            final ConversationMessage newMsg = newCursor.getMessage();
1589            final ConversationMessage oldMsg = oldCursor.getMessage();
1590
1591            // We are going to update the data in the adapter whenever any input fields change.
1592            // This ensures that the Message object that ComposeActivity uses will be correctly
1593            // aligned with the most up-to-date data.
1594            if (!newMsg.isEqual(oldMsg)) {
1595                mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions);
1596                LogUtils.i(LOG_TAG, "msg #%d (%d): detected field(s) change. sendingState=%s",
1597                        pos, newMsg.id, newMsg.sendingState);
1598            }
1599
1600            // update changed message bodies in-place
1601            if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) ||
1602                    !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) {
1603                // maybe just set a flag to notify JS to re-request changed bodies
1604                idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"');
1605                LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id);
1606            }
1607
1608            pos++;
1609        }
1610
1611
1612        if (!changedOverlayPositions.isEmpty()) {
1613            // notify once after the entire adapter is updated
1614            mConversationContainer.onOverlayModelUpdate(changedOverlayPositions);
1615            changed = true;
1616        }
1617
1618        final ConversationFooterItem footerItem = mAdapter.getFooterItem();
1619        if (footerItem != null) {
1620            footerItem.invalidateMeasurement();
1621        }
1622        if (!idsOfChangedBodies.isEmpty()) {
1623            mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);",
1624                    TextUtils.join(",", idsOfChangedBodies)));
1625            changed = true;
1626        }
1627
1628        return changed;
1629    }
1630
1631    private void processNewOutgoingMessage(ConversationMessage msg) {
1632        // Temporarily remove the ConversationFooterItem and its view.
1633        // It will get re-added right after the new message is added.
1634        final ConversationFooterItem footerItem = mAdapter.removeFooterItem();
1635        mConversationContainer.removeViewAtAdapterIndex(footerItem.getPosition());
1636        mTemplates.reset();
1637        // this method will add some items to mAdapter, but we deliberately want to avoid notifying
1638        // adapter listeners (i.e. ConversationContainer) until onWebContentGeometryChange is next
1639        // called, to prevent N+1 headers rendering with N message bodies.
1640        renderMessage(msg, true /* expanded */, msg.alwaysShowImages);
1641        mTempBodiesHtml = mTemplates.emit();
1642
1643        if (footerItem != null) {
1644            footerItem.setLastMessageHeaderItem(getLastMessageHeaderItem());
1645            footerItem.invalidateMeasurement();
1646            mAdapter.addItem(footerItem);
1647        }
1648
1649        mViewState.setExpansionState(msg, ExpansionState.EXPANDED);
1650        // FIXME: should the provider set this as initial state?
1651        mViewState.setReadState(msg, false /* read */);
1652
1653        // From now until the updated spacer geometry is returned, the adapter items are mismatched
1654        // with the existing spacers. Do not let them layout.
1655        mConversationContainer.invalidateSpacerGeometry();
1656
1657        mWebView.loadUrl("javascript:appendMessageHtml();");
1658    }
1659
1660    private static class SetCookieTask extends AsyncTask<Void, Void, Void> {
1661        private final Context mContext;
1662        private final String mUri;
1663        private final Uri mAccountCookieQueryUri;
1664        private final ContentResolver mResolver;
1665
1666        /* package */ SetCookieTask(Context context, String baseUri, Uri accountCookieQueryUri) {
1667            mContext = context;
1668            mUri = baseUri;
1669            mAccountCookieQueryUri = accountCookieQueryUri;
1670            mResolver = context.getContentResolver();
1671        }
1672
1673        @Override
1674        public Void doInBackground(Void... args) {
1675            // First query for the cookie string from the UI provider
1676            final Cursor cookieCursor = mResolver.query(mAccountCookieQueryUri,
1677                    UIProvider.ACCOUNT_COOKIE_PROJECTION, null, null, null);
1678            if (cookieCursor == null) {
1679                return null;
1680            }
1681
1682            try {
1683                if (cookieCursor.moveToFirst()) {
1684                    final String cookie = cookieCursor.getString(
1685                            cookieCursor.getColumnIndex(UIProvider.AccountCookieColumns.COOKIE));
1686
1687                    if (cookie != null) {
1688                        final CookieSyncManager csm =
1689                                CookieSyncManager.createInstance(mContext);
1690                        CookieManager.getInstance().setCookie(mUri, cookie);
1691                        csm.sync();
1692                    }
1693                }
1694
1695            } finally {
1696                cookieCursor.close();
1697            }
1698
1699
1700            return null;
1701        }
1702    }
1703
1704    @Override
1705    public void onConversationUpdated(Conversation conv) {
1706        final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer
1707                .findViewById(R.id.conversation_header);
1708        mConversation = conv;
1709        if (headerView != null) {
1710            headerView.onConversationUpdated(conv);
1711        }
1712    }
1713
1714    @Override
1715    public void onLayoutChange(View v, int left, int top, int right,
1716            int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
1717        boolean sizeChanged = mNeedRender
1718                && mConversationContainer.getWidth() != 0;
1719        if (sizeChanged) {
1720            mNeedRender = false;
1721            mConversationContainer.removeOnLayoutChangeListener(this);
1722            renderConversation(getMessageCursor());
1723        }
1724    }
1725
1726    @Override
1727    public void setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded, int heightBefore) {
1728        mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore);
1729    }
1730
1731    /**
1732     * @return {@code true} because either the Print or Print All menu item is shown in GMail
1733     */
1734    @Override
1735    protected boolean shouldShowPrintInOverflow() {
1736        return true;
1737    }
1738
1739    @Override
1740    protected void printConversation() {
1741        PrintUtils.printConversation(mActivity.getActivityContext(), getMessageCursor(),
1742                mAddressCache, mConversation.getBaseUri(mBaseUri), true /* useJavascript */);
1743    }
1744}
1745