ConversationViewFragment.java revision 41dca185f7683b36bdafd9520c0648c897a95834
1/*
2 * Copyright (C) 2012 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.ui;
19
20import android.app.Activity;
21import android.app.Fragment;
22import android.app.LoaderManager;
23import android.content.ActivityNotFoundException;
24import android.content.Context;
25import android.content.CursorLoader;
26import android.content.Intent;
27import android.content.Loader;
28import android.database.Cursor;
29import android.database.DataSetObservable;
30import android.database.DataSetObserver;
31import android.net.Uri;
32import android.os.Bundle;
33import android.os.Handler;
34import android.provider.Browser;
35import android.text.TextUtils;
36import android.view.LayoutInflater;
37import android.view.Menu;
38import android.view.MenuInflater;
39import android.view.MenuItem;
40import android.view.View;
41import android.view.ViewGroup;
42import android.webkit.ConsoleMessage;
43import android.webkit.WebChromeClient;
44import android.webkit.WebSettings;
45import android.webkit.WebView;
46import android.webkit.WebViewClient;
47import android.widget.TextView;
48
49import com.android.mail.ContactInfo;
50import com.android.mail.ContactInfoSource;
51import com.android.mail.R;
52import com.android.mail.SenderInfoLoader;
53import com.android.mail.browse.ConversationContainer;
54import com.android.mail.browse.ConversationOverlayItem;
55import com.android.mail.browse.ConversationViewAdapter;
56import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem;
57import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
58import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
59import com.android.mail.browse.ConversationViewHeader;
60import com.android.mail.browse.ConversationWebView;
61import com.android.mail.browse.MessageCursor;
62import com.android.mail.browse.MessageCursor.ConversationMessage;
63import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
64import com.android.mail.browse.SuperCollapsedBlock;
65import com.android.mail.browse.WebViewContextMenu;
66import com.android.mail.providers.Account;
67import com.android.mail.providers.Address;
68import com.android.mail.providers.Conversation;
69import com.android.mail.providers.Folder;
70import com.android.mail.providers.ListParams;
71import com.android.mail.providers.Message;
72import com.android.mail.providers.Settings;
73import com.android.mail.providers.UIProvider;
74import com.android.mail.providers.UIProvider.AccountCapabilities;
75import com.android.mail.providers.UIProvider.FolderCapabilities;
76import com.android.mail.utils.LogTag;
77import com.android.mail.utils.LogUtils;
78import com.android.mail.utils.Utils;
79import com.google.common.collect.ImmutableMap;
80import com.google.common.collect.Lists;
81import com.google.common.collect.Maps;
82import com.google.common.collect.Sets;
83
84import org.json.JSONException;
85
86import java.util.Arrays;
87import java.util.List;
88import java.util.Map;
89import java.util.Set;
90
91
92/**
93 * The conversation view UI component.
94 */
95public final class ConversationViewFragment extends Fragment implements
96        ConversationViewHeader.ConversationViewHeaderCallbacks,
97        MessageHeaderViewCallbacks,
98        SuperCollapsedBlock.OnClickListener {
99
100    private static final String LOG_TAG = LogTag.getLogTag();
101    public static final String LAYOUT_TAG = "ConvLayout";
102
103    private static final int MESSAGE_LOADER_ID = 0;
104    private static final int CONTACT_LOADER_ID = 1;
105
106    private ControllableActivity mActivity;
107
108    private Context mContext;
109
110    private Conversation mConversation;
111
112    private ConversationContainer mConversationContainer;
113
114    private Account mAccount;
115
116    private ConversationWebView mWebView;
117
118    private View mNewMessageBar;
119
120    private HtmlConversationTemplates mTemplates;
121
122    private String mBaseUri;
123
124    private final Handler mHandler = new Handler();
125
126    private final MailJsBridge mJsBridge = new MailJsBridge();
127
128    private final WebViewClient mWebViewClient = new ConversationWebViewClient();
129
130    private ConversationViewAdapter mAdapter;
131    private MessageCursor mCursor;
132    private MessageCursor mPendingCursor;
133
134    private boolean mViewsCreated;
135
136    private MenuItem mChangeFoldersMenuItem;
137
138    /**
139     * Folder is used to help determine valid menu actions for this conversation.
140     */
141    private Folder mFolder;
142
143    private final Map<String, Address> mAddressCache = Maps.newHashMap();
144
145    /**
146     * Temporary string containing the message bodies of the messages within a super-collapsed
147     * block, for one-time use during block expansion. We cannot easily pass the body HTML
148     * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it
149     * using {@link MailJsBridge}.
150     */
151    private String mTempBodiesHtml;
152
153    private boolean mUserVisible;
154
155    private int  mMaxAutoLoadMessages;
156
157    private boolean mDeferredConversationLoad;
158
159    /**
160     * Handles a deferred 'mark read' operation, necessary when the conversation view has finished
161     * loading before the conversation cursor. Normally null unless this situation occurs.
162     * When finally able to 'mark read', this observer will also be unregistered and cleaned up.
163     */
164    private MarkReadObserver mMarkReadObserver;
165
166    /**
167     * Parcelable state of the conversation view. Can safely be used without null checking any time
168     * after {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)}.
169     */
170    private ConversationViewState mViewState;
171
172    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
173    private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks();
174
175    private static final String ARG_ACCOUNT = "account";
176    public static final String ARG_CONVERSATION = "conversation";
177    private static final String ARG_FOLDER = "folder";
178    private static final String BUNDLE_VIEW_STATE = "viewstate";
179
180    private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
181    private static final boolean DISABLE_OFFSCREEN_LOADING = false;
182
183    /**
184     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
185     */
186    public ConversationViewFragment() {
187        super();
188    }
189
190    /**
191     * Creates a new instance of {@link ConversationViewFragment}, initialized
192     * to display a conversation with other parameters inherited/copied from an existing bundle,
193     * typically one created using {@link #makeBasicArgs}.
194     */
195    public static ConversationViewFragment newInstance(Bundle existingArgs,
196            Conversation conversation) {
197        ConversationViewFragment f = new ConversationViewFragment();
198        Bundle args = new Bundle(existingArgs);
199        args.putParcelable(ARG_CONVERSATION, conversation);
200        f.setArguments(args);
201        return f;
202    }
203
204    public static Bundle makeBasicArgs(Account account, Folder folder) {
205        Bundle args = new Bundle();
206        args.putParcelable(ARG_ACCOUNT, account);
207        args.putParcelable(ARG_FOLDER, folder);
208        return args;
209    }
210
211    @Override
212    public void onActivityCreated(Bundle savedInstanceState) {
213        LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s subj=%s", this,
214                mConversation.subject);
215        super.onActivityCreated(savedInstanceState);
216        // Strictly speaking, we get back an android.app.Activity from getActivity. However, the
217        // only activity creating a ConversationListContext is a MailActivity which is of type
218        // ControllableActivity, so this cast should be safe. If this cast fails, some other
219        // activity is creating ConversationListFragments. This activity must be of type
220        // ControllableActivity.
221        final Activity activity = getActivity();
222        if (!(activity instanceof ControllableActivity)) {
223            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
224                    + "create it. Cannot proceed.");
225        }
226        mActivity = (ControllableActivity) activity;
227        mContext = mActivity.getApplicationContext();
228        if (mActivity.isFinishing()) {
229            // Activity is finishing, just bail.
230            return;
231        }
232        mTemplates = new HtmlConversationTemplates(mContext);
233
234        mAdapter = new ConversationViewAdapter(mActivity.getActivityContext(), mAccount,
235                getLoaderManager(), this, mContactLoaderCallbacks, this, this, mAddressCache);
236        mConversationContainer.setOverlayAdapter(mAdapter);
237
238        mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages);
239
240        mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(activity));
241
242        showConversation();
243    }
244
245    @Override
246    public void onCreate(Bundle savedState) {
247        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
248        super.onCreate(savedState);
249
250        final Bundle args = getArguments();
251        mAccount = args.getParcelable(ARG_ACCOUNT);
252        mConversation = args.getParcelable(ARG_CONVERSATION);
253        mFolder = args.getParcelable(ARG_FOLDER);
254        // If the provider has specified a base uri to be used, use that one.
255        mBaseUri = mConversation.conversationBaseUri != null ?
256                mConversation.conversationBaseUri.toString() :
257                "x-thread://" + mAccount.name + "/" + mConversation.id;
258
259        // Not really, we just want to get a crack to store a reference to the change_folder item
260        setHasOptionsMenu(true);
261    }
262
263    @Override
264    public View onCreateView(LayoutInflater inflater,
265            ViewGroup container, Bundle savedInstanceState) {
266
267        if (savedInstanceState != null) {
268            mViewState = savedInstanceState.getParcelable(BUNDLE_VIEW_STATE);
269        } else {
270            mViewState = new ConversationViewState();
271        }
272
273        View rootView = inflater.inflate(R.layout.conversation_view, container, false);
274        mConversationContainer = (ConversationContainer) rootView
275                .findViewById(R.id.conversation_container);
276
277        mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar);
278        mNewMessageBar.setOnClickListener(new View.OnClickListener() {
279            @Override
280            public void onClick(View v) {
281                onNewMessageBarClick();
282            }
283        });
284
285        mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
286
287        mWebView.addJavascriptInterface(mJsBridge, "mail");
288        mWebView.setWebViewClient(mWebViewClient);
289        mWebView.setWebChromeClient(new WebChromeClient() {
290            @Override
291            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
292                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
293                        consoleMessage.sourceId(), consoleMessage.lineNumber());
294                return true;
295            }
296        });
297        mWebView.setContentSizeChangeListener(new ConversationWebView.ContentSizeChangeListener() {
298            @Override
299            public void onHeightChange(int h) {
300                // When WebKit says the DOM height has changed, re-measure bodies and re-position
301                // their headers.
302                // This is separate from the typical JavaScript DOM change listeners because
303                // cases like NARROW_COLUMNS text reflow do not trigger DOM events.
304                mWebView.loadUrl("javascript:measurePositions();");
305            }
306        });
307
308        final WebSettings settings = mWebView.getSettings();
309
310        settings.setJavaScriptEnabled(true);
311        settings.setUseWideViewPort(true);
312        settings.setLoadWithOverviewMode(true);
313
314        settings.setSupportZoom(true);
315        settings.setBuiltInZoomControls(true);
316        settings.setDisplayZoomControls(false);
317
318        final float fontScale = getResources().getConfiguration().fontScale;
319        final int desiredFontSizePx = getResources()
320                .getInteger(R.integer.conversation_desired_font_size_px);
321        final int unstyledFontSizePx = getResources()
322                .getInteger(R.integer.conversation_unstyled_font_size_px);
323
324        int textZoom = settings.getTextZoom();
325        // apply a correction to the default body text style to get regular text to the size we want
326        textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx;
327        // then apply any system font scaling
328        textZoom = (int) (textZoom * fontScale);
329        settings.setTextZoom(textZoom);
330
331        mViewsCreated = true;
332
333        return rootView;
334    }
335
336    @Override
337    public void onSaveInstanceState(Bundle outState) {
338        if (mViewState != null) {
339            outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
340        }
341    }
342
343    @Override
344    public void onDestroyView() {
345        super.onDestroyView();
346        mConversationContainer.setOverlayAdapter(null);
347        mAdapter = null;
348        if (mMarkReadObserver != null) {
349            mActivity.getConversationUpdater().unregisterConversationListObserver(
350                    mMarkReadObserver);
351            mMarkReadObserver = null;
352        }
353        mViewsCreated = false;
354    }
355
356    @Override
357    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
358        super.onCreateOptionsMenu(menu, inflater);
359
360        mChangeFoldersMenuItem = menu.findItem(R.id.change_folder);
361    }
362
363    @Override
364    public void onPrepareOptionsMenu(Menu menu) {
365        super.onPrepareOptionsMenu(menu);
366        final boolean showMarkImportant = !mConversation.isImportant();
367        Utils.setMenuItemVisibility(
368                menu,
369                R.id.mark_important,
370                showMarkImportant
371                        && mAccount
372                                .supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
373        Utils.setMenuItemVisibility(
374                menu,
375                R.id.mark_not_important,
376                !showMarkImportant
377                        && mAccount
378                                .supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
379        // TODO(mindyp) show/ hide spam and mute based on conversation
380        // properties to be added.
381        Utils.setMenuItemVisibility(menu, R.id.archive,
382                mAccount.supportsCapability(AccountCapabilities.ARCHIVE) && mFolder != null
383                        && mFolder.supportsCapability(FolderCapabilities.ARCHIVE));
384        Utils.setMenuItemVisibility(menu, R.id.report_spam,
385                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
386                        && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM)
387                        && !mConversation.spam);
388        Utils.setMenuItemVisibility(menu, R.id.mark_not_spam,
389                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
390                        && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM)
391                        && mConversation.spam);
392        Utils.setMenuItemVisibility(menu, R.id.report_phishing,
393                mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null
394                        && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING)
395                        && !mConversation.phishing);
396        Utils.setMenuItemVisibility(
397                menu,
398                R.id.mute,
399                mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null
400                        && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)
401                        && !mConversation.muted);
402    }
403
404    @Override
405    public boolean onOptionsItemSelected(MenuItem item) {
406        boolean handled = false;
407
408        switch (item.getItemId()) {
409            case R.id.inside_conversation_unread:
410                markUnread();
411                handled = true;
412                break;
413        }
414
415        return handled;
416    }
417
418    private void markUnread() {
419        // Ignore unsafe calls made after a fragment is detached from an activity
420        final ControllableActivity activity = (ControllableActivity) getActivity();
421        if (activity == null) {
422            LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
423            return;
424        }
425
426        if (mViewState == null) {
427            LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
428                    mConversation.id);
429            return;
430        }
431        activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
432                mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
433    }
434
435    /**
436     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
437     * reliability on older platforms.
438     */
439    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
440        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
441
442        if (mUserVisible != isVisibleToUser) {
443            mUserVisible = isVisibleToUser;
444
445            if (isVisibleToUser && mViewsCreated) {
446
447                if (mCursor == null && mDeferredConversationLoad) {
448                    // load
449                    LogUtils.v(LOG_TAG, "Fragment is now user-visible, showing conversation: %s",
450                            mConversation.uri);
451                    showConversation();
452                    mDeferredConversationLoad = false;
453                } else {
454                    onConversationSeen();
455                }
456
457            }
458        }
459    }
460
461    /**
462     * Handles a request to show a new conversation list, either from a search query or for viewing
463     * a folder. This will initiate a data load, and hence must be called on the UI thread.
464     */
465    private void showConversation() {
466        final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
467                || (mConversation.getNumMessages() > mMaxAutoLoadMessages);
468        if (!mUserVisible && disableOffscreenLoading) {
469            LogUtils.v(LOG_TAG, "Fragment not user-visible, not showing conversation: %s",
470                    mConversation.uri);
471            mDeferredConversationLoad = true;
472            return;
473        }
474        LogUtils.v(LOG_TAG,
475                "Fragment is short or user-visible, immediately rendering conversation: %s",
476                mConversation.uri);
477        getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, mMessageLoaderCallbacks);
478    }
479
480    public Conversation getConversation() {
481        return mConversation;
482    }
483
484    private void renderConversation(MessageCursor messageCursor) {
485        final String convHtml = renderMessageBodies(messageCursor);
486
487        if (DEBUG_DUMP_CONVERSATION_HTML) {
488            java.io.FileWriter fw = null;
489            try {
490                fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id
491                        + ".html");
492                fw.write(convHtml);
493            } catch (java.io.IOException e) {
494                e.printStackTrace();
495            } finally {
496                if (fw != null) {
497                    try {
498                        fw.close();
499                    } catch (java.io.IOException e) {
500                        e.printStackTrace();
501                    }
502                }
503            }
504        }
505
506        mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
507        mCursor = messageCursor;
508    }
509
510    /**
511     * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
512     * conversation header), and return an HTML document with spacer divs inserted for all overlays.
513     *
514     */
515    private String renderMessageBodies(MessageCursor messageCursor) {
516        int pos = -1;
517
518        LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s subj=%s", this,
519                mConversation.subject);
520        boolean allowNetworkImages = false;
521
522        // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
523        final Settings settings = mActivity.getSettings();
524        if (settings != null) {
525            mAdapter.setDefaultReplyAll(settings.replyBehavior ==
526                    UIProvider.DefaultReplyBehavior.REPLY_ALL);
527        }
528        // Walk through the cursor and build up an overlay adapter as you go.
529        // Each overlay has an entry in the adapter for easy scroll handling in the container.
530        // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
531        // When adding adapter items, also add their heights to help the container later determine
532        // overlay dimensions.
533
534        mAdapter.clear();
535
536        // re-evaluate the message parts of the view state, since the messages may have changed
537        // since the previous render
538        final ConversationViewState prevState = mViewState;
539        mViewState = new ConversationViewState(prevState);
540
541        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
542        // a pixel is an mdpi pixel, unless you set device-dpi.
543
544        // add a single conversation header item
545        final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
546        final int convHeaderPx = measureOverlayHeight(convHeaderPos);
547
548        mTemplates.startConversation(mWebView.screenPxToWebPx(convHeaderPx));
549
550        int collapsedStart = -1;
551        ConversationMessage prevCollapsedMsg = null;
552        boolean prevSafeForImages = false;
553
554        while (messageCursor.moveToPosition(++pos)) {
555            final ConversationMessage msg = messageCursor.getMessage();
556
557            // TODO: save/restore 'show pics' state
558            final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
559            allowNetworkImages |= safeForImages;
560
561            final Boolean savedExpanded = prevState.getExpandedState(msg);
562            final boolean expanded;
563            if (savedExpanded != null) {
564                expanded = savedExpanded;
565                mViewState.setExpandedState(msg, expanded);
566            } else {
567                expanded = !msg.read || msg.starred || messageCursor.isLast();
568            }
569
570            // save off "read" state from the cursor
571            // later, the view may not match the cursor (e.g. conversation marked read on open)
572            mViewState.setReadState(msg, msg.read);
573
574            if (savedExpanded == null && !expanded) {
575                // contribute to a super-collapsed block that will be emitted just before the next
576                // expanded header
577                if (collapsedStart < 0) {
578                    collapsedStart = pos;
579                }
580                prevCollapsedMsg = msg;
581                prevSafeForImages = safeForImages;
582                continue;
583            }
584
585            // resolve any deferred decisions on previous collapsed items
586            if (collapsedStart >= 0) {
587                if (pos - collapsedStart == 1) {
588                    // special-case for a single collapsed message: no need to super-collapse it
589                    renderMessage(prevCollapsedMsg, false /* expanded */,
590                            prevSafeForImages);
591                } else {
592                    renderSuperCollapsedBlock(collapsedStart, pos - 1);
593                }
594                prevCollapsedMsg = null;
595                collapsedStart = -1;
596            }
597
598            renderMessage(msg, expanded, safeForImages);
599        }
600
601        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
602
603        return mTemplates.endConversation(mBaseUri, 320, mWebView.getViewportWidth());
604    }
605
606    private void renderSuperCollapsedBlock(int start, int end) {
607        final int blockPos = mAdapter.addSuperCollapsedBlock(start, end);
608        final int blockPx = measureOverlayHeight(blockPos);
609        mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
610    }
611
612    private void renderMessage(ConversationMessage msg, boolean expanded,
613            boolean safeForImages) {
614        final int headerPos = mAdapter.addMessageHeader(msg, expanded);
615        final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
616
617        final int footerPos = mAdapter.addMessageFooter(headerItem);
618
619        // Measure item header and footer heights to allocate spacers in HTML
620        // But since the views themselves don't exist yet, render each item temporarily into
621        // a host view for measurement.
622        final int headerPx = measureOverlayHeight(headerPos);
623        final int footerPx = measureOverlayHeight(footerPos);
624
625        mTemplates.appendMessageHtml(msg, expanded, safeForImages, 1.0f,
626                mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
627    }
628
629    private String renderCollapsedHeaders(MessageCursor cursor,
630            SuperCollapsedBlockItem blockToReplace) {
631        final List<ConversationOverlayItem> replacements = Lists.newArrayList();
632
633        mTemplates.reset();
634
635        for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
636            cursor.moveToPosition(i);
637            final ConversationMessage msg = cursor.getMessage();
638            final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg,
639                    false /* expanded */);
640            final MessageFooterItem footer = mAdapter.newMessageFooterItem(header);
641
642            final int headerPx = measureOverlayHeight(header);
643            final int footerPx = measureOverlayHeight(footer);
644
645            mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages, 1.0f,
646                    mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
647            replacements.add(header);
648            replacements.add(footer);
649
650            mViewState.setExpandedState(msg, false);
651        }
652
653        mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
654
655        return mTemplates.emit();
656    }
657
658    private int measureOverlayHeight(int position) {
659        return measureOverlayHeight(mAdapter.getItem(position));
660    }
661
662    /**
663     * Measure the height of an adapter view by rendering an adapter item into a temporary
664     * host view, and asking the view to immediately measure itself. This method will reuse
665     * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
666     * earlier.
667     * <p>
668     * After measuring the height, this method also saves the height in the
669     * {@link ConversationOverlayItem} for later use in overlay positioning.
670     *
671     * @param convItem adapter item with data to render and measure
672     * @return height of the rendered view in screen px
673     */
674    private int measureOverlayHeight(ConversationOverlayItem convItem) {
675        final int type = convItem.getType();
676
677        final View convertView = mConversationContainer.getScrapView(type);
678        final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
679                true /* measureOnly */);
680        if (convertView == null) {
681            mConversationContainer.addScrapView(type, hostView);
682        }
683
684        final int heightPx = mConversationContainer.measureOverlay(hostView);
685        convItem.setHeight(heightPx);
686        convItem.markMeasurementValid();
687
688        return heightPx;
689    }
690
691    private void onConversationSeen() {
692        // Ignore unsafe calls made after a fragment is detached from an activity
693        final ControllableActivity activity = (ControllableActivity) getActivity();
694        if (activity == null) {
695            LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id);
696            return;
697        }
698
699        // Viewing a conversation should always update the "viewed" status. We do not want to update
700        // the read state every single time, but since we are doing an update, an additional update
701        // to the read state should be safe.
702        try {
703            mViewState.setInfoForConversation(mConversation);
704
705            final ConversationUpdater listController = activity.getConversationUpdater();
706            // The conversation cursor may not have finished loading by now (when launched via
707            // notification), so watch for when it finishes and mark it read then.
708            if (listController.getConversationListCursor() == null) {
709                LogUtils.i(LOG_TAG, "deferring conv mark read on open for id=%d",
710                        mConversation.id);
711                mMarkReadObserver = new MarkReadObserver(listController);
712                listController.registerConversationListObserver(mMarkReadObserver);
713            } else {
714                // Mark the conversation viewed and read.
715                listController.markConversationsRead(Arrays.asList(mConversation),
716                        true, true);
717            }
718
719        } catch (JSONException e) {
720            LogUtils.w(LOG_TAG, e, "bad ConversationInfo, unable to mark conversation read");
721        }
722
723        activity.onConversationSeen(mConversation);
724
725        final SubjectDisplayChanger sdc = activity.getSubjectDisplayChanger();
726        if (sdc != null) {
727            sdc.setSubject(mConversation.subject);
728        }
729    }
730
731    // BEGIN conversation header callbacks
732    @Override
733    public void onFoldersClicked() {
734        if (mChangeFoldersMenuItem == null) {
735            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
736            return;
737        }
738        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
739    }
740
741    @Override
742    public void onConversationViewHeaderHeightChange(int newHeight) {
743        // TODO: propagate the new height to the header's HTML spacer. This can happen when labels
744        // are added/removed
745    }
746
747    @Override
748    public String getSubjectRemainder(String subject) {
749        final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
750        if (sdc == null) {
751            return subject;
752        }
753        return sdc.getUnshownSubject(subject);
754    }
755    // END conversation header callbacks
756
757    // START message header callbacks
758    @Override
759    public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
760        mConversationContainer.invalidateSpacerGeometry();
761
762        // update message HTML spacer height
763        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
764        LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
765                newSpacerHeightPx);
766        mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %d);",
767                mTemplates.getMessageDomId(item.message), h));
768    }
769
770    @Override
771    public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
772        mConversationContainer.invalidateSpacerGeometry();
773
774        // show/hide the HTML message body and update the spacer height
775        final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
776        LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
777                item.isExpanded(), h, newSpacerHeightPx);
778        mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %d);",
779                mTemplates.getMessageDomId(item.message), item.isExpanded(), h));
780
781        mViewState.setExpandedState(item.message, item.isExpanded());
782    }
783
784    @Override
785    public void showExternalResources(Message msg) {
786        mWebView.getSettings().setBlockNetworkImage(false);
787        mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');");
788    }
789    // END message header callbacks
790
791    @Override
792    public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
793        if (mCursor == null || !mViewsCreated) {
794            return;
795        }
796
797        mTempBodiesHtml = renderCollapsedHeaders(mCursor, item);
798        mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
799    }
800
801    private void showNewMessageNotification(NewMessagesInfo info) {
802        final TextView descriptionView = (TextView) mNewMessageBar.findViewById(
803                R.id.new_message_description);
804        descriptionView.setText(info.getNotificationText());
805        mNewMessageBar.setVisibility(View.VISIBLE);
806    }
807
808    private void onNewMessageBarClick() {
809        mNewMessageBar.setVisibility(View.GONE);
810
811        renderConversation(mPendingCursor);
812        mPendingCursor = null;
813    }
814
815    private static class MessageLoader extends CursorLoader {
816        private boolean mDeliveredFirstResults = false;
817        private final Conversation mConversation;
818        private final ConversationUpdater mListController;
819
820        public MessageLoader(Context c, Conversation conv, ConversationUpdater updater) {
821            super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
822            mConversation = conv;
823            mListController = updater;
824        }
825
826        @Override
827        public Cursor loadInBackground() {
828            return new MessageCursor(super.loadInBackground(), mConversation, mListController);
829        }
830
831        @Override
832        public void deliverResult(Cursor result) {
833            // We want to deliver these results, and then we want to make sure that any subsequent
834            // queries do not hit the network
835            super.deliverResult(result);
836
837            if (!mDeliveredFirstResults) {
838                mDeliveredFirstResults = true;
839                Uri uri = getUri();
840
841                // Create a ListParams that tells the provider to not hit the network
842                final ListParams listParams =
843                        new ListParams(ListParams.NO_LIMIT, false /* useNetwork */);
844
845                // Build the new uri with this additional parameter
846                uri = uri.buildUpon().appendQueryParameter(
847                        UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build();
848                setUri(uri);
849            }
850        }
851    }
852
853    private static int[] parseInts(final String[] stringArray) {
854        final int len = stringArray.length;
855        final int[] ints = new int[len];
856        for (int i = 0; i < len; i++) {
857            ints[i] = Integer.parseInt(stringArray[i]);
858        }
859        return ints;
860    }
861
862    @Override
863    public String toString() {
864        // log extra info at DEBUG level or finer
865        final String s = super.toString();
866        if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
867            return s;
868        }
869        return "(" + s + " subj=" + mConversation.subject + ")";
870    }
871
872    private class ConversationWebViewClient extends WebViewClient {
873
874        @Override
875        public void onPageFinished(WebView view, String url) {
876            // Ignore unsafe calls made after a fragment is detached from an activity
877            final ControllableActivity activity = (ControllableActivity) getActivity();
878            if (activity == null || !mViewsCreated) {
879                LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
880                        ConversationViewFragment.this);
881                return;
882            }
883
884            LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s act=%s", url,
885                    ConversationViewFragment.this, getActivity());
886
887            super.onPageFinished(view, url);
888
889            // TODO: save off individual message unread state (here, or in onLoadFinished?) so
890            // 'mark unread' restores the original unread state for each individual message
891
892            if (mUserVisible) {
893                onConversationSeen();
894            }
895
896            final Set<String> emailAddresses = Sets.newHashSet();
897            for (Address addr : mAddressCache.values()) {
898                emailAddresses.add(addr.getAddress());
899            }
900            mContactLoaderCallbacks.setSenders(emailAddresses);
901            getLoaderManager().restartLoader(CONTACT_LOADER_ID, Bundle.EMPTY,
902                    mContactLoaderCallbacks);
903        }
904
905        @Override
906        public boolean shouldOverrideUrlLoading(WebView view, String url) {
907            final Activity activity = getActivity();
908            if (!mViewsCreated || activity == null) {
909                return false;
910            }
911
912            boolean result = false;
913            final Uri uri = Uri.parse(url);
914            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
915            intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName());
916            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
917
918            // FIXME: give provider a chance to customize url intents?
919            // Utils.addGoogleUriAccountIntentExtras(mContext, uri, mAccount, intent);
920
921            try {
922                activity.startActivity(intent);
923                result = true;
924            } catch (ActivityNotFoundException ex) {
925                // If no application can handle the URL, assume that the
926                // caller can handle it.
927            }
928
929            return result;
930        }
931
932    }
933
934    /**
935     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
936     * via reflection and not stripped.
937     *
938     */
939    private class MailJsBridge {
940
941        @SuppressWarnings("unused")
942        public void onWebContentGeometryChange(final String[] overlayBottomStrs) {
943            try {
944                mHandler.post(new Runnable() {
945                    @Override
946                    public void run() {
947                        if (!mViewsCreated) {
948                            LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" +
949                                    " are gone, %s", ConversationViewFragment.this);
950                            return;
951                        }
952
953                        mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs));
954                    }
955                });
956            } catch (Throwable t) {
957                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
958            }
959        }
960
961        @SuppressWarnings("unused")
962        public String getTempMessageBodies() {
963            try {
964                if (!mViewsCreated) {
965                    return "";
966                }
967
968                final String s = mTempBodiesHtml;
969                mTempBodiesHtml = null;
970                return s;
971            } catch (Throwable t) {
972                LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
973                return "";
974            }
975        }
976
977    }
978
979    private class NewMessagesInfo {
980        int count;
981        String senderAddress;
982
983        /**
984         * Return the display text for the new message notification overlay. It will be formatted
985         * appropriately for a single new message vs. multiple new messages.
986         *
987         * @return display text
988         */
989        public String getNotificationText() {
990            final Object param;
991            if (count > 1) {
992                param = count;
993            } else {
994                Address addr = mAddressCache.get(senderAddress);
995                if (addr == null) {
996                    addr = Address.getEmailAddress(senderAddress);
997                    mAddressCache.put(senderAddress, addr);
998                }
999                param = TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName();
1000            }
1001            return getResources().getQuantityString(R.plurals.new_incoming_messages, count, param);
1002        }
1003    }
1004
1005    private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
1006
1007        @Override
1008        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
1009            return new MessageLoader(mContext, mConversation, mActivity.getConversationUpdater());
1010        }
1011
1012        @Override
1013        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
1014            MessageCursor messageCursor = (MessageCursor) data;
1015
1016            // ignore truly duplicate results
1017            // this can happen when restoring after rotation
1018            if (mCursor == messageCursor) {
1019                return;
1020            }
1021
1022            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
1023                LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
1024            }
1025
1026            // ignore cursors that are still loading results
1027            if (!messageCursor.isLoaded()) {
1028                return;
1029            }
1030
1031            // TODO: handle ERROR status
1032
1033            if (messageCursor.getCount() == 0 && mCursor != null) {
1034                // TODO: need to exit this view- conversation may have been deleted, or for
1035                // whatever reason is now invalid (e.g. discard single draft)
1036                return;
1037            }
1038
1039            if (mCursor != null) {
1040                final NewMessagesInfo info = getNewIncomingMessagesInfo(messageCursor);
1041
1042                if (info.count > 0) {
1043                    // don't immediately render new incoming messages from other senders
1044                    // (to avoid a new message from losing the user's focus)
1045                    //
1046                    // hold the new cursor as pending for later render
1047                    mPendingCursor = messageCursor;
1048                    LogUtils.i(LOG_TAG,
1049                            "conversation updated, holding cursor for new incoming message");
1050
1051                    showNewMessageNotification(info);
1052
1053                    return;
1054                }
1055            }
1056
1057            if (mCursor == null) {
1058                LogUtils.i(LOG_TAG, "existing cursor is null, rendering from scratch");
1059            } else {
1060                // re-render?
1061                // or render just those messages that changed?
1062                LogUtils.i(LOG_TAG,
1063                        "conversation updated, but not due to incoming message. rendering.");
1064            }
1065            renderConversation(messageCursor);
1066
1067            // TODO: if this is not user-visible, delay render until user-visible fragment is done.
1068            // This is needed in addition to the showConversation() delay to speed up rotation and
1069            // restoration.
1070        }
1071
1072        @Override
1073        public void onLoaderReset(Loader<Cursor> loader) {
1074            mCursor = null;
1075            // TODO: null out all Message.mMessageCursor references
1076        }
1077
1078        private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
1079            final NewMessagesInfo info = new NewMessagesInfo();
1080
1081            int pos = -1;
1082            while (newCursor.moveToPosition(++pos)) {
1083                final Message m = newCursor.getMessage();
1084                if (!mViewState.contains(m)) {
1085                    LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
1086                    // TODO: distinguish ours from theirs
1087                    info.count++;
1088                    info.senderAddress = m.from;
1089                }
1090            }
1091            return info;
1092        }
1093
1094    }
1095
1096    /**
1097     * Inner class to to asynchronously load contact data for all senders in the conversation,
1098     * and notify observers when the data is ready.
1099     *
1100     */
1101    private class ContactLoaderCallbacks implements ContactInfoSource,
1102            LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> {
1103
1104        private Set<String> mSenders;
1105        private ImmutableMap<String, ContactInfo> mContactInfoMap;
1106        private DataSetObservable mObservable = new DataSetObservable();
1107
1108        public void setSenders(Set<String> emailAddresses) {
1109            mSenders = emailAddresses;
1110        }
1111
1112        @Override
1113        public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) {
1114            return new SenderInfoLoader(mContext, mSenders);
1115        }
1116
1117        @Override
1118        public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader,
1119                ImmutableMap<String, ContactInfo> data) {
1120            mContactInfoMap = data;
1121            mObservable.notifyChanged();
1122        }
1123
1124        @Override
1125        public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) {
1126        }
1127
1128        @Override
1129        public ContactInfo getContactInfo(String email) {
1130            if (mContactInfoMap == null) {
1131                return null;
1132            }
1133            return mContactInfoMap.get(email);
1134        }
1135
1136        @Override
1137        public void registerObserver(DataSetObserver observer) {
1138            mObservable.registerObserver(observer);
1139        }
1140
1141        @Override
1142        public void unregisterObserver(DataSetObserver observer) {
1143            mObservable.unregisterObserver(observer);
1144        }
1145
1146    }
1147
1148    private class MarkReadObserver extends DataSetObserver {
1149        private final ConversationUpdater mListController;
1150
1151        private MarkReadObserver(ConversationUpdater listController) {
1152            mListController = listController;
1153        }
1154
1155        @Override
1156        public void onChanged() {
1157            if (mListController.getConversationListCursor() == null) {
1158                // nothing yet, keep watching
1159                return;
1160            }
1161            // done loading, safe to mark read now
1162            mListController.unregisterConversationListObserver(this);
1163            mMarkReadObserver = null;
1164            LogUtils.i(LOG_TAG, "running deferred conv mark read on open, id=%d", mConversation.id);
1165            mListController.markConversationsRead(Arrays.asList(mConversation),
1166                    true /* viewed */, true /* read */);
1167        }
1168    }
1169
1170    @Override
1171    public Settings getSettings() {
1172        return mAccount.settings;
1173    }
1174
1175}
1176