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