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