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