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