ConversationViewFragment.java revision 7bdc3750454efe59617b7df945eadd7e59bee954
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.MotionEvent;
38import android.view.View;
39import android.view.ViewGroup;
40import android.webkit.ConsoleMessage;
41import android.webkit.WebChromeClient;
42import android.webkit.WebSettings;
43import android.webkit.WebView;
44import android.webkit.WebViewClient;
45
46import com.android.mail.R;
47import com.android.mail.browse.ConversationContainer;
48import com.android.mail.browse.ConversationViewAdapter;
49import com.android.mail.browse.ConversationViewAdapter.ConversationItem;
50import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
51import com.android.mail.browse.ConversationViewHeader;
52import com.android.mail.browse.ConversationWebView;
53import com.android.mail.browse.MessageCursor;
54import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
55import com.android.mail.providers.Account;
56import com.android.mail.providers.Conversation;
57import com.android.mail.providers.Folder;
58import com.android.mail.providers.ListParams;
59import com.android.mail.providers.Message;
60import com.android.mail.providers.UIProvider;
61import com.android.mail.providers.UIProvider.AccountCapabilities;
62import com.android.mail.providers.UIProvider.FolderCapabilities;
63import com.android.mail.utils.LogUtils;
64import com.android.mail.utils.Utils;
65
66
67/**
68 * The conversation view UI component.
69 */
70public final class ConversationViewFragment extends Fragment implements
71        LoaderManager.LoaderCallbacks<Cursor>,
72        ConversationViewHeader.ConversationViewHeaderCallbacks,
73        MessageHeaderViewCallbacks {
74
75    private static final String LOG_TAG = new LogUtils().getLogTag();
76
77    private static final int MESSAGE_LOADER_ID = 0;
78
79    private ControllableActivity mActivity;
80
81    private Context mContext;
82
83    private Conversation mConversation;
84
85    private ConversationContainer mConversationContainer;
86
87    private Account mAccount;
88
89    private ConversationWebView mWebView;
90
91    private HtmlConversationTemplates mTemplates;
92
93    private String mBaseUri;
94
95    private final Handler mHandler = new Handler();
96
97    private final MailJsBridge mJsBridge = new MailJsBridge();
98
99    private final WebViewClient mWebViewClient = new ConversationWebViewClient();
100
101    private ConversationViewAdapter mAdapter;
102    private MessageCursor mCursor;
103
104    private boolean mViewsCreated;
105
106    private MenuItem mChangeFoldersMenuItem;
107
108    private float mDensity;
109
110    private Folder mFolder;
111
112    private static final String ARG_ACCOUNT = "account";
113    private static final String ARG_CONVERSATION = "conversation";
114    private static final String ARG_FOLDER = "folder";
115
116    /**
117     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
118     */
119    public ConversationViewFragment() {
120        super();
121    }
122
123    /**
124     * Creates a new instance of {@link ConversationViewFragment}, initialized
125     * to display conversation.
126     */
127    public static ConversationViewFragment newInstance(Account account,
128            Conversation conversation, Folder folder) {
129       ConversationViewFragment f = new ConversationViewFragment();
130       Bundle args = new Bundle();
131       args.putParcelable(ARG_ACCOUNT, account);
132       args.putParcelable(ARG_CONVERSATION, conversation);
133       args.putParcelable(ARG_FOLDER, folder);
134       f.setArguments(args);
135       return f;
136    }
137
138    @Override
139    public void onActivityCreated(Bundle savedInstanceState) {
140        super.onActivityCreated(savedInstanceState);
141        // Strictly speaking, we get back an android.app.Activity from getActivity. However, the
142        // only activity creating a ConversationListContext is a MailActivity which is of type
143        // ControllableActivity, so this cast should be safe. If this cast fails, some other
144        // activity is creating ConversationListFragments. This activity must be of type
145        // ControllableActivity.
146        final Activity activity = getActivity();
147        if (!(activity instanceof ControllableActivity)) {
148            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
149                    + "create it. Cannot proceed.");
150        }
151        mActivity = (ControllableActivity) activity;
152        mContext = mActivity.getApplicationContext();
153        if (mActivity.isFinishing()) {
154            // Activity is finishing, just bail.
155            return;
156        }
157        mActivity.attachConversationView(this);
158        mTemplates = new HtmlConversationTemplates(mContext);
159
160        mAdapter = new ConversationViewAdapter(mActivity.getActivityContext(), mAccount,
161                getLoaderManager(), this, this);
162        mConversationContainer.setOverlayAdapter(mAdapter);
163
164        mDensity = getResources().getDisplayMetrics().density;
165
166        // Show conversation and start loading messages.
167        showConversation();
168    }
169
170    @Override
171    public void onCreate(Bundle savedState) {
172        LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this);
173        super.onCreate(savedState);
174
175        Bundle args = getArguments();
176        mAccount = args.getParcelable(ARG_ACCOUNT);
177        mConversation = args.getParcelable(ARG_CONVERSATION);
178        mFolder = args.getParcelable(ARG_FOLDER);
179        mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
180
181        // not really, we just want to get a crack to store a reference to the change_folders item
182        setHasOptionsMenu(true);
183    }
184
185    @Override
186    public View onCreateView(LayoutInflater inflater,
187            ViewGroup container, Bundle savedInstanceState) {
188        View rootView = inflater.inflate(R.layout.conversation_view, null);
189        mConversationContainer = (ConversationContainer) rootView
190                .findViewById(R.id.conversation_container);
191        mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
192
193        mWebView.addJavascriptInterface(mJsBridge, "mail");
194        mWebView.setWebViewClient(mWebViewClient);
195        mWebView.setWebChromeClient(new WebChromeClient() {
196            @Override
197            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
198                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
199                        consoleMessage.sourceId(), consoleMessage.lineNumber());
200                return true;
201            }
202        });
203
204        final WebSettings settings = mWebView.getSettings();
205
206        settings.setJavaScriptEnabled(true);
207        settings.setUseWideViewPort(true);
208
209        settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
210
211        settings.setSupportZoom(true);
212        settings.setBuiltInZoomControls(true);
213        settings.setDisplayZoomControls(false);
214
215        mViewsCreated = true;
216
217        return rootView;
218    }
219
220    @Override
221    public void onDestroyView() {
222        super.onDestroyView();
223        mViewsCreated = false;
224        mActivity.attachConversationView(null);
225    }
226
227    @Override
228    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
229        super.onCreateOptionsMenu(menu, inflater);
230
231        mChangeFoldersMenuItem = menu.findItem(R.id.change_folders);
232    }
233
234    @Override
235    public void onPrepareOptionsMenu(Menu menu) {
236        super.onPrepareOptionsMenu(menu);
237        boolean showMarkImportant = !mConversation.isImportant();
238        Utils.setMenuItemVisibility(
239                menu,
240                R.id.mark_important,
241                showMarkImportant
242                        && mAccount
243                                .supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
244        Utils.setMenuItemVisibility(
245                menu,
246                R.id.mark_not_important,
247                !showMarkImportant
248                        && mAccount
249                                .supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
250        // TODO(mindyp) show/ hide spam and mute based on conversation
251        // properties to be added.
252        Utils.setMenuItemVisibility(menu, R.id.y_button,
253                mAccount.supportsCapability(AccountCapabilities.ARCHIVE) && mFolder != null
254                        && mFolder.supportsCapability(FolderCapabilities.ARCHIVE));
255        Utils.setMenuItemVisibility(menu, R.id.report_spam,
256                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
257                        && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM)
258                        && !mConversation.spam);
259        Utils.setMenuItemVisibility(
260                menu,
261                R.id.mute,
262                mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null
263                        && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)
264                        && !mConversation.muted);
265    }
266    /**
267     * Handles a request to show a new conversation list, either from a search query or for viewing
268     * a folder. This will initiate a data load, and hence must be called on the UI thread.
269     */
270    private void showConversation() {
271        getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, this);
272    }
273
274    @Override
275    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
276        return new MessageLoader(mContext, mConversation.messageListUri);
277    }
278
279    @Override
280    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
281        MessageCursor messageCursor = (MessageCursor) data;
282
283        // TODO: handle Gmail loading states (like LOADING and ERROR)
284        if (messageCursor.getCount() == 0) {
285            if (mCursor != null) {
286                // TODO: need to exit this view- conversation may have been deleted, or for
287                // whatever reason is now invalid
288            } else {
289                // ignore zero-sized cursors during initial load
290            }
291            return;
292        }
293
294        renderConversation(messageCursor);
295    }
296
297    @Override
298    public void onLoaderReset(Loader<Cursor> loader) {
299        mCursor = null;
300        // TODO: null out all Message.mMessageCursor references
301    }
302
303    private void renderConversation(MessageCursor messageCursor) {
304        mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html",
305                "utf-8", null);
306        mCursor = messageCursor;
307    }
308
309    private void updateConversation(MessageCursor messageCursor) {
310        // TODO: handle server-side conversation updates
311        // for simple things like header data changes, just re-render the affected headers
312        // if a new message is present, save off the pending cursor and show a notification to
313        // re-render
314
315        mCursor = messageCursor;
316    }
317
318    /**
319     * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
320     * conversation header), and return an HTML document with spacer divs inserted for all overlays.
321     *
322     */
323    private String renderMessageBodies(MessageCursor messageCursor) {
324        int pos = -1;
325
326        boolean allowNetworkImages = false;
327
328        // Walk through the cursor and build up an overlay adapter as you go.
329        // Each overlay has an entry in the adapter for easy scroll handling in the container.
330        // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
331        // When adding adapter items, also add their heights to help the container later determine
332        // overlay dimensions.
333
334        mAdapter.clear();
335
336        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
337        // a pixel is an mdpi pixel, unless you set device-dpi.
338
339        // add a single conversation header item
340        final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
341        final int convHeaderDp = measureOverlayHeight(convHeaderPos);
342
343        mTemplates.startConversation(convHeaderDp);
344
345        while (messageCursor.moveToPosition(++pos)) {
346            final Message msg = messageCursor.getMessage();
347            // TODO: save/restore 'show pics' state
348            final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
349            allowNetworkImages |= safeForImages;
350
351            final int headerPos = mAdapter.addMessageHeader(msg, true /* expanded */);
352            final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
353
354            final int footerPos = mAdapter.addMessageFooter(headerItem);
355
356            // Measure item header and footer heights to allocate spacers in HTML
357            // But since the views themselves don't exist yet, render each item temporarily into
358            // a host view for measurement.
359            final int headerDp = measureOverlayHeight(headerPos);
360            final int footerDp = measureOverlayHeight(footerPos);
361
362            mTemplates.appendMessageHtml(msg, true /* expanded */, safeForImages, 1.0f, headerDp,
363                    footerDp);
364        }
365
366        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
367
368        return mTemplates.endConversation(mBaseUri, 320);
369    }
370
371    /**
372     * Measure the height of an adapter view by rendering the data in the adapter into a temporary
373     * host view, and asking the adapter item to immediately measure itself. This method will reuse
374     * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
375     * earlier.
376     * <p>
377     * After measuring the height, this method also saves the height in the {@link ConversationItem}
378     * for later use in overlay positioning.
379     *
380     * @param position index into the adapter
381     * @return height in dp of the rendered view
382     */
383    private int measureOverlayHeight(int position) {
384        final ConversationItem convItem = mAdapter.getItem(position);
385        final int type = convItem.getType();
386
387        final View convertView = mConversationContainer.getScrapView(type);
388        final View hostView = mAdapter.getView(position, convertView, mConversationContainer);
389        if (convertView == null) {
390            mConversationContainer.addScrapView(type, hostView);
391        }
392
393        final int heightPx = convItem.measureHeight(hostView, mConversationContainer);
394        convItem.setHeight(heightPx);
395
396        return (int) (heightPx / mDensity);
397    }
398
399    public void onTouchEvent(MotionEvent event) {
400        // TODO: (mindyp) when there is an undo bar, check for event !in undo bar
401        // if its not in undo bar, dismiss the undo bar.
402    }
403
404    // BEGIN conversation header callbacks
405    @Override
406    public void onFoldersClicked() {
407        if (mChangeFoldersMenuItem == null) {
408            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
409            return;
410        }
411        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
412    }
413
414    @Override
415    public void onConversationViewHeaderHeightChange(int newHeight) {
416        // TODO: propagate the new height to the header's HTML spacer. This can happen when labels
417        // are added/removed
418    }
419
420    @Override
421    public String getSubjectRemainder(String subject) {
422        // TODO: hook this up to action bar
423        return subject;
424    }
425    // END conversation header callbacks
426
427    // START message header callbacks
428    @Override
429    public void setMessageSpacerHeight(Message msg, int height) {
430        // TODO: update message HTML spacer height
431        // TODO: expand this concept to handle bottom-aligned attachments
432    }
433
434    @Override
435    public void setMessageExpanded(Message msg, boolean expanded, int spacerHeight) {
436        // TODO: show/hide the HTML message body and update the spacer height
437    }
438
439    @Override
440    public void showExternalResources(Message msg) {
441        mWebView.getSettings().setBlockNetworkImage(false);
442        mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');");
443    }
444    // END message header callbacks
445
446    private static class MessageLoader extends CursorLoader {
447        private boolean mDeliveredFirstResults = false;
448
449        public MessageLoader(Context c, Uri uri) {
450            super(c, uri, UIProvider.MESSAGE_PROJECTION, null, null, null);
451        }
452
453        @Override
454        public Cursor loadInBackground() {
455            return new MessageCursor(super.loadInBackground());
456
457        }
458
459        @Override
460        public void deliverResult(Cursor result) {
461            // We want to deliver these results, and then we want to make sure that any subsequent
462            // queries do not hit the network
463            super.deliverResult(result);
464
465            if (!mDeliveredFirstResults) {
466                mDeliveredFirstResults = true;
467                Uri uri = getUri();
468
469                // Create a ListParams that tells the provider to not hit the network
470                final ListParams listParams =
471                        new ListParams(ListParams.NO_LIMIT, false /* useNetwork */);
472
473                // Build the new uri with this additional parameter
474                uri = uri.buildUpon().appendQueryParameter(
475                        UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build();
476                setUri(uri);
477            }
478        }
479    }
480
481    private static int[] parseInts(final String[] stringArray) {
482        final int len = stringArray.length;
483        final int[] ints = new int[len];
484        for (int i = 0; i < len; i++) {
485            ints[i] = Integer.parseInt(stringArray[i]);
486        }
487        return ints;
488    }
489
490    private class ConversationWebViewClient extends WebViewClient {
491
492        @Override
493        public void onPageFinished(WebView view, String url) {
494            super.onPageFinished(view, url);
495
496            // TODO: save off individual message unread state (here, or in onLoadFinished?) so
497            // 'mark unread' restores the original unread state for each individual message
498
499            // mark as read upon open
500            if (!mConversation.read) {
501                mConversation.markRead(mContext, true /* read */);
502                mConversation.read = true;
503            }
504        }
505
506        @Override
507        public boolean shouldOverrideUrlLoading(WebView view, String url) {
508            boolean result = false;
509            final Uri uri = Uri.parse(url);
510            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
511            intent.putExtra(Browser.EXTRA_APPLICATION_ID, getActivity().getPackageName());
512            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
513
514            // FIXME: give provider a chance to customize url intents?
515            // Utils.addGoogleUriAccountIntentExtras(mContext, uri, mAccount, intent);
516
517            try {
518                mActivity.getActivityContext().startActivity(intent);
519                result = true;
520            } catch (ActivityNotFoundException ex) {
521                // If no application can handle the URL, assume that the
522                // caller can handle it.
523            }
524
525            return result;
526        }
527
528    }
529
530    /**
531     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
532     * via reflection and not stripped.
533     *
534     */
535    private class MailJsBridge {
536
537        @SuppressWarnings("unused")
538        public void onWebContentGeometryChange(final String[] overlayBottomStrs) {
539            mHandler.post(new Runnable() {
540                @Override
541                public void run() {
542                    if (!mViewsCreated) {
543                        LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" +
544                                " are gone, %s", ConversationViewFragment.this);
545                        return;
546                    }
547
548                    mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs));
549                }
550            });
551        }
552
553    }
554
555}
556