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