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