ConversationViewFragment.java revision 4d6384e8c8d4bba45c2744b137cf90ebf5cc63a4
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)", consoleMessage.message(),
390                        consoleMessage.sourceId(), consoleMessage.lineNumber());
391                return true;
392            }
393        };
394        mWebView.setWebChromeClient(wcc);
395
396        final WebSettings settings = mWebView.getSettings();
397
398        mScrollIndicators = (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators);
399        mScrollIndicators.setSourceView(mWebView);
400
401        settings.setJavaScriptEnabled(true);
402
403        final float fontScale = getResources().getConfiguration().fontScale;
404        final int desiredFontSizePx = getResources()
405                .getInteger(R.integer.conversation_desired_font_size_px);
406        final int unstyledFontSizePx = getResources()
407                .getInteger(R.integer.conversation_unstyled_font_size_px);
408
409        int textZoom = settings.getTextZoom();
410        // apply a correction to the default body text style to get regular text to the size we want
411        textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx;
412        // then apply any system font scaling
413        textZoom = (int) (textZoom * fontScale);
414        settings.setTextZoom(textZoom);
415
416        mMessageGroup = rootView.findViewById(R.id.message_group);
417        mMessageInnerGroup = mMessageGroup.findViewById(R.id.message_inner_group);
418        mMessageView = (MessageView) mMessageGroup.findViewById(R.id.message_view);
419        mMessageGroup.findViewById(R.id.message_view_close).setOnClickListener(
420                new View.OnClickListener() {
421            @Override
422            public void onClick(View v) {
423                dismissWhooshMode();
424            }
425        });
426        mMessageView.setWebChromeClient(wcc);
427        mMessageView.setContentSizeChangeListener(new ContentSizeChangeListener() {
428            @Override
429            public void onHeightChange(final int h) {
430                if (h == 0 || !mMessageViewIsLoading) {
431                    return;
432                }
433                mMessageViewIsLoading = false;
434                getHandler().post(new FragmentRunnable("MessageView.onHeightChange") {
435                    @Override
436                    public void go() {
437                        LogUtils.i(LOG_TAG, "message view content height=%d initialScrollX/Y=%s/%s",
438                                h, mMessageScrollXFraction, mMessageScrollYFraction);
439
440                        // restore scroll position within the message in conversation view
441                        final int hSlack = mMessageView.computeHorizontalScrollRange()
442                                - mMessageView.computeHorizontalScrollExtent();
443                        final int vSlack = mMessageView.computeVerticalScrollRange()
444                                - mMessageView.computeVerticalScrollExtent();
445                        final int x = Math.round(hSlack * mMessageScrollXFraction);
446                        final int y = Math.round(vSlack * mMessageScrollYFraction);
447                        mMessageView.scrollTo(x, y);
448                    }
449                });
450            }
451        });
452        mMessageView.getSettings().setJavaScriptEnabled(true);
453        mMessageView.addJavascriptInterface(mMessageJsBridge, "mail");
454
455        mViewsCreated = true;
456        mWebViewLoadedData = false;
457
458        return rootView;
459    }
460
461    @Override
462    public void onStart() {
463        super.onStart();
464
465        final ControllableActivity activity = (ControllableActivity) getActivity();
466        if (activity != null) {
467            activity.getUpOrBackController().addUpOrBackHandler(this);
468        }
469    }
470
471    @Override
472    public boolean onBackPressed() {
473        if (mMessageGroup.getVisibility() == View.VISIBLE) {
474            dismissWhooshMode();
475            return true;
476        }
477        return false;
478    }
479
480    @Override
481    public boolean onUpPressed() {
482        return false;
483    }
484
485    @Override
486    public void onStop() {
487        super.onStop();
488
489        final ControllableActivity activity = (ControllableActivity) getActivity();
490        if (activity != null) {
491            activity.getUpOrBackController().removeUpOrBackHandler(this);
492        }
493    }
494
495    @Override
496    public void onDestroyView() {
497        super.onDestroyView();
498        mConversationContainer.setOverlayAdapter(null);
499        mAdapter = null;
500        resetLoadWaiting(); // be sure to unregister any active load observer
501        mViewsCreated = false;
502    }
503
504    @Override
505    protected WebView getWebView() {
506        return mWebView;
507    }
508
509    @Override
510    public void onSaveInstanceState(Bundle outState) {
511        super.onSaveInstanceState(outState);
512
513        outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent());
514    }
515
516    private float calculateScrollYPercent() {
517        float p;
518        int scrollY = mWebView.getScrollY();
519        int viewH = mWebView.getHeight();
520        int webH = (int) (mWebView.getContentHeight() * mWebView.getScale());
521
522        if (webH == 0 || webH <= viewH) {
523            p = 0;
524        } else if (scrollY + viewH >= webH) {
525            // The very bottom is a special case, it acts as a stronger anchor than the scroll top
526            // at that point.
527            p = 1.0f;
528        } else {
529            p = (float) scrollY / webH;
530        }
531        return p;
532    }
533
534    private void resetLoadWaiting() {
535        if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) {
536            getListController().unregisterConversationLoadedObserver(mLoadedObserver);
537        }
538        mLoadWaitReason = LOAD_NOW;
539    }
540
541    @Override
542    protected void markUnread() {
543        super.markUnread();
544        // Ignore unsafe calls made after a fragment is detached from an activity
545        final ControllableActivity activity = (ControllableActivity) getActivity();
546        if (activity == null) {
547            LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
548            return;
549        }
550
551        if (mViewState == null) {
552            LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
553                    mConversation.id);
554            return;
555        }
556        activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
557                mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
558    }
559
560    @Override
561    public void onUserVisibleHintChanged() {
562        final boolean userVisible = isUserVisible();
563
564        if (!userVisible) {
565            dismissLoadingStatus();
566        } else if (mViewsCreated) {
567            if (getMessageCursor() != null) {
568                LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this);
569                onConversationSeen();
570            } else if (isLoadWaiting()) {
571                LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this);
572                handleDelayedConversationLoad();
573            }
574        }
575
576        if (mWebView != null) {
577            mWebView.onUserVisibilityChanged(userVisible);
578        }
579    }
580
581    /**
582     * Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do
583     * nothing (in which case you should later call {@link #handleDelayedConversationLoad()}).
584     */
585    private void showConversation() {
586        final int reason;
587
588        if (isUserVisible()) {
589            LogUtils.i(LOG_TAG,
590                    "SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this);
591            reason = LOAD_NOW;
592            timerMark("CVF.showConversation");
593        } else {
594            final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
595                    || (mConversation.isRemote
596                            || mConversation.getNumMessages() > mMaxAutoLoadMessages);
597
598            // When not visible, we should not immediately load if either this conversation is
599            // too heavyweight, or if the main/initial conversation is busy loading.
600            if (disableOffscreenLoading) {
601                reason = LOAD_WAIT_UNTIL_VISIBLE;
602                LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this);
603            } else if (getListController().isInitialConversationLoading()) {
604                reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION;
605                LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this);
606                getListController().registerConversationLoadedObserver(mLoadedObserver);
607            } else {
608                LogUtils.i(LOG_TAG,
609                        "SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)",
610                        this);
611                reason = LOAD_NOW;
612            }
613        }
614
615        mLoadWaitReason = reason;
616        if (mLoadWaitReason == LOAD_NOW) {
617            startConversationLoad();
618        }
619    }
620
621    private void handleDelayedConversationLoad() {
622        resetLoadWaiting();
623        startConversationLoad();
624    }
625
626    private void startConversationLoad() {
627        mWebView.setVisibility(View.VISIBLE);
628        getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks());
629        // TODO(mindyp): don't show loading status for a previously rendered
630        // conversation. Ielieve this is better done by making sure don't show loading status
631        // until XX ms have passed without loading completed.
632        showLoadingStatus();
633    }
634
635    private void revealConversation() {
636        timerMark("revealing conversation");
637        dismissLoadingStatus(mOnProgressDismiss);
638    }
639
640    private boolean isLoadWaiting() {
641        return mLoadWaitReason != LOAD_NOW;
642    }
643
644    private void renderConversation(MessageCursor messageCursor) {
645        final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal);
646        timerMark("rendered conversation");
647
648        if (DEBUG_DUMP_CONVERSATION_HTML) {
649            java.io.FileWriter fw = null;
650            try {
651                fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id
652                        + ".html");
653                fw.write(convHtml);
654            } catch (java.io.IOException e) {
655                e.printStackTrace();
656            } finally {
657                if (fw != null) {
658                    try {
659                        fw.close();
660                    } catch (java.io.IOException e) {
661                        e.printStackTrace();
662                    }
663                }
664            }
665        }
666
667        // save off existing scroll position before re-rendering
668        if (mWebViewLoadedData) {
669            mWebViewYPercent = calculateScrollYPercent();
670        }
671
672        mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
673        mWebViewLoadedData = true;
674        mWebViewLoadStartMs = SystemClock.uptimeMillis();
675    }
676
677    /**
678     * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
679     * conversation header), and return an HTML document with spacer divs inserted for all overlays.
680     *
681     */
682    private String renderMessageBodies(MessageCursor messageCursor,
683            boolean enableContentReadySignal) {
684        int pos = -1;
685
686        LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this);
687        boolean allowNetworkImages = false;
688
689        // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
690
691        // Walk through the cursor and build up an overlay adapter as you go.
692        // Each overlay has an entry in the adapter for easy scroll handling in the container.
693        // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
694        // When adding adapter items, also add their heights to help the container later determine
695        // overlay dimensions.
696
697        // When re-rendering, prevent ConversationContainer from laying out overlays until after
698        // the new spacers are positioned by WebView.
699        mConversationContainer.invalidateSpacerGeometry();
700
701        mAdapter.clear();
702
703        // re-evaluate the message parts of the view state, since the messages may have changed
704        // since the previous render
705        final ConversationViewState prevState = mViewState;
706        mViewState = new ConversationViewState(prevState);
707
708        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
709        // a pixel is an mdpi pixel, unless you set device-dpi.
710
711        // add a single conversation header item
712        final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
713        final int convHeaderPx = measureOverlayHeight(convHeaderPos);
714
715        mTemplates.startConversation(mWebView.screenPxToWebPx(mSideMarginPx),
716                mWebView.screenPxToWebPx(convHeaderPx));
717
718        int collapsedStart = -1;
719        ConversationMessage prevCollapsedMsg = null;
720        boolean prevSafeForImages = false;
721
722        while (messageCursor.moveToPosition(++pos)) {
723            final ConversationMessage msg = messageCursor.getMessage();
724
725            final boolean safeForImages =
726                    msg.alwaysShowImages || prevState.getShouldShowImages(msg);
727            allowNetworkImages |= safeForImages;
728
729            final Integer savedExpanded = prevState.getExpansionState(msg);
730            final int expandedState;
731            if (savedExpanded != null) {
732                if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) {
733                    // override saved state when this is now the new last message
734                    // this happens to the second-to-last message when you discard a draft
735                    expandedState = ExpansionState.EXPANDED;
736                } else {
737                    expandedState = savedExpanded;
738                }
739            } else {
740                // new messages that are not expanded default to being eligible for super-collapse
741                expandedState = (!msg.read || msg.starred || messageCursor.isLast()) ?
742                        ExpansionState.EXPANDED : ExpansionState.SUPER_COLLAPSED;
743            }
744            mViewState.setShouldShowImages(msg, prevState.getShouldShowImages(msg));
745            mViewState.setExpansionState(msg, expandedState);
746
747            // save off "read" state from the cursor
748            // later, the view may not match the cursor (e.g. conversation marked read on open)
749            // however, if a previous state indicated this message was unread, trust that instead
750            // so "mark unread" marks all originally unread messages
751            mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg));
752
753            // We only want to consider this for inclusion in the super collapsed block if
754            // 1) The we don't have previous state about this message  (The first time that the
755            //    user opens a conversation)
756            // 2) The previously saved state for this message indicates that this message is
757            //    in the super collapsed block.
758            if (ExpansionState.isSuperCollapsed(expandedState)) {
759                // contribute to a super-collapsed block that will be emitted just before the
760                // next expanded header
761                if (collapsedStart < 0) {
762                    collapsedStart = pos;
763                }
764                prevCollapsedMsg = msg;
765                prevSafeForImages = safeForImages;
766                continue;
767            }
768
769            // resolve any deferred decisions on previous collapsed items
770            if (collapsedStart >= 0) {
771                if (pos - collapsedStart == 1) {
772                    // special-case for a single collapsed message: no need to super-collapse it
773                    renderMessage(prevCollapsedMsg, false /* expanded */,
774                            prevSafeForImages);
775                } else {
776                    renderSuperCollapsedBlock(collapsedStart, pos - 1);
777                }
778                prevCollapsedMsg = null;
779                collapsedStart = -1;
780            }
781
782            renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages);
783        }
784
785        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
786
787        // If the conversation has specified a base uri, use it here, otherwise use mBaseUri
788        return mTemplates.endConversation(mBaseUri, mConversation.getBaseUri(mBaseUri), 320,
789                mWebView.getViewportWidth(), enableContentReadySignal, isOverviewMode(mAccount));
790    }
791
792    private void renderSuperCollapsedBlock(int start, int end) {
793        final int blockPos = mAdapter.addSuperCollapsedBlock(start, end);
794        final int blockPx = measureOverlayHeight(blockPos);
795        mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
796    }
797
798    private void renderMessage(ConversationMessage msg, boolean expanded,
799            boolean safeForImages) {
800        final int headerPos = mAdapter.addMessageHeader(msg, expanded,
801                mViewState.getShouldShowImages(msg));
802        final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
803
804        final int footerPos = mAdapter.addMessageFooter(headerItem);
805
806        // Measure item header and footer heights to allocate spacers in HTML
807        // But since the views themselves don't exist yet, render each item temporarily into
808        // a host view for measurement.
809        final int headerPx = measureOverlayHeight(headerPos);
810        final int footerPx = measureOverlayHeight(footerPos);
811
812        mTemplates.appendMessageHtml(msg, expanded, safeForImages,
813                mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
814        timerMark("rendered message");
815    }
816
817    private String renderCollapsedHeaders(MessageCursor cursor,
818            SuperCollapsedBlockItem blockToReplace) {
819        final List<ConversationOverlayItem> replacements = Lists.newArrayList();
820
821        mTemplates.reset();
822
823        // In devices with non-integral density multiplier, screen pixels translate to non-integral
824        // web pixels. Keep track of the error that occurs when we cast all heights to int
825        float error = 0f;
826        for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
827            cursor.moveToPosition(i);
828            final ConversationMessage msg = cursor.getMessage();
829            final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg,
830                    false /* expanded */, mViewState.getShouldShowImages(msg));
831            final MessageFooterItem footer = mAdapter.newMessageFooterItem(header);
832
833            final int headerPx = measureOverlayHeight(header);
834            final int footerPx = measureOverlayHeight(footer);
835            error += mWebView.screenPxToWebPxError(headerPx)
836                    + mWebView.screenPxToWebPxError(footerPx);
837
838            // When the error becomes greater than 1 pixel, make the next header 1 pixel taller
839            int correction = 0;
840            if (error >= 1) {
841                correction = 1;
842                error -= 1;
843            }
844
845            mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages,
846                    mWebView.screenPxToWebPx(headerPx) + correction,
847                    mWebView.screenPxToWebPx(footerPx));
848            replacements.add(header);
849            replacements.add(footer);
850
851            mViewState.setExpansionState(msg, ExpansionState.COLLAPSED);
852        }
853
854        mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
855        mAdapter.notifyDataSetChanged();
856
857        return mTemplates.emit();
858    }
859
860    private int measureOverlayHeight(int position) {
861        return measureOverlayHeight(mAdapter.getItem(position));
862    }
863
864    /**
865     * Measure the height of an adapter view by rendering an adapter item into a temporary
866     * host view, and asking the view to immediately measure itself. This method will reuse
867     * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
868     * earlier.
869     * <p>
870     * After measuring the height, this method also saves the height in the
871     * {@link ConversationOverlayItem} for later use in overlay positioning.
872     *
873     * @param convItem adapter item with data to render and measure
874     * @return height of the rendered view in screen px
875     */
876    private int measureOverlayHeight(ConversationOverlayItem convItem) {
877        final int type = convItem.getType();
878
879        final View convertView = mConversationContainer.getScrapView(type);
880        final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
881                true /* measureOnly */);
882        if (convertView == null) {
883            mConversationContainer.addScrapView(type, hostView);
884        }
885
886        final int heightPx = mConversationContainer.measureOverlay(hostView);
887        convItem.setHeight(heightPx);
888        convItem.markMeasurementValid();
889
890        return heightPx;
891    }
892
893    @Override
894    public void onConversationViewHeaderHeightChange(int newHeight) {
895        final int h = mWebView.screenPxToWebPx(newHeight);
896
897        mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h));
898    }
899
900    // END conversation header callbacks
901
902    // START message header callbacks
903    @Override
904    public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
905        mConversationContainer.invalidateSpacerGeometry();
906
907        // update message HTML spacer height
908        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
909        LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
910                newSpacerHeightPx);
911        mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);",
912                mTemplates.getMessageDomId(item.getMessage()), h));
913    }
914
915    @Override
916    public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
917        mConversationContainer.invalidateSpacerGeometry();
918
919        // show/hide the HTML message body and update the spacer height
920        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
921        LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
922                item.isExpanded(), h, newSpacerHeightPx);
923        mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);",
924                mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h));
925
926        mViewState.setExpansionState(item.getMessage(),
927                item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED);
928    }
929
930    @Override
931    public void showExternalResources(final Message msg) {
932        mViewState.setShouldShowImages(msg, true);
933        mWebView.getSettings().setBlockNetworkImage(false);
934        mWebView.loadUrl("javascript:unblockImages(['" + mTemplates.getMessageDomId(msg) + "']);");
935    }
936
937    @Override
938    public void showExternalResources(final String senderRawAddress) {
939        mWebView.getSettings().setBlockNetworkImage(false);
940
941        final Address sender = getAddress(senderRawAddress);
942        final MessageCursor cursor = getMessageCursor();
943
944        final List<String> messageDomIds = new ArrayList<String>();
945
946        int pos = -1;
947        while (cursor.moveToPosition(++pos)) {
948            final ConversationMessage message = cursor.getMessage();
949            if (sender.equals(getAddress(message.getFrom()))) {
950                message.alwaysShowImages = true;
951
952                mViewState.setShouldShowImages(message, true);
953                messageDomIds.add(mTemplates.getMessageDomId(message));
954            }
955        }
956
957        final String url = String.format(
958                "javascript:unblockImages(['%s']);", TextUtils.join("','", messageDomIds));
959        mWebView.loadUrl(url);
960    }
961    // END message header callbacks
962
963    @Override
964    public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
965        MessageCursor cursor = getMessageCursor();
966        if (cursor == null || !mViewsCreated) {
967            return;
968        }
969
970        mTempBodiesHtml = renderCollapsedHeaders(cursor, item);
971        mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
972    }
973
974    private void showNewMessageNotification(NewMessagesInfo info) {
975        final TextView descriptionView = (TextView) mNewMessageBar.findViewById(
976                R.id.new_message_description);
977        descriptionView.setText(info.getNotificationText());
978        mNewMessageBar.setVisibility(View.VISIBLE);
979    }
980
981    private void onNewMessageBarClick() {
982        mNewMessageBar.setVisibility(View.GONE);
983
984        renderConversation(getMessageCursor()); // mCursor is already up-to-date
985                                                // per onLoadFinished()
986    }
987
988    private static OverlayPosition[] parsePositions(final String[] topArray,
989            final String[] bottomArray) {
990        final int len = topArray.length;
991        final OverlayPosition[] positions = new OverlayPosition[len];
992        for (int i = 0; i < len; i++) {
993            positions[i] = new OverlayPosition(
994                    Integer.parseInt(topArray[i]), Integer.parseInt(bottomArray[i]));
995        }
996        return positions;
997    }
998
999    private Address getAddress(String rawFrom) {
1000        Address addr = mAddressCache.get(rawFrom);
1001        if (addr == null) {
1002            addr = Address.getEmailAddress(rawFrom);
1003            mAddressCache.put(rawFrom, addr);
1004        }
1005        return addr;
1006    }
1007
1008    private void ensureContentSizeChangeListener() {
1009        if (mWebViewSizeChangeListener == null) {
1010            mWebViewSizeChangeListener = new ContentSizeChangeListener() {
1011                @Override
1012                public void onHeightChange(int h) {
1013                    // When WebKit says the DOM height has changed, re-measure
1014                    // bodies and re-position their headers.
1015                    // This is separate from the typical JavaScript DOM change
1016                    // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM
1017                    // events.
1018                    mWebView.loadUrl("javascript:measurePositions();");
1019                }
1020            };
1021        }
1022        mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener);
1023    }
1024
1025    private static boolean isOverviewMode(Account acct) {
1026        return acct.settings.conversationViewMode == UIProvider.ConversationViewMode.OVERVIEW;
1027    }
1028
1029    private void setupOverviewMode() {
1030        // for now, overview mode means use the built-in WebView zoom and disable custom scale
1031        // gesture handling
1032        final boolean overviewMode = isOverviewMode(mAccount);
1033        final WebSettings settings = mWebView.getSettings();
1034        settings.setUseWideViewPort(overviewMode);
1035
1036        final OnScaleGestureListener listener;
1037
1038        if (MailPrefs.get(getContext()).isWhooshZoomEnabled()) {
1039            listener = new WhooshScaleInterceptor();
1040        } else {
1041            settings.setSupportZoom(overviewMode);
1042            settings.setBuiltInZoomControls(overviewMode);
1043            if (overviewMode) {
1044                settings.setDisplayZoomControls(false);
1045            }
1046            listener = ENABLE_CSS_ZOOM && !overviewMode ? new CssScaleInterceptor() : null;
1047        }
1048        mWebView.setOnScaleGestureListener(listener);
1049    }
1050
1051    private void dismissWhooshMode() {
1052        mMessageGroup.animate().alpha(0f).scaleX(0.8f).scaleY(0.8f).setDuration(200)
1053            .setListener(new HardwareLayerEnabler(mMessageGroup) {
1054                @Override
1055                public void onAnimationEnd(Animator animation) {
1056                    super.onAnimationEnd(animation);
1057                    mMessageGroup.setVisibility(View.GONE);
1058                    mMessageView.clearView();
1059                    mMessageGroup.setAlpha(1f);
1060                    mMessageGroup.setScaleX(1f);
1061                    mMessageGroup.setScaleY(1f);
1062                }
1063            });
1064    }
1065
1066    private class ConversationWebViewClient extends AbstractConversationWebViewClient {
1067        @Override
1068        public void onPageFinished(WebView view, String url) {
1069            // Ignore unsafe calls made after a fragment is detached from an activity.
1070            // This method needs to, for example, get at the loader manager, which needs
1071            // the fragment to be added.
1072            final ControllableActivity activity = (ControllableActivity) getActivity();
1073            if (!isAdded() || !mViewsCreated) {
1074                LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
1075                        ConversationViewFragment.this);
1076                return;
1077            }
1078
1079            LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s wv=%s t=%sms", url,
1080                    ConversationViewFragment.this, view,
1081                    (SystemClock.uptimeMillis() - mWebViewLoadStartMs));
1082
1083            ensureContentSizeChangeListener();
1084
1085            if (!mEnableContentReadySignal) {
1086                revealConversation();
1087            }
1088
1089            final Set<String> emailAddresses = Sets.newHashSet();
1090            for (Address addr : mAddressCache.values()) {
1091                emailAddresses.add(addr.getAddress());
1092            }
1093            ContactLoaderCallbacks callbacks = getContactInfoSource();
1094            getContactInfoSource().setSenders(emailAddresses);
1095            getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks);
1096        }
1097
1098        @Override
1099        public boolean shouldOverrideUrlLoading(WebView view, String url) {
1100            return mViewsCreated && super.shouldOverrideUrlLoading(view, url);
1101        }
1102    }
1103
1104    /**
1105     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
1106     * via reflection and not stripped.
1107     *
1108     */
1109    private class MailJsBridge {
1110
1111        @SuppressWarnings("unused")
1112        @JavascriptInterface
1113        public void onWebContentGeometryChange(final String[] overlayTopStrs,
1114                final String[] overlayBottomStrs) {
1115            getHandler().post(new FragmentRunnable("onWebContentGeometryChange") {
1116
1117                @Override
1118                public void go() {
1119                    try {
1120                        if (!mViewsCreated) {
1121                            LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views"
1122                                    + " are gone, %s", ConversationViewFragment.this);
1123                            return;
1124                        }
1125                        mConversationContainer.onGeometryChange(
1126                                parsePositions(overlayTopStrs, overlayBottomStrs));
1127                        if (mDiff != 0) {
1128                            // SCROLL!
1129                            int scale = (int) (mWebView.getScale() / mWebView.getInitialScale());
1130                            if (scale > 1) {
1131                                mWebView.scrollBy(0, (mDiff * (scale - 1)));
1132                            }
1133                            mDiff = 0;
1134                        }
1135                    } catch (Throwable t) {
1136                        LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
1137                    }
1138                }
1139            });
1140        }
1141
1142        @SuppressWarnings("unused")
1143        @JavascriptInterface
1144        public String getTempMessageBodies() {
1145            try {
1146                if (!mViewsCreated) {
1147                    return "";
1148                }
1149
1150                final String s = mTempBodiesHtml;
1151                mTempBodiesHtml = null;
1152                return s;
1153            } catch (Throwable t) {
1154                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
1155                return "";
1156            }
1157        }
1158
1159        @SuppressWarnings("unused")
1160        @JavascriptInterface
1161        public String getMessageBody(String domId) {
1162            try {
1163                final MessageCursor cursor = getMessageCursor();
1164                if (!mViewsCreated || cursor == null) {
1165                    return "";
1166                }
1167
1168                int pos = -1;
1169                while (cursor.moveToPosition(++pos)) {
1170                    final ConversationMessage msg = cursor.getMessage();
1171                    if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
1172                        return msg.getBodyAsHtml();
1173                    }
1174                }
1175
1176                return "";
1177
1178            } catch (Throwable t) {
1179                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody");
1180                return "";
1181            }
1182        }
1183
1184        @SuppressWarnings("unused")
1185        @JavascriptInterface
1186        public void onContentReady() {
1187            getHandler().post(new FragmentRunnable("onContentReady") {
1188                @Override
1189                public void go() {
1190                    try {
1191                        if (mWebViewLoadStartMs != 0) {
1192                            LogUtils.i(LOG_TAG, "IN CVF.onContentReady, f=%s vis=%s t=%sms",
1193                                    ConversationViewFragment.this,
1194                                    isUserVisible(),
1195                                    (SystemClock.uptimeMillis() - mWebViewLoadStartMs));
1196                        }
1197                        revealConversation();
1198                    } catch (Throwable t) {
1199                        LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
1200                        // Still try to show the conversation.
1201                        revealConversation();
1202                    }
1203                }
1204            });
1205        }
1206
1207        @SuppressWarnings("unused")
1208        @JavascriptInterface
1209        public float getScrollYPercent() {
1210            try {
1211                return mWebViewYPercent;
1212            } catch (Throwable t) {
1213                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent");
1214                return 0f;
1215            }
1216        }
1217    }
1218
1219    /**
1220     * This is a minimal version of {@link MailJsBridge}, above, that enables the single-message
1221     * WebView to use the same JavaScript code.
1222     *
1223     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
1224     * via reflection and not stripped.
1225     *
1226     */
1227    private class MessageJsBridge {
1228        @SuppressWarnings("unused")
1229        @JavascriptInterface
1230        public void onContentReady() {
1231            getHandler().post(new FragmentRunnable("onMessageContentReady") {
1232                @Override
1233                public void go() {
1234                    if (mMessageViewLoadStartMs != 0) {
1235                        LogUtils.i(LOG_TAG, "IN MESSAGEVIEW.onContentReady, f=%s vis=%s t=%sms",
1236                                ConversationViewFragment.this,
1237                                isUserVisible(),
1238                                (SystemClock.uptimeMillis() - mMessageViewLoadStartMs));
1239                    }
1240                    // TODO: remove me if PictureListener works well enough on ICS
1241                }
1242            });
1243        }
1244
1245        @SuppressWarnings("unused")
1246        @JavascriptInterface
1247        public float getScrollYPercent() {
1248            return 0f;
1249        }
1250
1251        @SuppressWarnings("unused")
1252        @JavascriptInterface
1253        public void onWebContentGeometryChange(final String[] overlayTopStrs,
1254                final String[] overlayBottomStrs) {
1255            // no-op
1256        }
1257    }
1258
1259    private class NewMessagesInfo {
1260        int count;
1261        int countFromSelf;
1262        String senderAddress;
1263
1264        /**
1265         * Return the display text for the new message notification overlay. It will be formatted
1266         * appropriately for a single new message vs. multiple new messages.
1267         *
1268         * @return display text
1269         */
1270        public String getNotificationText() {
1271            Resources res = getResources();
1272            if (count > 1) {
1273                return res.getString(R.string.new_incoming_messages_many, count);
1274            } else {
1275                final Address addr = getAddress(senderAddress);
1276                return res.getString(R.string.new_incoming_messages_one,
1277                        TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName());
1278            }
1279        }
1280    }
1281
1282    @Override
1283    public void onMessageCursorLoadFinished(Loader<Cursor> loader, MessageCursor newCursor,
1284            MessageCursor oldCursor) {
1285        /*
1286         * what kind of changes affect the MessageCursor? 1. new message(s) 2.
1287         * read/unread state change 3. deleted message, either regular or draft
1288         * 4. updated message, either from self or from others, updated in
1289         * content or state or sender 5. star/unstar of message (technically
1290         * similar to #1) 6. other label change Use MessageCursor.hashCode() to
1291         * sort out interesting vs. no-op cursor updates.
1292         */
1293
1294        if (oldCursor != null && !oldCursor.isClosed()) {
1295            final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor);
1296
1297            if (info.count > 0) {
1298                // don't immediately render new incoming messages from other
1299                // senders
1300                // (to avoid a new message from losing the user's focus)
1301                LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1302                        + ", holding cursor for new incoming message (%s)", this);
1303                showNewMessageNotification(info);
1304                return;
1305            }
1306
1307            final int oldState = oldCursor.getStateHashCode();
1308            final boolean changed = newCursor.getStateHashCode() != oldState;
1309
1310            if (!changed) {
1311                final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor);
1312                if (processedInPlace) {
1313                    LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this);
1314                } else {
1315                    LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update"
1316                            + ", ignoring this conversation update (%s)", this);
1317                }
1318                return;
1319            } else if (info.countFromSelf == 1) {
1320                // Special-case the very common case of a new cursor that is the same as the old
1321                // one, except that there is a new message from yourself. This happens upon send.
1322                final boolean sameExceptNewLast = newCursor.getStateHashCode(1) == oldState;
1323                if (sameExceptNewLast) {
1324                    LogUtils.i(LOG_TAG, "CONV RENDER: update is a single new message from self"
1325                            + " (%s)", this);
1326                    newCursor.moveToLast();
1327                    processNewOutgoingMessage(newCursor.getMessage());
1328                    return;
1329                }
1330            }
1331            // cursors are different, and not due to an incoming message. fall
1332            // through and render.
1333            LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1334                    + ", but not due to incoming message. rendering. (%s)", this);
1335
1336            if (DEBUG_DUMP_CURSOR_CONTENTS) {
1337                LogUtils.i(LOG_TAG, "old cursor: %s", oldCursor.getDebugDump());
1338                LogUtils.i(LOG_TAG, "new cursor: %s", newCursor.getDebugDump());
1339            }
1340        } else {
1341            LogUtils.i(LOG_TAG, "CONV RENDER: initial render. (%s)", this);
1342            timerMark("message cursor load finished");
1343        }
1344
1345        // if layout hasn't happened, delay render
1346        // This is needed in addition to the showConversation() delay to speed
1347        // up rotation and restoration.
1348        if (mConversationContainer.getWidth() == 0) {
1349            mNeedRender = true;
1350            mConversationContainer.addOnLayoutChangeListener(this);
1351        } else {
1352            renderConversation(newCursor);
1353        }
1354    }
1355
1356    private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
1357        final NewMessagesInfo info = new NewMessagesInfo();
1358
1359        int pos = -1;
1360        while (newCursor.moveToPosition(++pos)) {
1361            final Message m = newCursor.getMessage();
1362            if (!mViewState.contains(m)) {
1363                LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
1364
1365                final Address from = getAddress(m.getFrom());
1366                // distinguish ours from theirs
1367                // new messages from the account owner should not trigger a
1368                // notification
1369                if (mAccount.ownsFromAddress(from.getAddress())) {
1370                    LogUtils.i(LOG_TAG, "found message from self: %s", m.uri);
1371                    info.countFromSelf++;
1372                    continue;
1373                }
1374
1375                info.count++;
1376                info.senderAddress = m.getFrom();
1377            }
1378        }
1379        return info;
1380    }
1381
1382    private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) {
1383        final Set<String> idsOfChangedBodies = Sets.newHashSet();
1384        final List<Integer> changedOverlayPositions = Lists.newArrayList();
1385
1386        boolean changed = false;
1387
1388        int pos = 0;
1389        while (true) {
1390            if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) {
1391                break;
1392            }
1393
1394            final ConversationMessage newMsg = newCursor.getMessage();
1395            final ConversationMessage oldMsg = oldCursor.getMessage();
1396
1397            if (!TextUtils.equals(newMsg.getFrom(), oldMsg.getFrom()) ||
1398                    newMsg.isSending != oldMsg.isSending) {
1399                mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions);
1400                LogUtils.i(LOG_TAG, "msg #%d (%d): detected from/sending change. isSending=%s",
1401                        pos, newMsg.id, newMsg.isSending);
1402            }
1403
1404            // update changed message bodies in-place
1405            if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) ||
1406                    !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) {
1407                // maybe just set a flag to notify JS to re-request changed bodies
1408                idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"');
1409                LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id);
1410            }
1411
1412            pos++;
1413        }
1414
1415
1416        if (!changedOverlayPositions.isEmpty()) {
1417            // notify once after the entire adapter is updated
1418            mConversationContainer.onOverlayModelUpdate(changedOverlayPositions);
1419            changed = true;
1420        }
1421
1422        if (!idsOfChangedBodies.isEmpty()) {
1423            mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);",
1424                    TextUtils.join(",", idsOfChangedBodies)));
1425            changed = true;
1426        }
1427
1428        return changed;
1429    }
1430
1431    private void processNewOutgoingMessage(ConversationMessage msg) {
1432        mTemplates.reset();
1433        // this method will add some items to mAdapter, but we deliberately want to avoid notifying
1434        // adapter listeners (i.e. ConversationContainer) until onWebContentGeometryChange is next
1435        // called, to prevent N+1 headers rendering with N message bodies.
1436        renderMessage(msg, true /* expanded */, msg.alwaysShowImages);
1437        mTempBodiesHtml = mTemplates.emit();
1438
1439        mViewState.setExpansionState(msg, ExpansionState.EXPANDED);
1440        // FIXME: should the provider set this as initial state?
1441        mViewState.setReadState(msg, false /* read */);
1442
1443        // From now until the updated spacer geometry is returned, the adapter items are mismatched
1444        // with the existing spacers. Do not let them layout.
1445        mConversationContainer.invalidateSpacerGeometry();
1446
1447        mWebView.loadUrl("javascript:appendMessageHtml();");
1448    }
1449
1450    private class SetCookieTask extends AsyncTask<Void, Void, Void> {
1451        final String mUri;
1452        final Uri mAccountCookieQueryUri;
1453        final ContentResolver mResolver;
1454
1455        SetCookieTask(Context context, Uri baseUri, Uri accountCookieQueryUri) {
1456            mUri = baseUri.toString();
1457            mAccountCookieQueryUri = accountCookieQueryUri;
1458            mResolver = context.getContentResolver();
1459        }
1460
1461        @Override
1462        public Void doInBackground(Void... args) {
1463            // First query for the coookie string from the UI provider
1464            final Cursor cookieCursor = mResolver.query(mAccountCookieQueryUri,
1465                    UIProvider.ACCOUNT_COOKIE_PROJECTION, null, null, null);
1466            if (cookieCursor == null) {
1467                return null;
1468            }
1469
1470            try {
1471                if (cookieCursor.moveToFirst()) {
1472                    final String cookie = cookieCursor.getString(
1473                            cookieCursor.getColumnIndex(UIProvider.AccountCookieColumns.COOKIE));
1474
1475                    if (cookie != null) {
1476                        final CookieSyncManager csm =
1477                            CookieSyncManager.createInstance(getContext());
1478                        CookieManager.getInstance().setCookie(mUri, cookie);
1479                        csm.sync();
1480                    }
1481                }
1482
1483            } finally {
1484                cookieCursor.close();
1485            }
1486
1487
1488            return null;
1489        }
1490    }
1491
1492    @Override
1493    public void onConversationUpdated(Conversation conv) {
1494        final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer
1495                .findViewById(R.id.conversation_header);
1496        mConversation = conv;
1497        if (headerView != null) {
1498            headerView.onConversationUpdated(conv);
1499            headerView.setSubject(conv.subject);
1500        }
1501    }
1502
1503    @Override
1504    public void onLayoutChange(View v, int left, int top, int right,
1505            int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
1506        boolean sizeChanged = mNeedRender
1507                && mConversationContainer.getWidth() != 0;
1508        if (sizeChanged) {
1509            mNeedRender = false;
1510            mConversationContainer.removeOnLayoutChangeListener(this);
1511            renderConversation(getMessageCursor());
1512        }
1513    }
1514
1515    @Override
1516    public void setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded,
1517            int heightBefore) {
1518        mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore);
1519    }
1520
1521    private class CssScaleInterceptor implements OnScaleGestureListener {
1522
1523        private float getFocusXWebPx(ScaleGestureDetector detector) {
1524            return (detector.getFocusX() - mSideMarginPx) / mWebView.getInitialScale();
1525        }
1526
1527        private float getFocusYWebPx(ScaleGestureDetector detector) {
1528            return detector.getFocusY() / mWebView.getInitialScale();
1529        }
1530
1531        @Override
1532        public boolean onScale(ScaleGestureDetector detector) {
1533            mWebView.loadUrl(String.format("javascript:onScale(%s, %s, %s);",
1534                    detector.getScaleFactor(), getFocusXWebPx(detector),
1535                    getFocusYWebPx(detector)));
1536            return false;
1537        }
1538
1539        @Override
1540        public boolean onScaleBegin(ScaleGestureDetector detector) {
1541            mWebView.loadUrl(String.format("javascript:onScaleBegin(%s, %s);",
1542                    getFocusXWebPx(detector), getFocusYWebPx(detector)));
1543            return true;
1544        }
1545
1546        @Override
1547        public void onScaleEnd(ScaleGestureDetector detector) {
1548            mWebView.loadUrl(String.format("javascript:onScaleEnd(%s, %s);",
1549                    getFocusXWebPx(detector), getFocusYWebPx(detector)));
1550        }
1551
1552    }
1553
1554    private class WhooshScaleInterceptor implements OnScaleGestureListener {
1555        @Override
1556        public boolean onScale(ScaleGestureDetector detector) {
1557            final float scale = detector.getScaleFactor();
1558            if (mIgnoreOnScaleCalls || Math.abs(scale - 1.0f) < WHOOSH_SCALE_MINIMUM) {
1559                return false;
1560            }
1561
1562            loadSingleMessageView(scale > 1, (int) detector.getFocusX(),
1563                    (int) detector.getFocusY());
1564
1565            // Ignore subsequent onScale() calls
1566            mIgnoreOnScaleCalls = true;
1567            return false;
1568        }
1569
1570        @Override
1571        public boolean onScaleBegin(ScaleGestureDetector detector) {
1572            return true;
1573        }
1574
1575        @Override
1576        public void onScaleEnd(ScaleGestureDetector detector) {
1577            mIgnoreOnScaleCalls = false;
1578        }
1579
1580        private void loadSingleMessageView(boolean isZoomingIn, int focusX, int focusY) {
1581            // TODO: make isWide determination per-message
1582            final boolean isWide = mWebView.computeHorizontalScrollRange() >
1583                    mWebView.computeHorizontalScrollExtent();
1584
1585            // if the conversation already fits-width, disable zooming out
1586            // anymore since entire page
1587            // already fits on screen.
1588            if (!isWide && !isZoomingIn) {
1589                return;
1590            }
1591            if (isOverviewMode(mAccount)) {
1592                if (!isZoomingIn) {
1593                    return;
1594                }
1595            }
1596
1597            // find the message under focusX/focusY
1598            final int y = focusY + mWebView.getScrollY();
1599
1600            // assuming positioning has happened by now...
1601            int msgBottom = mWebView.computeVerticalScrollRange();
1602            MessageHeaderItem msgItem = null;
1603
1604            for (int i = 0, len = mAdapter.getCount(); i < len; i++) {
1605                final ConversationOverlayItem item = mAdapter.getItem(i);
1606
1607                if (y < item.getTop()) {
1608                    // focus must have been on the last-seen item.
1609                    msgBottom = item.getTop();
1610                    break;
1611                }
1612
1613                if (item instanceof MessageHeaderItem && item.canBecomeSnapHeader()) {
1614                    msgItem = (MessageHeaderItem) item;
1615                }
1616            }
1617
1618            if (msgItem == null) {
1619                LogUtils.w(LOG_TAG, "ignoring scaleBegin on y=%d, no matching message.", y);
1620                return;
1621            }
1622
1623            // save off scroll position to be restored later in onHeightChange()
1624            // TODO: need to take into account focus position, too
1625            if (mWebView.getScrollX() > 0) {
1626                mMessageScrollXFraction = (float) mWebView.computeHorizontalScrollOffset()
1627                        / (mWebView.computeHorizontalScrollRange() -
1628                        mWebView.computeHorizontalScrollExtent());
1629            } else {
1630                mMessageScrollXFraction = 0f;
1631            }
1632
1633            final int msgBodyTop = msgItem.getTop() + msgItem.getHeight();
1634            if (msgBodyTop < mWebView.getScrollY()) {
1635                mMessageScrollYFraction = ((float) mWebView.getScrollY() - msgBodyTop) /
1636                        (msgBottom - msgBodyTop - mWebView.computeVerticalScrollExtent());
1637            } else {
1638                mMessageScrollYFraction = 0f;
1639            }
1640
1641            final boolean safeForImages = msgItem.getMessage().alwaysShowImages
1642                    || msgItem.getShowImages();
1643
1644            // render the single message HTML
1645            mTemplates.startConversation(mWebView.screenPxToWebPx(mSideMarginPx), 0);
1646            mTemplates.appendMessageHtml(msgItem.getMessage(), true /* expanded */,
1647                    safeForImages, 0, 0);
1648            final String html = mTemplates.endConversation(mBaseUri,
1649                    mConversation.getBaseUri(mBaseUri), 0, 0, false, false);
1650
1651            mMessageView.loadDataWithBaseURL(mBaseUri, html, "text/html", "utf-8", null);
1652            mMessageViewLoadStartMs = SystemClock.uptimeMillis();
1653            mMessageView.setInitialScale(isZoomingIn ? 200 : 1);
1654
1655            mMessageViewIsLoading = true;
1656
1657            mMessageGroup.setVisibility(View.VISIBLE);
1658            mMessageGroup.setBackgroundColor(0);
1659
1660            final float scaleOut;
1661            if (isZoomingIn) {
1662                scaleOut = 1.15f;
1663            } else {
1664                scaleOut = 0.85f;
1665            }
1666
1667            final int offsetBy = Math.max(0, msgItem.getTop() - mWebView.getScrollY());
1668
1669            final Interpolator curve = new DecelerateInterpolator(2.5f);
1670
1671            mWebView.setPivotX(focusX);
1672            mWebView.setPivotY(focusY);
1673            mWebView.animate().scaleX(scaleOut).scaleY(scaleOut).alpha(0.3f)
1674                    .translationY(-offsetBy * 1.2f)
1675                    .setDuration(300).setInterpolator(curve)
1676                    .setListener(new HardwareLayerEnabler(mWebView));
1677
1678            msgItem.bindView(mWhooshHeader, false /* measureOnly */);
1679            mWhooshHeader.setTranslationY(offsetBy);
1680
1681            mWhooshHeader.animate().translationY(0).setDuration(300).setInterpolator(curve);
1682
1683            // immediately hide the message's header view, if present
1684            final View zoomingHeader = mConversationContainer.getViewForItem(msgItem);
1685
1686            final List<View> otherOverlays = mConversationContainer.getOverlayViews();
1687            if (zoomingHeader != null) {
1688                otherOverlays.remove(zoomingHeader);
1689                zoomingHeader.setVisibility(View.INVISIBLE);
1690            }
1691            for (View v : otherOverlays) {
1692                v.animate().alpha(0f).setDuration(150).setInterpolator(curve)
1693                        .setListener(new HardwareLayerEnabler(v));
1694            }
1695
1696            // WebView seems to have some optimizations to not draw or load if completely
1697            // transparent. Start with some non-zero opacity.
1698            mMessageInnerGroup.setAlpha(0.01f);
1699
1700            final AnimatorListener fadeIn = new AnimatorListenerAdapter() {
1701                @Override
1702                public void onAnimationStart(Animator animation) {
1703                }
1704
1705                @Override
1706                public void onAnimationEnd(Animator animation) {
1707                    mMessageInnerGroup.setLayerType(View.LAYER_TYPE_NONE, null);
1708                    if (zoomingHeader != null) {
1709                        zoomingHeader.setVisibility(View.VISIBLE);
1710                    }
1711                    mWebView.animate().cancel();
1712                    mWebView.setTranslationY(0);
1713                    mWebView.setAlpha(1f);
1714                    mWebView.setScaleX(1f);
1715                    mWebView.setScaleY(1f);
1716                    mMessageGroup.setBackgroundColor(0xffffffff);
1717                    for (View v : otherOverlays) {
1718                        v.animate().cancel();
1719                        v.setAlpha(1f);
1720                    }
1721                }
1722            };
1723            mOnMessageLoadComplete = new FragmentRunnable("onMessageLoadComplete") {
1724                @Override
1725                public void go() {
1726                    mWhooshHeader.animate().cancel();
1727                    mWhooshHeader.setTranslationY(0);
1728                    mMessageInnerGroup.setLayerType(View.LAYER_TYPE_HARDWARE, null);
1729                    mMessageInnerGroup.setAlpha(0.3f);
1730                    mMessageInnerGroup.animate().alpha(1f).setInterpolator(curve)
1731                            .setDuration(150).setListener(fadeIn);
1732                }
1733            };
1734
1735            setupPictureListener();
1736        }
1737
1738        @SuppressWarnings("deprecation")
1739        private void setupPictureListener() {
1740            mMessageView.setPictureListener(new WebView.PictureListener() {
1741                @Override
1742                public void onNewPicture(WebView view, Picture picture) {
1743                    LogUtils.w(LOG_TAG, "MessageView.onNewPicture, t=%s",
1744                            SystemClock.uptimeMillis() - mMessageViewLoadStartMs);
1745                    mMessageView.setPictureListener(null);
1746
1747                    if (mOnMessageLoadComplete != null) {
1748                        mOnMessageLoadComplete.run();
1749                    }
1750                }
1751            });
1752        }
1753
1754    }
1755
1756}
1757