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