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