ConversationViewFragment.java revision 9d3fd92ed6091dbd0d38799222a1cf841f1c3f29
1/*
2 * Copyright (C) 2012 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.ui;
19
20
21import android.content.Context;
22import android.content.Loader;
23import android.content.res.Resources;
24import android.database.Cursor;
25import android.database.DataSetObserver;
26import android.os.AsyncTask;
27import android.os.Bundle;
28import android.os.SystemClock;
29import android.text.TextUtils;
30import android.view.LayoutInflater;
31import android.view.View;
32import android.view.View.OnLayoutChangeListener;
33import android.view.ViewGroup;
34import android.webkit.ConsoleMessage;
35import android.webkit.CookieManager;
36import android.webkit.CookieSyncManager;
37import android.webkit.JavascriptInterface;
38import android.webkit.WebChromeClient;
39import android.webkit.WebSettings;
40import android.webkit.WebView;
41import android.webkit.WebViewClient;
42import android.widget.TextView;
43
44import com.android.mail.FormattedDateBuilder;
45import com.android.mail.R;
46import com.android.mail.browse.ConversationContainer;
47import com.android.mail.browse.ConversationOverlayItem;
48import com.android.mail.browse.ConversationViewAdapter;
49import com.android.mail.browse.ScrollIndicatorsView;
50import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController;
51import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem;
52import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
53import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
54import com.android.mail.browse.ConversationViewHeader;
55import com.android.mail.browse.ConversationWebView;
56import com.android.mail.browse.ConversationWebView.ContentSizeChangeListener;
57import com.android.mail.browse.MessageCursor;
58import com.android.mail.browse.MessageCursor.ConversationController;
59import com.android.mail.browse.MessageCursor.ConversationMessage;
60import com.android.mail.browse.MessageHeaderView;
61import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
62import com.android.mail.browse.SuperCollapsedBlock;
63import com.android.mail.browse.WebViewContextMenu;
64import com.android.mail.providers.Account;
65import com.android.mail.providers.Address;
66import com.android.mail.providers.Conversation;
67import com.android.mail.providers.Message;
68import com.android.mail.ui.ConversationViewState.ExpansionState;
69import com.android.mail.utils.LogTag;
70import com.android.mail.utils.LogUtils;
71import com.android.mail.utils.Utils;
72import com.google.common.collect.Lists;
73import com.google.common.collect.Sets;
74
75import java.util.List;
76import java.util.Set;
77
78
79/**
80 * The conversation view UI component.
81 */
82public final class ConversationViewFragment extends AbstractConversationViewFragment implements
83        MessageHeaderViewCallbacks,
84        SuperCollapsedBlock.OnClickListener,
85        ConversationController,
86        ConversationAccountController,
87        OnLayoutChangeListener {
88
89    private static final String LOG_TAG = LogTag.getLogTag();
90    public static final String LAYOUT_TAG = "ConvLayout";
91
92    /**
93     * Default value for {@link #mLoadWaitReason}. Conversation load will happen immediately.
94     */
95    private final int LOAD_NOW = 0;
96    /**
97     * Value for {@link #mLoadWaitReason} that means we are offscreen and waiting for the visible
98     * conversation to finish loading before beginning our load.
99     * <p>
100     * When this value is set, the fragment should register with {@link ConversationListCallbacks}
101     * to know when the visible conversation is loaded. When it is unset, it should unregister.
102     */
103    private final int LOAD_WAIT_FOR_INITIAL_CONVERSATION = 1;
104    /**
105     * Value for {@link #mLoadWaitReason} used when a conversation is too heavyweight to load at
106     * all when not visible (e.g. requires network fetch, or too complex). Conversation load will
107     * wait until this fragment is visible.
108     */
109    private final int LOAD_WAIT_UNTIL_VISIBLE = 2;
110
111    private ConversationContainer mConversationContainer;
112
113    private ConversationWebView mWebView;
114
115    private ScrollIndicatorsView mScrollIndicators;
116
117    private View mNewMessageBar;
118
119    private HtmlConversationTemplates mTemplates;
120
121    private final MailJsBridge mJsBridge = new MailJsBridge();
122
123    private final WebViewClient mWebViewClient = new ConversationWebViewClient();
124
125    private ConversationViewAdapter mAdapter;
126
127    private boolean mViewsCreated;
128    // True if we attempted to render before the views were laid out
129    // We will render immediately once layout is done
130    private boolean mNeedRender;
131
132    /**
133     * Temporary string containing the message bodies of the messages within a super-collapsed
134     * block, for one-time use during block expansion. We cannot easily pass the body HTML
135     * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it
136     * using {@link MailJsBridge}.
137     */
138    private String mTempBodiesHtml;
139
140    private int  mMaxAutoLoadMessages;
141
142    /**
143     * If this conversation fragment is not visible, and it's inappropriate to load up front,
144     * this is the reason we are waiting. This flag should be cleared once it's okay to load
145     * the conversation.
146     */
147    private int mLoadWaitReason = LOAD_NOW;
148
149    private boolean mEnableContentReadySignal;
150
151    private ContentSizeChangeListener mWebViewSizeChangeListener;
152
153    private final DataSetObserver mLoadedObserver = new DataSetObserver() {
154        @Override
155        public void onChanged() {
156            getHandler().post(new FragmentRunnable("delayedConversationLoad") {
157                @Override
158                public void go() {
159                    LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s",
160                            ConversationViewFragment.this);
161                    handleDelayedConversationLoad();
162                }
163            });
164        }
165    };
166
167    private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
168    private static final boolean DISABLE_OFFSCREEN_LOADING = false;
169    protected static final String AUTO_LOAD_KEY = "auto-load";
170
171    /**
172     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
173     */
174    public ConversationViewFragment() {
175        super();
176    }
177
178    /**
179     * Creates a new instance of {@link ConversationViewFragment}, initialized
180     * to display a conversation with other parameters inherited/copied from an existing bundle,
181     * typically one created using {@link #makeBasicArgs}.
182     */
183    public static ConversationViewFragment newInstance(Bundle existingArgs,
184            Conversation conversation) {
185        ConversationViewFragment f = new ConversationViewFragment();
186        Bundle args = new Bundle(existingArgs);
187        args.putParcelable(ARG_CONVERSATION, conversation);
188        f.setArguments(args);
189        return f;
190    }
191
192    @Override
193    public void onAccountChanged() {
194        // settings may have been updated; refresh views that are known to
195        // depend on settings
196        mConversationContainer.getSnapHeader().onAccountChanged();
197        mAdapter.notifyDataSetChanged();
198    }
199
200    @Override
201    public void onActivityCreated(Bundle savedInstanceState) {
202        LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s visible=%s", this, isUserVisible());
203        super.onActivityCreated(savedInstanceState);
204        Context context = getContext();
205        mTemplates = new HtmlConversationTemplates(context);
206
207        final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(context);
208
209        mAdapter = new ConversationViewAdapter(mActivity, this,
210                getLoaderManager(), this, getContactInfoSource(), this,
211                this, mAddressCache, dateBuilder);
212        mConversationContainer.setOverlayAdapter(mAdapter);
213
214        // set up snap header (the adapter usually does this with the other ones)
215        final MessageHeaderView snapHeader = mConversationContainer.getSnapHeader();
216        snapHeader.initialize(dateBuilder, this, mAddressCache);
217        snapHeader.setCallbacks(this);
218        snapHeader.setContactInfoSource(getContactInfoSource());
219
220        mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages);
221
222        mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(getActivity()));
223
224        // Defer the call to initLoader with a Handler.
225        // We want to wait until we know which fragments are present and their final visibility
226        // states before going off and doing work. This prevents extraneous loading from occurring
227        // as the ViewPager shifts about before the initial position is set.
228        //
229        // e.g. click on item #10
230        // ViewPager.setAdapter() actually first loads #0 and #1 under the assumption that #0 is
231        // the initial primary item
232        // Then CPC immediately sets the primary item to #10, which tears down #0/#1 and sets up
233        // #9/#10/#11.
234        getHandler().post(new FragmentRunnable("showConversation") {
235            @Override
236            public void go() {
237                showConversation();
238            }
239        });
240
241        if (mConversation.conversationBaseUri != null &&
242                !TextUtils.isEmpty(mConversation.conversationCookie)) {
243            // Set the cookie for this base url
244            new SetCookieTask(mConversation.conversationBaseUri.toString(),
245                    mConversation.conversationCookie).execute();
246        }
247    }
248
249    @Override
250    public View onCreateView(LayoutInflater inflater,
251            ViewGroup container, Bundle savedInstanceState) {
252
253        View rootView = inflater.inflate(R.layout.conversation_view, container, false);
254        mConversationContainer = (ConversationContainer) rootView
255                .findViewById(R.id.conversation_container);
256
257        mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar);
258        mNewMessageBar.setOnClickListener(new View.OnClickListener() {
259            @Override
260            public void onClick(View v) {
261                onNewMessageBarClick();
262            }
263        });
264
265        instantiateProgressIndicators(rootView);
266
267        mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
268
269        mWebView.addJavascriptInterface(mJsBridge, "mail");
270        // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
271        // Below JB, try to speed up initial render by having the webview do supplemental draws to
272        // custom a software canvas.
273        // TODO(mindyp):
274        //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
275        // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
276        // animation that immediately runs on page load. The app uses this as a signal that the
277        // content is loaded and ready to draw, since WebView delays firing this event until the
278        // layers are composited and everything is ready to draw.
279        // This signal does not seem to be reliable, so just use the old method for now.
280        mEnableContentReadySignal = Utils.isRunningJellybeanOrLater();
281        mWebView.setUseSoftwareLayer(!mEnableContentReadySignal);
282        mWebView.setWebViewClient(mWebViewClient);
283        mWebView.setWebChromeClient(new WebChromeClient() {
284            @Override
285            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
286                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
287                        consoleMessage.sourceId(), consoleMessage.lineNumber());
288                return true;
289            }
290        });
291
292        final WebSettings settings = mWebView.getSettings();
293
294        mScrollIndicators = (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators);
295        mScrollIndicators.setSourceView(mWebView);
296
297        settings.setJavaScriptEnabled(true);
298        settings.setUseWideViewPort(true);
299        settings.setLoadWithOverviewMode(true);
300
301        settings.setSupportZoom(true);
302        settings.setBuiltInZoomControls(true);
303        settings.setDisplayZoomControls(false);
304
305        final float fontScale = getResources().getConfiguration().fontScale;
306        final int desiredFontSizePx = getResources()
307                .getInteger(R.integer.conversation_desired_font_size_px);
308        final int unstyledFontSizePx = getResources()
309                .getInteger(R.integer.conversation_unstyled_font_size_px);
310
311        int textZoom = settings.getTextZoom();
312        // apply a correction to the default body text style to get regular text to the size we want
313        textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx;
314        // then apply any system font scaling
315        textZoom = (int) (textZoom * fontScale);
316        settings.setTextZoom(textZoom);
317
318        mViewsCreated = true;
319
320        return rootView;
321    }
322
323    @Override
324    public void onResume() {
325        super.onResume();
326
327        // Hacky workaround for http://b/6946182
328        Utils.fixSubTreeLayoutIfOrphaned(getView(), "ConversationViewFragment");
329    }
330
331    @Override
332    public void onDestroyView() {
333        super.onDestroyView();
334        mConversationContainer.setOverlayAdapter(null);
335        mAdapter = null;
336        resetLoadWaiting(); // be sure to unregister any active load observer
337        mViewsCreated = false;
338    }
339
340    private void resetLoadWaiting() {
341        if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) {
342            getListController().unregisterConversationLoadedObserver(mLoadedObserver);
343        }
344        mLoadWaitReason = LOAD_NOW;
345    }
346
347    @Override
348    protected void markUnread() {
349        // Ignore unsafe calls made after a fragment is detached from an activity
350        final ControllableActivity activity = (ControllableActivity) getActivity();
351        if (activity == null) {
352            LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
353            return;
354        }
355
356        if (mViewState == null) {
357            LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
358                    mConversation.id);
359            return;
360        }
361        activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
362                mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
363    }
364
365    @Override
366    public void onUserVisibleHintChanged() {
367        final boolean userVisible = isUserVisible();
368
369        if (!userVisible) {
370            dismissLoadingStatus();
371        } else if (mViewsCreated) {
372            if (getMessageCursor() != null) {
373                LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this);
374                onConversationSeen();
375            } else if (isLoadWaiting()) {
376                LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this);
377                handleDelayedConversationLoad();
378            }
379        }
380    }
381
382    /**
383     * Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do
384     * nothing (in which case you should later call {@link #handleDelayedConversationLoad()}).
385     */
386    private void showConversation() {
387        final int reason;
388
389        if (isUserVisible()) {
390            LogUtils.i(LOG_TAG,
391                    "SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this);
392            reason = LOAD_NOW;
393        } else {
394            final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
395                    || (mConversation.isRemote
396                            || mConversation.getNumMessages() > mMaxAutoLoadMessages);
397
398            // When not visible, we should not immediately load if either this conversation is
399            // too heavyweight, or if the main/initial conversation is busy loading.
400            if (disableOffscreenLoading) {
401                reason = LOAD_WAIT_UNTIL_VISIBLE;
402                LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this);
403            } else if (getListController().isInitialConversationLoading()) {
404                reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION;
405                LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this);
406                getListController().registerConversationLoadedObserver(mLoadedObserver);
407            } else {
408                LogUtils.i(LOG_TAG,
409                        "SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)",
410                        this);
411                reason = LOAD_NOW;
412            }
413        }
414
415        mLoadWaitReason = reason;
416        if (mLoadWaitReason == LOAD_NOW) {
417            startConversationLoad();
418        }
419    }
420
421    private void handleDelayedConversationLoad() {
422        resetLoadWaiting();
423        startConversationLoad();
424    }
425
426    private void startConversationLoad() {
427        mWebView.setVisibility(View.VISIBLE);
428        getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks());
429        if (isUserVisible()) {
430            final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
431            if (sdc != null) {
432                sdc.setSubject(mConversation.subject);
433            }
434        }
435        // TODO(mindyp): don't show loading status for a previously rendered
436        // conversation. Ielieve this is better done by making sure don't show loading status
437        // until XX ms have passed without loading completed.
438        showLoadingStatus();
439    }
440
441    private boolean isLoadWaiting() {
442        return mLoadWaitReason != LOAD_NOW;
443    }
444
445    private void renderConversation(MessageCursor messageCursor) {
446        final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal);
447
448        if (DEBUG_DUMP_CONVERSATION_HTML) {
449            java.io.FileWriter fw = null;
450            try {
451                fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id
452                        + ".html");
453                fw.write(convHtml);
454            } catch (java.io.IOException e) {
455                e.printStackTrace();
456            } finally {
457                if (fw != null) {
458                    try {
459                        fw.close();
460                    } catch (java.io.IOException e) {
461                        e.printStackTrace();
462                    }
463                }
464            }
465        }
466
467        mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
468    }
469
470    /**
471     * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
472     * conversation header), and return an HTML document with spacer divs inserted for all overlays.
473     *
474     */
475    private String renderMessageBodies(MessageCursor messageCursor,
476            boolean enableContentReadySignal) {
477        int pos = -1;
478
479        LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this);
480        boolean allowNetworkImages = false;
481
482        // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
483
484        // Walk through the cursor and build up an overlay adapter as you go.
485        // Each overlay has an entry in the adapter for easy scroll handling in the container.
486        // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
487        // When adding adapter items, also add their heights to help the container later determine
488        // overlay dimensions.
489
490        // When re-rendering, prevent ConversationContainer from laying out overlays until after
491        // the new spacers are positioned by WebView.
492        mConversationContainer.invalidateSpacerGeometry();
493
494        mAdapter.clear();
495
496        // re-evaluate the message parts of the view state, since the messages may have changed
497        // since the previous render
498        final ConversationViewState prevState = mViewState;
499        mViewState = new ConversationViewState(prevState);
500
501        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
502        // a pixel is an mdpi pixel, unless you set device-dpi.
503
504        // add a single conversation header item
505        final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
506        final int convHeaderPx = measureOverlayHeight(convHeaderPos);
507
508        final int sideMarginPx = getResources().getDimensionPixelOffset(
509                R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset(
510                R.dimen.conversation_message_content_margin_side);
511
512        mTemplates.startConversation(mWebView.screenPxToWebPx(sideMarginPx),
513                mWebView.screenPxToWebPx(convHeaderPx));
514
515        int collapsedStart = -1;
516        ConversationMessage prevCollapsedMsg = null;
517        boolean prevSafeForImages = false;
518
519        while (messageCursor.moveToPosition(++pos)) {
520            final ConversationMessage msg = messageCursor.getMessage();
521
522            // TODO: save/restore 'show pics' state
523            final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
524            allowNetworkImages |= safeForImages;
525
526            final Integer savedExpanded = prevState.getExpansionState(msg);
527            final int expandedState;
528            if (savedExpanded != null) {
529                if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) {
530                    // override saved state when this is now the new last message
531                    // this happens to the second-to-last message when you discard a draft
532                    expandedState = ExpansionState.EXPANDED;
533                } else {
534                    expandedState = savedExpanded;
535                }
536            } else {
537                // new messages that are not expanded default to being eligible for super-collapse
538                expandedState = (!msg.read || msg.starred || messageCursor.isLast()) ?
539                        ExpansionState.EXPANDED : ExpansionState.SUPER_COLLAPSED;
540            }
541            mViewState.setExpansionState(msg, expandedState);
542
543            // save off "read" state from the cursor
544            // later, the view may not match the cursor (e.g. conversation marked read on open)
545            // however, if a previous state indicated this message was unread, trust that instead
546            // so "mark unread" marks all originally unread messages
547            mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg));
548
549            // We only want to consider this for inclusion in the super collapsed block if
550            // 1) The we don't have previous state about this message  (The first time that the
551            //    user opens a conversation)
552            // 2) The previously saved state for this message indicates that this message is
553            //    in the super collapsed block.
554            if (ExpansionState.isSuperCollapsed(expandedState)) {
555                // contribute to a super-collapsed block that will be emitted just before the
556                // next expanded header
557                if (collapsedStart < 0) {
558                    collapsedStart = pos;
559                }
560                prevCollapsedMsg = msg;
561                prevSafeForImages = safeForImages;
562                continue;
563            }
564
565            // resolve any deferred decisions on previous collapsed items
566            if (collapsedStart >= 0) {
567                if (pos - collapsedStart == 1) {
568                    // special-case for a single collapsed message: no need to super-collapse it
569                    renderMessage(prevCollapsedMsg, false /* expanded */,
570                            prevSafeForImages);
571                } else {
572                    renderSuperCollapsedBlock(collapsedStart, pos - 1);
573                }
574                prevCollapsedMsg = null;
575                collapsedStart = -1;
576            }
577
578            renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages);
579        }
580
581        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
582
583        // If the conversation has specified a base uri, use it here, use mBaseUri
584        final String conversationBaseUri = mConversation.conversationBaseUri != null ?
585                mConversation.conversationBaseUri.toString() : mBaseUri;
586        return mTemplates.endConversation(mBaseUri, conversationBaseUri, 320,
587                mWebView.getViewportWidth(), enableContentReadySignal);
588    }
589
590    private void renderSuperCollapsedBlock(int start, int end) {
591        final int blockPos = mAdapter.addSuperCollapsedBlock(start, end);
592        final int blockPx = measureOverlayHeight(blockPos);
593        mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
594    }
595
596    private void renderMessage(ConversationMessage msg, boolean expanded,
597            boolean safeForImages) {
598        final int headerPos = mAdapter.addMessageHeader(msg, expanded);
599        final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
600
601        final int footerPos = mAdapter.addMessageFooter(headerItem);
602
603        // Measure item header and footer heights to allocate spacers in HTML
604        // But since the views themselves don't exist yet, render each item temporarily into
605        // a host view for measurement.
606        final int headerPx = measureOverlayHeight(headerPos);
607        final int footerPx = measureOverlayHeight(footerPos);
608
609        mTemplates.appendMessageHtml(msg, expanded, safeForImages,
610                mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
611    }
612
613    private String renderCollapsedHeaders(MessageCursor cursor,
614            SuperCollapsedBlockItem blockToReplace) {
615        final List<ConversationOverlayItem> replacements = Lists.newArrayList();
616
617        mTemplates.reset();
618
619        // In devices with non-integral density multiplier, screen pixels translate to non-integral
620        // web pixels. Keep track of the error that occurs when we cast all heights to int
621        float error = 0f;
622        for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
623            cursor.moveToPosition(i);
624            final ConversationMessage msg = cursor.getMessage();
625            final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg,
626                    false /* expanded */);
627            final MessageFooterItem footer = mAdapter.newMessageFooterItem(header);
628
629            final int headerPx = measureOverlayHeight(header);
630            final int footerPx = measureOverlayHeight(footer);
631            error += mWebView.screenPxToWebPxError(headerPx)
632                    + mWebView.screenPxToWebPxError(footerPx);
633
634            // When the error becomes greater than 1 pixel, make the next header 1 pixel taller
635            int correction = 0;
636            if (error >= 1) {
637                correction = 1;
638                error -= 1;
639            }
640
641            mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages,
642                    mWebView.screenPxToWebPx(headerPx) + correction,
643                    mWebView.screenPxToWebPx(footerPx));
644            replacements.add(header);
645            replacements.add(footer);
646
647            mViewState.setExpansionState(msg, ExpansionState.COLLAPSED);
648        }
649
650        mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
651
652        return mTemplates.emit();
653    }
654
655    private int measureOverlayHeight(int position) {
656        return measureOverlayHeight(mAdapter.getItem(position));
657    }
658
659    /**
660     * Measure the height of an adapter view by rendering an adapter item into a temporary
661     * host view, and asking the view to immediately measure itself. This method will reuse
662     * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
663     * earlier.
664     * <p>
665     * After measuring the height, this method also saves the height in the
666     * {@link ConversationOverlayItem} for later use in overlay positioning.
667     *
668     * @param convItem adapter item with data to render and measure
669     * @return height of the rendered view in screen px
670     */
671    private int measureOverlayHeight(ConversationOverlayItem convItem) {
672        final int type = convItem.getType();
673
674        final View convertView = mConversationContainer.getScrapView(type);
675        final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
676                true /* measureOnly */);
677        if (convertView == null) {
678            mConversationContainer.addScrapView(type, hostView);
679        }
680
681        final int heightPx = mConversationContainer.measureOverlay(hostView);
682        convItem.setHeight(heightPx);
683        convItem.markMeasurementValid();
684
685        return heightPx;
686    }
687
688    @Override
689    public void onConversationViewHeaderHeightChange(int newHeight) {
690        final int h = mWebView.screenPxToWebPx(newHeight);
691
692        mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h));
693    }
694
695    // END conversation header callbacks
696
697    // START message header callbacks
698    @Override
699    public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
700        mConversationContainer.invalidateSpacerGeometry();
701
702        // update message HTML spacer height
703        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
704        LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
705                newSpacerHeightPx);
706        mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);",
707                mTemplates.getMessageDomId(item.getMessage()), h));
708    }
709
710    @Override
711    public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
712        mConversationContainer.invalidateSpacerGeometry();
713
714        // show/hide the HTML message body and update the spacer height
715        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
716        LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
717                item.isExpanded(), h, newSpacerHeightPx);
718        mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);",
719                mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h));
720
721        mViewState.setExpansionState(item.getMessage(),
722                item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED);
723    }
724
725    @Override
726    public void showExternalResources(Message msg) {
727        mWebView.getSettings().setBlockNetworkImage(false);
728        mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');");
729    }
730    // END message header callbacks
731
732    @Override
733    public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
734        MessageCursor cursor = getMessageCursor();
735        if (cursor == null || !mViewsCreated) {
736            return;
737        }
738
739        mTempBodiesHtml = renderCollapsedHeaders(cursor, item);
740        mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
741    }
742
743    private void showNewMessageNotification(NewMessagesInfo info) {
744        final TextView descriptionView = (TextView) mNewMessageBar.findViewById(
745                R.id.new_message_description);
746        descriptionView.setText(info.getNotificationText());
747        mNewMessageBar.setVisibility(View.VISIBLE);
748    }
749
750    private void onNewMessageBarClick() {
751        mNewMessageBar.setVisibility(View.GONE);
752
753        renderConversation(getMessageCursor()); // mCursor is already up-to-date
754                                                // per onLoadFinished()
755    }
756
757    private static int[] parseInts(final String[] stringArray) {
758        final int len = stringArray.length;
759        final int[] ints = new int[len];
760        for (int i = 0; i < len; i++) {
761            ints[i] = Integer.parseInt(stringArray[i]);
762        }
763        return ints;
764    }
765
766    @Override
767    public String toString() {
768        // log extra info at DEBUG level or finer
769        final String s = super.toString();
770        if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
771            return s;
772        }
773        return "(" + s + " subj=" + mConversation.subject + ")";
774    }
775
776    private Address getAddress(String rawFrom) {
777        Address addr = mAddressCache.get(rawFrom);
778        if (addr == null) {
779            addr = Address.getEmailAddress(rawFrom);
780            mAddressCache.put(rawFrom, addr);
781        }
782        return addr;
783    }
784
785    @Override
786    public Account getAccount() {
787        return mAccount;
788    }
789
790    private void ensureContentSizeChangeListener() {
791        if (mWebViewSizeChangeListener == null) {
792            mWebViewSizeChangeListener = new ConversationWebView.ContentSizeChangeListener() {
793                @Override
794                public void onHeightChange(int h) {
795                    // When WebKit says the DOM height has changed, re-measure
796                    // bodies and re-position their headers.
797                    // This is separate from the typical JavaScript DOM change
798                    // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM
799                    // events.
800                    mWebView.loadUrl("javascript:measurePositions();");
801                }
802            };
803        }
804        mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener);
805    }
806
807    private class ConversationWebViewClient extends AbstractConversationWebViewClient {
808        @Override
809        public void onPageFinished(WebView view, String url) {
810            // Ignore unsafe calls made after a fragment is detached from an activity
811            final ControllableActivity activity = (ControllableActivity) getActivity();
812            if (activity == null || !mViewsCreated) {
813                LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
814                        ConversationViewFragment.this);
815                return;
816            }
817
818            LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s act=%s", url,
819                    ConversationViewFragment.this, getActivity());
820
821            super.onPageFinished(view, url);
822
823            ensureContentSizeChangeListener();
824
825            // TODO: save off individual message unread state (here, or in onLoadFinished?) so
826            // 'mark unread' restores the original unread state for each individual message
827
828            if (isUserVisible()) {
829                onConversationSeen();
830            }
831            if (!mEnableContentReadySignal) {
832                dismissLoadingStatus();
833            }
834
835            // We are not able to use the loader manager unless this fragment is added to the
836            // activity
837            if (isAdded()) {
838                final Set<String> emailAddresses = Sets.newHashSet();
839                for (Address addr : mAddressCache.values()) {
840                    emailAddresses.add(addr.getAddress());
841                }
842                ContactLoaderCallbacks callbacks = getContactInfoSource();
843                getContactInfoSource().setSenders(emailAddresses);
844                getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks);
845            }
846        }
847
848        @Override
849        public boolean shouldOverrideUrlLoading(WebView view, String url) {
850            return mViewsCreated && super.shouldOverrideUrlLoading(view, url);
851        }
852    }
853
854    /**
855     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
856     * via reflection and not stripped.
857     *
858     */
859    private class MailJsBridge {
860
861        @SuppressWarnings("unused")
862        @JavascriptInterface
863        public void onWebContentGeometryChange(final String[] overlayBottomStrs) {
864            try {
865                getHandler().post(new Runnable() {
866                    @Override
867                    public void run() {
868                        if (!mViewsCreated) {
869                            LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" +
870                                    " are gone, %s", ConversationViewFragment.this);
871                            return;
872                        }
873
874                        mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs));
875                    }
876                });
877            } catch (Throwable t) {
878                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
879            }
880        }
881
882        @SuppressWarnings("unused")
883        @JavascriptInterface
884        public String getTempMessageBodies() {
885            try {
886                if (!mViewsCreated) {
887                    return "";
888                }
889
890                final String s = mTempBodiesHtml;
891                mTempBodiesHtml = null;
892                return s;
893            } catch (Throwable t) {
894                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
895                return "";
896            }
897        }
898
899        @SuppressWarnings("unused")
900        @JavascriptInterface
901        public String getMessageBody(String domId) {
902            try {
903                final MessageCursor cursor = getMessageCursor();
904                if (!mViewsCreated || cursor == null) {
905                    return "";
906                }
907
908                int pos = -1;
909                while (cursor.moveToPosition(++pos)) {
910                    final ConversationMessage msg = cursor.getMessage();
911                    if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
912                        return msg.getBodyAsHtml();
913                    }
914                }
915
916                return "";
917
918            } catch (Throwable t) {
919                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody");
920                return "";
921            }
922        }
923
924        private void showConversation(Conversation conv) {
925            dismissLoadingStatus();
926        }
927
928        @SuppressWarnings("unused")
929        @JavascriptInterface
930        public void onContentReady() {
931            final Conversation conv = mConversation;
932            try {
933                getHandler().post(new Runnable() {
934                    @Override
935                    public void run() {
936                        LogUtils.d(LOG_TAG, "ANIMATION STARTED, ready to draw. t=%s",
937                                SystemClock.uptimeMillis());
938                        showConversation(conv);
939                    }
940                });
941            } catch (Throwable t) {
942                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
943                // Still try to show the conversation.
944                showConversation(conv);
945            }
946        }
947    }
948
949    private class NewMessagesInfo {
950        int count;
951        String senderAddress;
952
953        /**
954         * Return the display text for the new message notification overlay. It will be formatted
955         * appropriately for a single new message vs. multiple new messages.
956         *
957         * @return display text
958         */
959        public String getNotificationText() {
960            Resources res = getResources();
961            if (count > 1) {
962                return res.getString(R.string.new_incoming_messages_many, count);
963            } else {
964                final Address addr = getAddress(senderAddress);
965                return res.getString(R.string.new_incoming_messages_one,
966                        TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName());
967            }
968        }
969    }
970
971    @Override
972    public void onMessageCursorLoadFinished(Loader<Cursor> loader, MessageCursor newCursor,
973            MessageCursor oldCursor) {
974        /*
975         * what kind of changes affect the MessageCursor? 1. new message(s) 2.
976         * read/unread state change 3. deleted message, either regular or draft
977         * 4. updated message, either from self or from others, updated in
978         * content or state or sender 5. star/unstar of message (technically
979         * similar to #1) 6. other label change Use MessageCursor.hashCode() to
980         * sort out interesting vs. no-op cursor updates.
981         */
982        final boolean changed = newCursor != null && oldCursor != null
983                && newCursor.hashCode() != oldCursor.hashCode();
984
985        if (oldCursor != null) {
986            final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor);
987
988            if (info.count > 0) {
989                // don't immediately render new incoming messages from other
990                // senders
991                // (to avoid a new message from losing the user's focus)
992                LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
993                        + ", holding cursor for new incoming message (%s)", this);
994                showNewMessageNotification(info);
995                return;
996            }
997
998            if (!changed) {
999                final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor);
1000                if (processedInPlace) {
1001                    LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this);
1002                } else {
1003                    LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update"
1004                            + ", ignoring this conversation update (%s)", this);
1005                }
1006                return;
1007            }
1008        }
1009
1010        // cursors are different, and not due to an incoming message. fall
1011        // through and render.
1012        LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1013                + ", but not due to incoming message. rendering. (%s)", this);
1014
1015        // if layout hasn't happened, delay render
1016        // This is needed in addition to the showConversation() delay to speed
1017        // up rotation and restoration.
1018        if (mConversationContainer.getWidth() == 0) {
1019            mNeedRender = true;
1020            mConversationContainer.addOnLayoutChangeListener(this);
1021        } else {
1022            renderConversation(newCursor);
1023        }
1024    }
1025
1026    private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
1027        final NewMessagesInfo info = new NewMessagesInfo();
1028
1029        int pos = -1;
1030        while (newCursor.moveToPosition(++pos)) {
1031            final Message m = newCursor.getMessage();
1032            if (!mViewState.contains(m)) {
1033                LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
1034
1035                final Address from = getAddress(m.from);
1036                // distinguish ours from theirs
1037                // new messages from the account owner should not trigger a
1038                // notification
1039                if (mAccount.ownsFromAddress(from.getAddress())) {
1040                    LogUtils.i(LOG_TAG, "found message from self: %s", m.uri);
1041                    continue;
1042                }
1043
1044                info.count++;
1045                info.senderAddress = m.from;
1046            }
1047        }
1048        return info;
1049    }
1050
1051    private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) {
1052        final Set<String> idsOfChangedBodies = Sets.newHashSet();
1053        boolean changed = false;
1054
1055        int pos = 0;
1056        while (true) {
1057            if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) {
1058                break;
1059            }
1060
1061            final ConversationMessage newMsg = newCursor.getMessage();
1062            final ConversationMessage oldMsg = oldCursor.getMessage();
1063
1064            if (!TextUtils.equals(newMsg.from, oldMsg.from)) {
1065                mAdapter.updateItemsForMessage(newMsg);
1066                LogUtils.i(LOG_TAG, "msg #%d (%d): detected sender change", pos, newMsg.id);
1067                changed = true;
1068            }
1069
1070            // update changed message bodies in-place
1071            if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) ||
1072                    !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) {
1073                // maybe just set a flag to notify JS to re-request changed bodies
1074                idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"');
1075                LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id);
1076            }
1077
1078            pos++;
1079        }
1080
1081        if (!idsOfChangedBodies.isEmpty()) {
1082            mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);",
1083                    TextUtils.join(",", idsOfChangedBodies)));
1084            changed = true;
1085        }
1086
1087        return changed;
1088    }
1089
1090    private class SetCookieTask extends AsyncTask<Void, Void, Void> {
1091        final String mUri;
1092        final String mCookie;
1093
1094        SetCookieTask(String uri, String cookie) {
1095            mUri = uri;
1096            mCookie = cookie;
1097        }
1098
1099        @Override
1100        public Void doInBackground(Void... args) {
1101            final CookieSyncManager csm =
1102                CookieSyncManager.createInstance(getContext());
1103            CookieManager.getInstance().setCookie(mUri, mCookie);
1104            csm.sync();
1105            return null;
1106        }
1107    }
1108
1109    @Override
1110    public void onConversationUpdated(Conversation conv) {
1111        final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer
1112                .findViewById(R.id.conversation_header);
1113        mConversation = conv;
1114        if (headerView != null) {
1115            headerView.onConversationUpdated(conv);
1116        }
1117    }
1118
1119    @Override
1120    public void onLayoutChange(View v, int left, int top, int right,
1121            int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
1122        boolean sizeChanged = mNeedRender
1123                && mConversationContainer.getWidth() != 0;
1124        if (sizeChanged) {
1125            mNeedRender = false;
1126            mConversationContainer.removeOnLayoutChangeListener(this);
1127            renderConversation(getMessageCursor());
1128        }
1129    }
1130}
1131