ConversationViewFragment.java revision c9d59184da271d5a6974edb709e3b39a5a970fa7
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 com.google.common.collect.Maps;
21
22import android.app.Activity;
23import android.app.Fragment;
24import android.app.LoaderManager;
25import android.content.ActivityNotFoundException;
26import android.content.Context;
27import android.content.CursorLoader;
28import android.content.Intent;
29import android.content.Loader;
30import android.database.Cursor;
31import android.database.CursorWrapper;
32import android.net.Uri;
33import android.os.Bundle;
34import android.os.Handler;
35import android.provider.Browser;
36import android.view.LayoutInflater;
37import android.view.Menu;
38import android.view.MenuInflater;
39import android.view.MenuItem;
40import android.view.MotionEvent;
41import android.view.View;
42import android.view.ViewGroup;
43import android.webkit.ConsoleMessage;
44import android.webkit.WebChromeClient;
45import android.webkit.WebSettings;
46import android.webkit.WebView;
47import android.webkit.WebViewClient;
48import android.widget.ResourceCursorAdapter;
49
50import com.android.mail.FormattedDateBuilder;
51import com.android.mail.R;
52import com.android.mail.browse.ConversationContainer;
53import com.android.mail.browse.ConversationViewHeader;
54import com.android.mail.browse.ConversationWebView;
55import com.android.mail.browse.MessageHeaderView;
56import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
57import com.android.mail.providers.Account;
58import com.android.mail.providers.Conversation;
59import com.android.mail.providers.ListParams;
60import com.android.mail.providers.Message;
61import com.android.mail.providers.UIProvider;
62import com.android.mail.utils.LogUtils;
63import com.android.mail.utils.Utils;
64
65import java.util.Map;
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 ConversationViewHeader mConversationHeader;
86
87    private ConversationContainer mConversationContainer;
88
89    private Account mAccount;
90
91    private ConversationWebView mWebView;
92
93    private HtmlConversationTemplates mTemplates;
94
95    private String mBaseUri;
96
97    private final Handler mHandler = new Handler();
98
99    private final MailJsBridge mJsBridge = new MailJsBridge();
100
101    private final WebViewClient mWebViewClient = new ConversationWebViewClient();
102
103    private MessageListAdapter mAdapter;
104
105    private boolean mViewsCreated;
106
107    private MenuItem mChangeFoldersMenuItem;
108
109    private float mDensity;
110
111    private static final String ARG_ACCOUNT = "account";
112    private static final String ARG_CONVERSATION = "conversation";
113
114    /**
115     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
116     */
117    public ConversationViewFragment() {
118        super();
119    }
120
121    /**
122     * Creates a new instance of {@link ConversationViewFragment}, initialized
123     * to display conversation.
124     */
125    public static ConversationViewFragment newInstance(Account account,
126            Conversation conversation) {
127       ConversationViewFragment f = new ConversationViewFragment();
128       Bundle args = new Bundle();
129       args.putParcelable(ARG_ACCOUNT, account);
130       args.putParcelable(ARG_CONVERSATION, conversation);
131       f.setArguments(args);
132       return f;
133    }
134
135    @Override
136    public void onActivityCreated(Bundle savedInstanceState) {
137        super.onActivityCreated(savedInstanceState);
138        // Strictly speaking, we get back an android.app.Activity from getActivity. However, the
139        // only activity creating a ConversationListContext is a MailActivity which is of type
140        // ControllableActivity, so this cast should be safe. If this cast fails, some other
141        // activity is creating ConversationListFragments. This activity must be of type
142        // ControllableActivity.
143        final Activity activity = getActivity();
144        if (! (activity instanceof ControllableActivity)) {
145            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
146                    + "create it. Cannot proceed.");
147        }
148        mActivity = (ControllableActivity) activity;
149        mContext = mActivity.getApplicationContext();
150        if (mActivity.isFinishing()) {
151            // Activity is finishing, just bail.
152            return;
153        }
154        mActivity.attachConversationView(this);
155        mTemplates = new HtmlConversationTemplates(mContext);
156
157        mAdapter = new MessageListAdapter(mActivity.getActivityContext(),
158                null /* cursor */, mAccount, getLoaderManager(), this);
159        mConversationContainer.setOverlayAdapter(mAdapter);
160
161        mDensity = getResources().getDisplayMetrics().density;
162
163        // Show conversation and start loading messages.
164        showConversation();
165    }
166
167    @Override
168    public void onCreate(Bundle savedState) {
169        LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this);
170        super.onCreate(savedState);
171
172        Bundle args = getArguments();
173        mAccount = args.getParcelable(ARG_ACCOUNT);
174        mConversation = args.getParcelable(ARG_CONVERSATION);
175        mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
176
177        // not really, we just want to get a crack to store a reference to the change_folders item
178        setHasOptionsMenu(true);
179    }
180
181    @Override
182    public View onCreateView(LayoutInflater inflater,
183            ViewGroup container, Bundle savedInstanceState) {
184        View rootView = inflater.inflate(R.layout.conversation_view, null);
185        mConversationContainer = (ConversationContainer) rootView
186                .findViewById(R.id.conversation_container);
187        mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
188        mConversationHeader = (ConversationViewHeader) mConversationContainer.findViewById(
189                R.id.conversation_header);
190        mConversationHeader.setCallbacks(this);
191
192        mWebView.addJavascriptInterface(mJsBridge, "mail");
193        mWebView.setWebViewClient(mWebViewClient);
194        mWebView.setWebChromeClient(new WebChromeClient() {
195            @Override
196            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
197                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
198                        consoleMessage.sourceId(), consoleMessage.lineNumber());
199                return true;
200            }
201        });
202
203        final WebSettings settings = mWebView.getSettings();
204
205        settings.setJavaScriptEnabled(true);
206        settings.setUseWideViewPort(true);
207
208        settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
209
210        settings.setSupportZoom(true);
211        settings.setBuiltInZoomControls(true);
212        settings.setDisplayZoomControls(false);
213
214        mViewsCreated = true;
215
216        return rootView;
217    }
218
219    @Override
220    public void onDestroyView() {
221        super.onDestroyView();
222        mViewsCreated = false;
223        mActivity.attachConversationView(null);
224    }
225
226    @Override
227    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
228        super.onCreateOptionsMenu(menu, inflater);
229
230        mChangeFoldersMenuItem = menu.findItem(R.id.change_folders);
231    }
232
233    public void onPrepareOptionsMenu(Menu menu) {
234        super.onPrepareOptionsMenu(menu);
235        boolean showMarkImportant = !mConversation.isImportant();
236
237        final MenuItem markImportant = menu.findItem(R.id.mark_important);
238        if (markImportant != null) {
239            markImportant.setVisible(showMarkImportant
240                    && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
241            final MenuItem markNotImportant = menu.findItem(R.id.mark_not_important);
242            markNotImportant.setVisible(!showMarkImportant
243                    && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
244        }
245        // TODO(mindyp) show/ hide spam and mute based on conversation properties to be added.
246    }
247    /**
248     * Handles a request to show a new conversation list, either from a search query or for viewing
249     * a folder. This will initiate a data load, and hence must be called on the UI thread.
250     */
251    private void showConversation() {
252        // initialize conversation header, measure its height manually, and inform template render
253        // TODO: inform template render of initial header height
254        mConversationHeader.setSubject(mConversation.subject, false /* notify */);
255        if (mAccount.supportsCapability(
256                UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)) {
257            mConversationHeader.setFolders(mConversation, false /* notify */);
258        }
259
260        getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, this);
261    }
262
263    @Override
264    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
265        return new MessageLoader(mContext, mConversation.messageListUri);
266    }
267
268    @Override
269    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
270        MessageCursor messageCursor = (MessageCursor) data;
271
272        if (mAdapter.getCursor() == null) {
273            renderConversation(messageCursor);
274        } else {
275            updateConversation(messageCursor);
276        }
277    }
278
279    @Override
280    public void onLoaderReset(Loader<Cursor> loader) {
281        mAdapter.swapCursor(null);
282    }
283
284    private void renderConversation(MessageCursor messageCursor) {
285        mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html",
286                "utf-8", null);
287        mAdapter.swapCursor(messageCursor);
288    }
289
290    private void updateConversation(MessageCursor messageCursor) {
291        // TODO: handle server-side conversation updates
292        // for simple things like header data changes, just re-render the affected headers
293        // if a new message is present, save off the pending cursor and show a notification to
294        // re-render
295
296        final MessageCursor oldCursor = (MessageCursor) mAdapter.getCursor();
297        mAdapter.swapCursor(messageCursor);
298    }
299
300    private String renderMessageBodies(MessageCursor messageCursor) {
301        int pos = -1;
302
303        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
304        // a pixel is an mdpi pixel, unless you set device-dpi.
305
306        final int headerHeightPx = Utils.measureViewHeight(mConversationHeader,
307                mConversationContainer);
308        mTemplates.startConversation((int) (headerHeightPx / mDensity));
309
310        // FIXME: measure the header (and the attachments) and insert spacers of appropriate size
311        final int spacerH = (Utils.useTabletUI(mContext)) ? 112 : 96;
312
313        boolean allowNetworkImages = false;
314
315        while (messageCursor.moveToPosition(++pos)) {
316            final Message msg = messageCursor.get();
317            // TODO: save/restore 'show pics' state
318            final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
319            allowNetworkImages |= safeForImages;
320            mTemplates.appendMessageHtml(msg, true /* expanded */, safeForImages, 1.0f, spacerH);
321        }
322
323        mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
324
325        return mTemplates.endConversation(mBaseUri, 320);
326    }
327
328    public void onTouchEvent(MotionEvent event) {
329        // TODO: (mindyp) when there is an undo bar, check for event !in undo bar
330        // if its not in undo bar, dismiss the undo bar.
331    }
332
333    // BEGIN conversation header callbacks
334    @Override
335    public void onFoldersClicked() {
336        if (mChangeFoldersMenuItem == null) {
337            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
338            return;
339        }
340        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
341    }
342
343    @Override
344    public void onConversationViewHeaderHeightChange(int newHeight) {
345        // TODO: propagate the new height to the header's HTML spacer. This can happen when labels
346        // are added/removed
347    }
348
349    @Override
350    public String getSubjectRemainder(String subject) {
351        // TODO: hook this up to action bar
352        return subject;
353    }
354    // END conversation header callbacks
355
356    // START message header callbacks
357    @Override
358    public void setMessageSpacerHeight(Message msg, int height) {
359        // TODO: update message HTML spacer height
360        // TODO: expand this concept to handle bottom-aligned attachments
361    }
362
363    @Override
364    public void setMessageExpanded(Message msg, boolean expanded, int spacerHeight) {
365        // TODO: show/hide the HTML message body and update the spacer height
366    }
367
368    @Override
369    public void showExternalResources(Message msg) {
370        mWebView.getSettings().setBlockNetworkImage(false);
371        mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');");
372    }
373    // END message header callbacks
374
375    private static class MessageLoader extends CursorLoader {
376        private boolean mDeliveredFirstResults = false;
377
378        public MessageLoader(Context c, Uri uri) {
379            super(c, uri, UIProvider.MESSAGE_PROJECTION, null, null, null);
380        }
381
382        @Override
383        public Cursor loadInBackground() {
384            return new MessageCursor(super.loadInBackground());
385
386        }
387
388        @Override
389        public void deliverResult(Cursor result) {
390            // We want to deliver these results, and then we want to make sure that any subsequent
391            // queries do not hit the network
392            super.deliverResult(result);
393
394            if (!mDeliveredFirstResults) {
395                mDeliveredFirstResults = true;
396                Uri uri = getUri();
397
398                // Create a ListParams that tells the provider to not hit the network
399                final ListParams listParams =
400                        new ListParams(ListParams.NO_LIMIT, false /* useNetwork */);
401
402                // Build the new uri with this additional parameter
403                uri = uri.buildUpon().appendQueryParameter(
404                        UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build();
405                setUri(uri);
406            }
407        }
408    }
409
410    private static class MessageCursor extends CursorWrapper {
411
412        private Map<Long, Message> mCache = Maps.newHashMap();
413
414        public MessageCursor(Cursor inner) {
415            super(inner);
416        }
417
418        public Message get() {
419            final long id = getWrappedCursor().getLong(UIProvider.MESSAGE_ID_COLUMN);
420            Message m = mCache.get(id);
421            if (m == null) {
422                m = new Message(this);
423                mCache.put(id, m);
424            }
425            return m;
426        }
427    }
428
429    private static class MessageListAdapter extends ResourceCursorAdapter {
430
431        private final FormattedDateBuilder mDateBuilder;
432        private final Account mAccount;
433        private final LoaderManager mLoaderManager;
434        private final MessageHeaderViewCallbacks mCallbacks;
435
436        public MessageListAdapter(Context context, Cursor messageCursor, Account account,
437                LoaderManager loaderManager, MessageHeaderViewCallbacks cb) {
438            super(context, R.layout.conversation_message_header, messageCursor, 0);
439            mDateBuilder = new FormattedDateBuilder(context);
440            mAccount = account;
441            mLoaderManager = loaderManager;
442            mCallbacks = cb;
443        }
444
445        @Override
446        public void bindView(View view, Context context, Cursor cursor) {
447            final Message msg = ((MessageCursor) cursor).get();
448            MessageHeaderView header = (MessageHeaderView) view;
449            header.setCallbacks(mCallbacks);
450            header.initialize(mDateBuilder, mAccount, mLoaderManager, true /* expanded */,
451                    msg.shouldShowImagePrompt(), false /* defaultReplyAll */);
452            header.bind(msg);
453        }
454    }
455
456    private static int[] parseInts(final String[] stringArray) {
457        final int len = stringArray.length;
458        final int[] ints = new int[len];
459        for (int i = 0; i < len; i++) {
460            ints[i] = Integer.parseInt(stringArray[i]);
461        }
462        return ints;
463    }
464
465    private class ConversationWebViewClient extends WebViewClient {
466
467        @Override
468        public void onPageFinished(WebView view, String url) {
469            super.onPageFinished(view, url);
470
471            // TODO: save off individual message unread state (here, or in onLoadFinished?) so
472            // 'mark unread' restores the original unread state for each individual message
473
474            // mark as read upon open
475            if (!mConversation.read) {
476                mConversation.markRead(mContext, true /* read */);
477                mConversation.read = true;
478            }
479        }
480
481        @Override
482        public boolean shouldOverrideUrlLoading(WebView view, String url) {
483            boolean result = false;
484            final Uri uri = Uri.parse(url);
485            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
486            intent.putExtra(Browser.EXTRA_APPLICATION_ID, getActivity().getPackageName());
487            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
488
489            // FIXME: give provider a chance to customize url intents?
490            // Utils.addGoogleUriAccountIntentExtras(mContext, uri, mAccount, intent);
491
492            try {
493                mActivity.getActivityContext().startActivity(intent);
494                result = true;
495            } catch (ActivityNotFoundException ex) {
496                // If no application can handle the URL, assume that the
497                // caller can handle it.
498            }
499
500            return result;
501        }
502
503    }
504
505    /**
506     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
507     * via reflection and not stripped.
508     *
509     */
510    private class MailJsBridge {
511
512        @SuppressWarnings("unused")
513        public void onWebContentGeometryChange(final String[] headerBottomStrs,
514                final String[] headerHeightStrs) {
515            mHandler.post(new Runnable() {
516                @Override
517                public void run() {
518                    if (!mViewsCreated) {
519                        LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" +
520                                " are gone, %s", ConversationViewFragment.this);
521                        return;
522                    }
523
524                    mConversationContainer.onGeometryChange(parseInts(headerBottomStrs),
525                            parseInts(headerHeightStrs));
526                }
527            });
528        }
529
530    }
531
532}
533