ConversationViewFragment.java revision af5d4e0e2a5ce3300fffa0c484431d83adb89ee8
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.providers.Account;
57import com.android.mail.providers.Conversation;
58import com.android.mail.providers.ListParams;
59import com.android.mail.providers.Message;
60import com.android.mail.providers.UIProvider;
61import com.android.mail.utils.LogUtils;
62import com.android.mail.utils.Utils;
63
64import java.util.Map;
65
66/**
67 * The conversation view UI component.
68 */
69public final class ConversationViewFragment extends Fragment implements
70        LoaderManager.LoaderCallbacks<Cursor>,
71        ConversationViewHeader.ConversationViewHeaderCallbacks {
72
73    private static final String LOG_TAG = new LogUtils().getLogTag();
74
75    private static final int MESSAGE_LOADER_ID = 0;
76
77    private ControllableActivity mActivity;
78
79    private Context mContext;
80
81    private Conversation mConversation;
82
83    private ConversationViewHeader mConversationHeader;
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 MessageListAdapter mAdapter;
102
103    private boolean mViewsCreated;
104
105    private MenuItem mChangeFoldersMenuItem;
106
107    private float mDensity;
108
109    private static final String ARG_ACCOUNT = "account";
110    private static final String ARG_CONVERSATION = "conversation";
111
112    /**
113     * Constructor needs to be public to handle orientation changes and activity lifecycle events.
114     */
115    public ConversationViewFragment() {
116        super();
117    }
118
119    /**
120     * Creates a new instance of {@link ConversationViewFragment}, initialized
121     * to display conversation.
122     */
123    public static ConversationViewFragment newInstance(Account account,
124            Conversation conversation) {
125       ConversationViewFragment f = new ConversationViewFragment();
126       Bundle args = new Bundle();
127       args.putParcelable(ARG_ACCOUNT, account);
128       args.putParcelable(ARG_CONVERSATION, conversation);
129       f.setArguments(args);
130       return f;
131    }
132
133    @Override
134    public void onActivityCreated(Bundle savedInstanceState) {
135        super.onActivityCreated(savedInstanceState);
136        // Strictly speaking, we get back an android.app.Activity from getActivity. However, the
137        // only activity creating a ConversationListContext is a MailActivity which is of type
138        // ControllableActivity, so this cast should be safe. If this cast fails, some other
139        // activity is creating ConversationListFragments. This activity must be of type
140        // ControllableActivity.
141        final Activity activity = getActivity();
142        if (! (activity instanceof ControllableActivity)) {
143            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
144                    + "create it. Cannot proceed.");
145        }
146        mActivity = (ControllableActivity) activity;
147        mContext = mActivity.getApplicationContext();
148        if (mActivity.isFinishing()) {
149            // Activity is finishing, just bail.
150            return;
151        }
152        mActivity.attachConversationView(this);
153        mTemplates = new HtmlConversationTemplates(mContext);
154
155        mAdapter = new MessageListAdapter(mActivity.getActivityContext(),
156                null /* cursor */, mAccount, getLoaderManager());
157        mConversationContainer.setOverlayAdapter(mAdapter);
158
159        mDensity = getResources().getDisplayMetrics().density;
160
161        // Show conversation and start loading messages.
162        showConversation();
163    }
164
165    @Override
166    public void onCreate(Bundle savedState) {
167        LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this);
168        super.onCreate(savedState);
169
170        Bundle args = getArguments();
171        mAccount = args.getParcelable(ARG_ACCOUNT);
172        mConversation = args.getParcelable(ARG_CONVERSATION);
173        mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
174
175        // not really, we just want to get a crack to store a reference to the change_folders item
176        setHasOptionsMenu(true);
177    }
178
179    @Override
180    public View onCreateView(LayoutInflater inflater,
181            ViewGroup container, Bundle savedInstanceState) {
182        View rootView = inflater.inflate(R.layout.conversation_view, null);
183        mConversationContainer = (ConversationContainer) rootView
184                .findViewById(R.id.conversation_container);
185        mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
186        mConversationHeader = (ConversationViewHeader) mConversationContainer.findViewById(
187                R.id.conversation_header);
188        mConversationHeader.setCallbacks(this);
189
190        mWebView.addJavascriptInterface(mJsBridge, "mail");
191        mWebView.setWebViewClient(mWebViewClient);
192        mWebView.setWebChromeClient(new WebChromeClient() {
193            @Override
194            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
195                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
196                        consoleMessage.sourceId(), consoleMessage.lineNumber());
197                return true;
198            }
199        });
200
201        WebSettings settings = mWebView.getSettings();
202
203        settings.setBlockNetworkImage(true);
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    /**
234     * Handles a request to show a new conversation list, either from a search query or for viewing
235     * a folder. This will initiate a data load, and hence must be called on the UI thread.
236     */
237    private void showConversation() {
238        // initialize conversation header, measure its height manually, and inform template render
239        // TODO: inform template render of initial header height
240        mConversationHeader.setSubject(mConversation.subject, false /* notify */);
241        if (mAccount.supportsCapability(
242                UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)) {
243            mConversationHeader.setFolders(mConversation, false /* notify */);
244        }
245
246        getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, this);
247    }
248
249    @Override
250    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
251        return new MessageLoader(mContext, mConversation.messageListUri);
252    }
253
254    @Override
255    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
256        MessageCursor messageCursor = (MessageCursor) data;
257
258        if (mAdapter.getCursor() == null) {
259            renderConversation(messageCursor);
260        } else {
261            updateConversation(messageCursor);
262        }
263    }
264
265    @Override
266    public void onLoaderReset(Loader<Cursor> loader) {
267        mAdapter.swapCursor(null);
268    }
269
270    private void renderConversation(MessageCursor messageCursor) {
271        mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html",
272                "utf-8", null);
273        mAdapter.swapCursor(messageCursor);
274    }
275
276    private void updateConversation(MessageCursor messageCursor) {
277        // TODO: handle server-side conversation updates
278        // for simple things like header data changes, just re-render the affected headers
279        // if a new message is present, save off the pending cursor and show a notification to
280        // re-render
281
282        final MessageCursor oldCursor = (MessageCursor) mAdapter.getCursor();
283        mAdapter.swapCursor(messageCursor);
284    }
285
286    private String renderMessageBodies(MessageCursor messageCursor) {
287        int pos = -1;
288
289        // N.B. the units of height for spacers are actually dp and not px because WebView assumes
290        // a pixel is an mdpi pixel, unless you set device-dpi.
291
292        final int headerHeightPx = Utils.measureViewHeight(mConversationHeader,
293                mConversationContainer);
294        mTemplates.startConversation((int) (headerHeightPx / mDensity));
295
296        // FIXME: measure the header (and the attachments) and insert spacers of appropriate size
297        final int spacerH = (Utils.useTabletUI(mContext)) ? 112 : 96;
298        while (messageCursor.moveToPosition(++pos)) {
299            mTemplates.appendMessageHtml(messageCursor.get(), true, false, 1.0f, spacerH);
300        }
301        return mTemplates.endConversation(mBaseUri, 320);
302    }
303
304    public void onTouchEvent(MotionEvent event) {
305        // TODO: (mindyp) when there is an undo bar, check for event !in undo bar
306        // if its not in undo bar, dismiss the undo bar.
307    }
308
309    @Override
310    public void onFoldersClicked() {
311        if (mChangeFoldersMenuItem == null) {
312            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
313            return;
314        }
315        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
316    }
317
318    @Override
319    public void onConversationViewHeaderHeightChange(int newHeight) {
320        // TODO: propagate the new height to the header's HTML spacer. This can happen when labels
321        // are added/removed
322    }
323
324    @Override
325    public String getSubjectRemainder(String subject) {
326        // TODO: hook this up to action bar
327        return subject;
328    }
329
330    private static class MessageLoader extends CursorLoader {
331        private boolean mDeliveredFirstResults = false;
332
333        public MessageLoader(Context c, Uri uri) {
334            super(c, uri, UIProvider.MESSAGE_PROJECTION, null, null, null);
335        }
336
337        @Override
338        public Cursor loadInBackground() {
339            return new MessageCursor(super.loadInBackground());
340
341        }
342
343        @Override
344        public void deliverResult(Cursor result) {
345            // We want to deliver these results, and then we want to make sure that any subsequent
346            // queries do not hit the network
347            super.deliverResult(result);
348
349            if (!mDeliveredFirstResults) {
350                mDeliveredFirstResults = true;
351                Uri uri = getUri();
352
353                // Create a ListParams that tells the provider to not hit the network
354                final ListParams listParams =
355                        new ListParams(ListParams.NO_LIMIT, false /* useNetwork */);
356
357                // Build the new uri with this additional parameter
358                uri = uri.buildUpon().appendQueryParameter(
359                        UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build();
360                setUri(uri);
361            }
362        }
363    }
364
365    private static class MessageCursor extends CursorWrapper {
366
367        private Map<Long, Message> mCache = Maps.newHashMap();
368
369        public MessageCursor(Cursor inner) {
370            super(inner);
371        }
372
373        public Message get() {
374            final long id = getWrappedCursor().getLong(UIProvider.MESSAGE_ID_COLUMN);
375            Message m = mCache.get(id);
376            if (m == null) {
377                m = new Message(this);
378                mCache.put(id, m);
379            }
380            return m;
381        }
382    }
383
384    private static class MessageListAdapter extends ResourceCursorAdapter {
385
386        private final FormattedDateBuilder mDateBuilder;
387        private final Account mAccount;
388        private final LoaderManager mLoaderManager;
389
390        public MessageListAdapter(Context context, Cursor messageCursor, Account account,
391                LoaderManager loaderManager) {
392            super(context, R.layout.conversation_message_header, messageCursor, 0);
393            mDateBuilder = new FormattedDateBuilder(context);
394            mAccount = account;
395            mLoaderManager = loaderManager;
396        }
397
398        @Override
399        public void bindView(View view, Context context, Cursor cursor) {
400            Message m = ((MessageCursor) cursor).get();
401            MessageHeaderView header = (MessageHeaderView) view;
402            header.initialize(mDateBuilder, mAccount, mLoaderManager, true, false, false);
403            header.bind(m);
404        }
405    }
406
407    private static int[] parseInts(final String[] stringArray) {
408        final int len = stringArray.length;
409        final int[] ints = new int[len];
410        for (int i = 0; i < len; i++) {
411            ints[i] = Integer.parseInt(stringArray[i]);
412        }
413        return ints;
414    }
415
416    private class ConversationWebViewClient extends WebViewClient {
417
418        @Override
419        public void onPageFinished(WebView view, String url) {
420            super.onPageFinished(view, url);
421
422            // TODO: save off individual message unread state (here, or in onLoadFinished?) so
423            // 'mark unread' restores the original unread state for each individual message
424
425            // mark as read upon open
426            if (!mConversation.read) {
427                mConversation.markRead(mContext, true /* read */);
428                mConversation.read = true;
429            }
430        }
431
432        @Override
433        public boolean shouldOverrideUrlLoading(WebView view, String url) {
434            boolean result = false;
435            final Uri uri = Uri.parse(url);
436            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
437            intent.putExtra(Browser.EXTRA_APPLICATION_ID, getActivity().getPackageName());
438            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
439
440            // FIXME: give provider a chance to customize url intents?
441            // Utils.addGoogleUriAccountIntentExtras(mContext, uri, mAccount, intent);
442
443            try {
444                mActivity.getActivityContext().startActivity(intent);
445                result = true;
446            } catch (ActivityNotFoundException ex) {
447                // If no application can handle the URL, assume that the
448                // caller can handle it.
449            }
450
451            return result;
452        }
453
454    }
455
456    /**
457     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
458     * via reflection and not stripped.
459     *
460     */
461    private class MailJsBridge {
462
463        @SuppressWarnings("unused")
464        public void onWebContentGeometryChange(final String[] headerBottomStrs,
465                final String[] headerHeightStrs) {
466            mHandler.post(new Runnable() {
467                @Override
468                public void run() {
469                    if (!mViewsCreated) {
470                        LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" +
471                                " are gone, %s", ConversationViewFragment.this);
472                        return;
473                    }
474
475                    mConversationContainer.onGeometryChange(parseInts(headerBottomStrs),
476                            parseInts(headerHeightStrs));
477                }
478            });
479        }
480
481    }
482
483}
484