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