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