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