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