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