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