ConversationViewFragment.java revision 0972e0793cc321670391d063348aecb5031b2677
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.database.DataSetObservable; 30import android.database.DataSetObserver; 31import android.net.Uri; 32import android.os.Bundle; 33import android.os.Handler; 34import android.provider.Browser; 35import android.view.LayoutInflater; 36import android.view.Menu; 37import android.view.MenuInflater; 38import android.view.MenuItem; 39import android.view.View; 40import android.view.ViewGroup; 41import android.webkit.ConsoleMessage; 42import android.webkit.WebChromeClient; 43import android.webkit.WebSettings; 44import android.webkit.WebView; 45import android.webkit.WebViewClient; 46 47import com.android.mail.ContactInfo; 48import com.android.mail.ContactInfoSource; 49import com.android.mail.R; 50import com.android.mail.SenderInfoLoader; 51import com.android.mail.browse.ConversationContainer; 52import com.android.mail.browse.ConversationOverlayItem; 53import com.android.mail.browse.ConversationViewAdapter; 54import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem; 55import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem; 56import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem; 57import com.android.mail.browse.ConversationViewHeader; 58import com.android.mail.browse.ConversationWebView; 59import com.android.mail.browse.MessageCursor; 60import com.android.mail.browse.MessageCursor.ConversationMessage; 61import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; 62import com.android.mail.browse.SuperCollapsedBlock; 63import com.android.mail.browse.WebViewContextMenu; 64import com.android.mail.providers.Account; 65import com.android.mail.providers.Address; 66import com.android.mail.providers.Conversation; 67import com.android.mail.providers.Folder; 68import com.android.mail.providers.ListParams; 69import com.android.mail.providers.Message; 70import com.android.mail.providers.Settings; 71import com.android.mail.providers.UIProvider; 72import com.android.mail.providers.UIProvider.AccountCapabilities; 73import com.android.mail.providers.UIProvider.FolderCapabilities; 74import com.android.mail.utils.LogTag; 75import com.android.mail.utils.LogUtils; 76import com.android.mail.utils.Utils; 77import com.google.common.collect.ImmutableMap; 78import com.google.common.collect.Lists; 79import com.google.common.collect.Maps; 80import com.google.common.collect.Sets; 81 82import org.json.JSONException; 83 84import java.util.Arrays; 85import java.util.List; 86import java.util.Map; 87import java.util.Set; 88 89 90/** 91 * The conversation view UI component. 92 */ 93public final class ConversationViewFragment extends Fragment implements 94 ConversationViewHeader.ConversationViewHeaderCallbacks, 95 MessageHeaderViewCallbacks, 96 SuperCollapsedBlock.OnClickListener { 97 98 private static final String LOG_TAG = LogTag.getLogTag(); 99 public static final String LAYOUT_TAG = "ConvLayout"; 100 101 private static final int MESSAGE_LOADER_ID = 0; 102 private static final int CONTACT_LOADER_ID = 1; 103 104 private ControllableActivity mActivity; 105 106 private Context mContext; 107 108 private Conversation mConversation; 109 110 private ConversationContainer mConversationContainer; 111 112 private Account mAccount; 113 114 private ConversationWebView mWebView; 115 116 private HtmlConversationTemplates mTemplates; 117 118 private String mBaseUri; 119 120 private final Handler mHandler = new Handler(); 121 122 private final MailJsBridge mJsBridge = new MailJsBridge(); 123 124 private final WebViewClient mWebViewClient = new ConversationWebViewClient(); 125 126 private ConversationViewAdapter mAdapter; 127 private MessageCursor mCursor; 128 129 private boolean mViewsCreated; 130 131 private MenuItem mChangeFoldersMenuItem; 132 133 /** 134 * Folder is used to help determine valid menu actions for this conversation. 135 */ 136 private Folder mFolder; 137 138 private final Map<String, Address> mAddressCache = Maps.newHashMap(); 139 140 /** 141 * Temporary string containing the message bodies of the messages within a super-collapsed 142 * block, for one-time use during block expansion. We cannot easily pass the body HTML 143 * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it 144 * using {@link MailJsBridge}. 145 */ 146 private String mTempBodiesHtml; 147 148 private boolean mUserVisible; 149 150 private int mMaxAutoLoadMessages; 151 152 private boolean mDeferredConversationLoad; 153 154 /** 155 * Handles a deferred 'mark read' operation, necessary when the conversation view has finished 156 * loading before the conversation cursor. Normally null unless this situation occurs. 157 * When finally able to 'mark read', this observer will also be unregistered and cleaned up. 158 */ 159 private MarkReadObserver mMarkReadObserver; 160 161 /** 162 * Parcelable state of the conversation view. Can safely be used without null checking any time 163 * after {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)}. 164 */ 165 private ConversationViewState mViewState; 166 167 private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks(); 168 private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks(); 169 170 private static final String ARG_ACCOUNT = "account"; 171 public static final String ARG_CONVERSATION = "conversation"; 172 private static final String ARG_FOLDER = "folder"; 173 private static final String BUNDLE_VIEW_STATE = "viewstate"; 174 175 private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false; 176 177 /** 178 * Constructor needs to be public to handle orientation changes and activity lifecycle events. 179 */ 180 public ConversationViewFragment() { 181 super(); 182 } 183 184 /** 185 * Creates a new instance of {@link ConversationViewFragment}, initialized 186 * to display a conversation with other parameters inherited/copied from an existing bundle, 187 * typically one created using {@link #makeBasicArgs}. 188 */ 189 public static ConversationViewFragment newInstance(Bundle existingArgs, 190 Conversation conversation) { 191 ConversationViewFragment f = new ConversationViewFragment(); 192 Bundle args = new Bundle(existingArgs); 193 args.putParcelable(ARG_CONVERSATION, conversation); 194 f.setArguments(args); 195 return f; 196 } 197 198 public static Bundle makeBasicArgs(Account account, Folder folder) { 199 Bundle args = new Bundle(); 200 args.putParcelable(ARG_ACCOUNT, account); 201 args.putParcelable(ARG_FOLDER, folder); 202 return args; 203 } 204 205 @Override 206 public void onActivityCreated(Bundle savedInstanceState) { 207 LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s subj=%s", this, 208 mConversation.subject); 209 super.onActivityCreated(savedInstanceState); 210 // Strictly speaking, we get back an android.app.Activity from getActivity. However, the 211 // only activity creating a ConversationListContext is a MailActivity which is of type 212 // ControllableActivity, so this cast should be safe. If this cast fails, some other 213 // activity is creating ConversationListFragments. This activity must be of type 214 // ControllableActivity. 215 final Activity activity = getActivity(); 216 if (!(activity instanceof ControllableActivity)) { 217 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 218 + "create it. Cannot proceed."); 219 } 220 mActivity = (ControllableActivity) activity; 221 mContext = mActivity.getApplicationContext(); 222 if (mActivity.isFinishing()) { 223 // Activity is finishing, just bail. 224 return; 225 } 226 mTemplates = new HtmlConversationTemplates(mContext); 227 228 mAdapter = new ConversationViewAdapter(mActivity.getActivityContext(), mAccount, 229 getLoaderManager(), this, mContactLoaderCallbacks, this, this, mAddressCache); 230 mConversationContainer.setOverlayAdapter(mAdapter); 231 232 mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages); 233 234 mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(activity)); 235 236 showConversation(); 237 } 238 239 @Override 240 public void onCreate(Bundle savedState) { 241 LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this); 242 super.onCreate(savedState); 243 244 final Bundle args = getArguments(); 245 mAccount = args.getParcelable(ARG_ACCOUNT); 246 mConversation = args.getParcelable(ARG_CONVERSATION); 247 mFolder = args.getParcelable(ARG_FOLDER); 248 mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id; 249 250 // Not really, we just want to get a crack to store a reference to the change_folder item 251 setHasOptionsMenu(true); 252 } 253 254 @Override 255 public View onCreateView(LayoutInflater inflater, 256 ViewGroup container, Bundle savedInstanceState) { 257 258 if (savedInstanceState != null) { 259 mViewState = savedInstanceState.getParcelable(BUNDLE_VIEW_STATE); 260 } else { 261 mViewState = new ConversationViewState(); 262 } 263 264 View rootView = inflater.inflate(R.layout.conversation_view, container, false); 265 mConversationContainer = (ConversationContainer) rootView 266 .findViewById(R.id.conversation_container); 267 mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview); 268 269 mWebView.addJavascriptInterface(mJsBridge, "mail"); 270 mWebView.setWebViewClient(mWebViewClient); 271 mWebView.setWebChromeClient(new WebChromeClient() { 272 @Override 273 public boolean onConsoleMessage(ConsoleMessage consoleMessage) { 274 LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(), 275 consoleMessage.sourceId(), consoleMessage.lineNumber()); 276 return true; 277 } 278 }); 279 mWebView.setContentSizeChangeListener(new ConversationWebView.ContentSizeChangeListener() { 280 @Override 281 public void onHeightChange(int h) { 282 // When WebKit says the DOM height has changed, re-measure bodies and re-position 283 // their headers. 284 // This is separate from the typical JavaScript DOM change listeners because 285 // cases like NARROW_COLUMNS text reflow do not trigger DOM events. 286 mWebView.loadUrl("javascript:measurePositions();"); 287 } 288 }); 289 290 final WebSettings settings = mWebView.getSettings(); 291 292 settings.setJavaScriptEnabled(true); 293 settings.setUseWideViewPort(true); 294 settings.setLoadWithOverviewMode(true); 295 296 settings.setSupportZoom(true); 297 settings.setBuiltInZoomControls(true); 298 settings.setDisplayZoomControls(false); 299 300 final float fontScale = getResources().getConfiguration().fontScale; 301 final int desiredFontSizePx = getResources() 302 .getInteger(R.integer.conversation_desired_font_size_px); 303 final int unstyledFontSizePx = getResources() 304 .getInteger(R.integer.conversation_unstyled_font_size_px); 305 306 int textZoom = settings.getTextZoom(); 307 // apply a correction to the default body text style to get regular text to the size we want 308 textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx; 309 // then apply any system font scaling 310 textZoom = (int) (textZoom * fontScale); 311 settings.setTextZoom(textZoom); 312 313 mViewsCreated = true; 314 315 return rootView; 316 } 317 318 @Override 319 public void onSaveInstanceState(Bundle outState) { 320 if (mViewState != null) { 321 outState.putParcelable(BUNDLE_VIEW_STATE, mViewState); 322 } 323 } 324 325 @Override 326 public void onDestroyView() { 327 super.onDestroyView(); 328 mConversationContainer.setOverlayAdapter(null); 329 mAdapter = null; 330 if (mMarkReadObserver != null) { 331 mActivity.getConversationUpdater().unregisterConversationListObserver( 332 mMarkReadObserver); 333 mMarkReadObserver = null; 334 } 335 mViewsCreated = false; 336 } 337 338 @Override 339 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 340 super.onCreateOptionsMenu(menu, inflater); 341 342 mChangeFoldersMenuItem = menu.findItem(R.id.change_folder); 343 } 344 345 @Override 346 public void onPrepareOptionsMenu(Menu menu) { 347 super.onPrepareOptionsMenu(menu); 348 final boolean showMarkImportant = !mConversation.isImportant(); 349 Utils.setMenuItemVisibility( 350 menu, 351 R.id.mark_important, 352 showMarkImportant 353 && mAccount 354 .supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 355 Utils.setMenuItemVisibility( 356 menu, 357 R.id.mark_not_important, 358 !showMarkImportant 359 && mAccount 360 .supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 361 // TODO(mindyp) show/ hide spam and mute based on conversation 362 // properties to be added. 363 Utils.setMenuItemVisibility(menu, R.id.archive, 364 mAccount.supportsCapability(AccountCapabilities.ARCHIVE) && mFolder != null 365 && mFolder.supportsCapability(FolderCapabilities.ARCHIVE)); 366 Utils.setMenuItemVisibility(menu, R.id.report_spam, 367 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 368 && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM) 369 && !mConversation.spam); 370 Utils.setMenuItemVisibility(menu, R.id.mark_not_spam, 371 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 372 && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM) 373 && mConversation.spam); 374 Utils.setMenuItemVisibility(menu, R.id.report_phishing, 375 mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null 376 && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING) 377 && !mConversation.phishing); 378 Utils.setMenuItemVisibility( 379 menu, 380 R.id.mute, 381 mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null 382 && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE) 383 && !mConversation.muted); 384 } 385 386 @Override 387 public boolean onOptionsItemSelected(MenuItem item) { 388 boolean handled = false; 389 390 switch (item.getItemId()) { 391 case R.id.inside_conversation_unread: 392 markUnread(); 393 handled = true; 394 break; 395 } 396 397 return handled; 398 } 399 400 private void markUnread() { 401 // Ignore unsafe calls made after a fragment is detached from an activity 402 final ControllableActivity activity = (ControllableActivity) getActivity(); 403 if (activity == null) { 404 LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id); 405 return; 406 } 407 408 if (mViewState == null) { 409 LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)", 410 mConversation.id); 411 return; 412 } 413 activity.getConversationUpdater().markConversationMessagesUnread(mConversation, 414 mViewState.getUnreadMessageUris(), mViewState.getConversationInfo()); 415 } 416 417 /** 418 * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for 419 * reliability on older platforms. 420 */ 421 public void setExtraUserVisibleHint(boolean isVisibleToUser) { 422 LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this); 423 424 if (mUserVisible != isVisibleToUser) { 425 mUserVisible = isVisibleToUser; 426 427 if (isVisibleToUser && mViewsCreated) { 428 429 if (mCursor == null && mDeferredConversationLoad) { 430 // load 431 LogUtils.v(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", 432 mConversation.uri); 433 showConversation(); 434 mDeferredConversationLoad = false; 435 } else { 436 onConversationSeen(); 437 } 438 439 } 440 } 441 } 442 443 /** 444 * Handles a request to show a new conversation list, either from a search query or for viewing 445 * a folder. This will initiate a data load, and hence must be called on the UI thread. 446 */ 447 private void showConversation() { 448 if (!mUserVisible && mConversation.getNumMessages() > mMaxAutoLoadMessages) { 449 LogUtils.v(LOG_TAG, "Fragment not user-visible, not showing conversation: %s", 450 mConversation.uri); 451 mDeferredConversationLoad = true; 452 return; 453 } 454 LogUtils.v(LOG_TAG, 455 "Fragment is short or user-visible, immediately rendering conversation: %s", 456 mConversation.uri); 457 getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, mMessageLoaderCallbacks); 458 } 459 460 public Conversation getConversation() { 461 return mConversation; 462 } 463 464 private void renderConversation(MessageCursor messageCursor) { 465 final String convHtml = renderMessageBodies(messageCursor); 466 467 if (DEBUG_DUMP_CONVERSATION_HTML) { 468 java.io.FileWriter fw = null; 469 try { 470 fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id 471 + ".html"); 472 fw.write(convHtml); 473 } catch (java.io.IOException e) { 474 e.printStackTrace(); 475 } finally { 476 if (fw != null) { 477 try { 478 fw.close(); 479 } catch (java.io.IOException e) { 480 e.printStackTrace(); 481 } 482 } 483 } 484 } 485 486 mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null); 487 mCursor = messageCursor; 488 } 489 490 /** 491 * Populate the adapter with overlay views (message headers, super-collapsed blocks, a 492 * conversation header), and return an HTML document with spacer divs inserted for all overlays. 493 * 494 */ 495 private String renderMessageBodies(MessageCursor messageCursor) { 496 int pos = -1; 497 498 LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s subj=%s", this, 499 mConversation.subject); 500 boolean allowNetworkImages = false; 501 502 // TODO: re-use any existing adapter item state (expanded, details expanded, show pics) 503 final Settings settings = mActivity.getSettings(); 504 if (settings != null) { 505 mAdapter.setDefaultReplyAll(settings.replyBehavior == 506 UIProvider.DefaultReplyBehavior.REPLY_ALL); 507 } 508 // Walk through the cursor and build up an overlay adapter as you go. 509 // Each overlay has an entry in the adapter for easy scroll handling in the container. 510 // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks. 511 // When adding adapter items, also add their heights to help the container later determine 512 // overlay dimensions. 513 514 mAdapter.clear(); 515 516 // N.B. the units of height for spacers are actually dp and not px because WebView assumes 517 // a pixel is an mdpi pixel, unless you set device-dpi. 518 519 // add a single conversation header item 520 final int convHeaderPos = mAdapter.addConversationHeader(mConversation); 521 final int convHeaderPx = measureOverlayHeight(convHeaderPos); 522 523 mTemplates.startConversation(mWebView.screenPxToWebPx(convHeaderPx)); 524 525 int collapsedStart = -1; 526 ConversationMessage prevCollapsedMsg = null; 527 boolean prevSafeForImages = false; 528 529 while (messageCursor.moveToPosition(++pos)) { 530 final ConversationMessage msg = messageCursor.getMessage(); 531 532 // TODO: save/restore 'show pics' state 533 final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */; 534 allowNetworkImages |= safeForImages; 535 536 final Boolean savedExpanded = mViewState.getExpandedState(msg); 537 final boolean expanded; 538 if (savedExpanded != null) { 539 expanded = savedExpanded; 540 } else { 541 expanded = !msg.read || msg.starred || messageCursor.isLast(); 542 } 543 544 // save off "read" state from the cursor 545 // later, the view may not match the cursor (e.g. conversation marked read on open) 546 mViewState.setReadState(msg, msg.read); 547 548 if (savedExpanded == null && !expanded) { 549 // contribute to a super-collapsed block that will be emitted just before the next 550 // expanded header 551 if (collapsedStart < 0) { 552 collapsedStart = pos; 553 } 554 prevCollapsedMsg = msg; 555 prevSafeForImages = safeForImages; 556 continue; 557 } 558 559 // resolve any deferred decisions on previous collapsed items 560 if (collapsedStart >= 0) { 561 if (pos - collapsedStart == 1) { 562 // special-case for a single collapsed message: no need to super-collapse it 563 renderMessage(prevCollapsedMsg, false /* expanded */, 564 prevSafeForImages); 565 } else { 566 renderSuperCollapsedBlock(collapsedStart, pos - 1); 567 } 568 prevCollapsedMsg = null; 569 collapsedStart = -1; 570 } 571 572 renderMessage(msg, expanded, safeForImages); 573 } 574 575 mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages); 576 577 return mTemplates.endConversation(mBaseUri, 320, mWebView.getViewportWidth()); 578 } 579 580 private void renderSuperCollapsedBlock(int start, int end) { 581 final int blockPos = mAdapter.addSuperCollapsedBlock(start, end); 582 final int blockPx = measureOverlayHeight(blockPos); 583 mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx)); 584 } 585 586 private void renderMessage(ConversationMessage msg, boolean expanded, 587 boolean safeForImages) { 588 final int headerPos = mAdapter.addMessageHeader(msg, expanded); 589 final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos); 590 591 final int footerPos = mAdapter.addMessageFooter(headerItem); 592 593 // Measure item header and footer heights to allocate spacers in HTML 594 // But since the views themselves don't exist yet, render each item temporarily into 595 // a host view for measurement. 596 final int headerPx = measureOverlayHeight(headerPos); 597 final int footerPx = measureOverlayHeight(footerPos); 598 599 mTemplates.appendMessageHtml(msg, expanded, safeForImages, 1.0f, 600 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx)); 601 } 602 603 private String renderCollapsedHeaders(MessageCursor cursor, 604 SuperCollapsedBlockItem blockToReplace) { 605 final List<ConversationOverlayItem> replacements = Lists.newArrayList(); 606 607 mTemplates.reset(); 608 609 for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) { 610 cursor.moveToPosition(i); 611 final ConversationMessage msg = cursor.getMessage(); 612 final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg, 613 false /* expanded */); 614 final MessageFooterItem footer = mAdapter.newMessageFooterItem(header); 615 616 final int headerPx = measureOverlayHeight(header); 617 final int footerPx = measureOverlayHeight(footer); 618 619 mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages, 1.0f, 620 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx)); 621 replacements.add(header); 622 replacements.add(footer); 623 624 mViewState.setExpandedState(msg, false); 625 } 626 627 mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements); 628 629 return mTemplates.emit(); 630 } 631 632 private int measureOverlayHeight(int position) { 633 return measureOverlayHeight(mAdapter.getItem(position)); 634 } 635 636 /** 637 * Measure the height of an adapter view by rendering an adapter item into a temporary 638 * host view, and asking the view to immediately measure itself. This method will reuse 639 * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated 640 * earlier. 641 * <p> 642 * After measuring the height, this method also saves the height in the 643 * {@link ConversationOverlayItem} for later use in overlay positioning. 644 * 645 * @param convItem adapter item with data to render and measure 646 * @return height of the rendered view in screen px 647 */ 648 private int measureOverlayHeight(ConversationOverlayItem convItem) { 649 final int type = convItem.getType(); 650 651 final View convertView = mConversationContainer.getScrapView(type); 652 final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer, 653 true /* measureOnly */); 654 if (convertView == null) { 655 mConversationContainer.addScrapView(type, hostView); 656 } 657 658 final int heightPx = mConversationContainer.measureOverlay(hostView); 659 convItem.setHeight(heightPx); 660 convItem.markMeasurementValid(); 661 662 return heightPx; 663 } 664 665 private void onConversationSeen() { 666 // Ignore unsafe calls made after a fragment is detached from an activity 667 final ControllableActivity activity = (ControllableActivity) getActivity(); 668 if (activity == null) { 669 LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id); 670 return; 671 } 672 673 // mark as read upon open 674 if (!mConversation.read) { 675 try { 676 mViewState.setInfoForConversation(mConversation); 677 678 final ConversationUpdater listController = activity.getConversationUpdater(); 679 // The conversation cursor may not have finished loading by now (when launched via 680 // notification), so watch for when it finishes and mark it read then. 681 if (listController.getConversationListCursor() == null) { 682 LogUtils.i(LOG_TAG, "deferring conv mark read on open for id=%d", 683 mConversation.id); 684 mMarkReadObserver = new MarkReadObserver(listController); 685 listController.registerConversationListObserver(mMarkReadObserver); 686 } else { 687 listController.markConversationsRead(Arrays.asList(mConversation), 688 true /* read */); 689 } 690 691 } catch (JSONException e) { 692 LogUtils.w(LOG_TAG, e, "bad ConversationInfo, unable to mark conversation read"); 693 } 694 } 695 696 activity.onConversationSeen(mConversation); 697 698 final SubjectDisplayChanger sdc = activity.getSubjectDisplayChanger(); 699 if (sdc != null) { 700 sdc.setSubject(mConversation.subject); 701 } 702 } 703 704 // BEGIN conversation header callbacks 705 @Override 706 public void onFoldersClicked() { 707 if (mChangeFoldersMenuItem == null) { 708 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 709 return; 710 } 711 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 712 } 713 714 @Override 715 public void onConversationViewHeaderHeightChange(int newHeight) { 716 // TODO: propagate the new height to the header's HTML spacer. This can happen when labels 717 // are added/removed 718 } 719 720 @Override 721 public String getSubjectRemainder(String subject) { 722 final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger(); 723 if (sdc == null) { 724 return subject; 725 } 726 return sdc.getUnshownSubject(subject); 727 } 728 // END conversation header callbacks 729 730 // START message header callbacks 731 @Override 732 public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) { 733 mConversationContainer.invalidateSpacerGeometry(); 734 735 // update message HTML spacer height 736 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 737 LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h, 738 newSpacerHeightPx); 739 mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %d);", 740 mTemplates.getMessageDomId(item.message), h)); 741 } 742 743 @Override 744 public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) { 745 mConversationContainer.invalidateSpacerGeometry(); 746 747 // show/hide the HTML message body and update the spacer height 748 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 749 LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)", 750 item.isExpanded(), h, newSpacerHeightPx); 751 mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %d);", 752 mTemplates.getMessageDomId(item.message), item.isExpanded(), h)); 753 754 mViewState.setExpandedState(item.message, item.isExpanded()); 755 } 756 757 @Override 758 public void showExternalResources(Message msg) { 759 mWebView.getSettings().setBlockNetworkImage(false); 760 mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');"); 761 } 762 // END message header callbacks 763 764 @Override 765 public void onSuperCollapsedClick(SuperCollapsedBlockItem item) { 766 if (mCursor == null || !mViewsCreated) { 767 return; 768 } 769 770 mTempBodiesHtml = renderCollapsedHeaders(mCursor, item); 771 mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")"); 772 } 773 774 private static class MessageLoader extends CursorLoader { 775 private boolean mDeliveredFirstResults = false; 776 private final Conversation mConversation; 777 private final ConversationUpdater mListController; 778 779 public MessageLoader(Context c, Conversation conv, ConversationUpdater updater) { 780 super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null); 781 mConversation = conv; 782 mListController = updater; 783 } 784 785 @Override 786 public Cursor loadInBackground() { 787 return new MessageCursor(super.loadInBackground(), mConversation, mListController); 788 } 789 790 @Override 791 public void deliverResult(Cursor result) { 792 // We want to deliver these results, and then we want to make sure that any subsequent 793 // queries do not hit the network 794 super.deliverResult(result); 795 796 if (!mDeliveredFirstResults) { 797 mDeliveredFirstResults = true; 798 Uri uri = getUri(); 799 800 // Create a ListParams that tells the provider to not hit the network 801 final ListParams listParams = 802 new ListParams(ListParams.NO_LIMIT, false /* useNetwork */); 803 804 // Build the new uri with this additional parameter 805 uri = uri.buildUpon().appendQueryParameter( 806 UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build(); 807 setUri(uri); 808 } 809 } 810 } 811 812 private static int[] parseInts(final String[] stringArray) { 813 final int len = stringArray.length; 814 final int[] ints = new int[len]; 815 for (int i = 0; i < len; i++) { 816 ints[i] = Integer.parseInt(stringArray[i]); 817 } 818 return ints; 819 } 820 821 private class ConversationWebViewClient extends WebViewClient { 822 823 @Override 824 public void onPageFinished(WebView view, String url) { 825 // Ignore unsafe calls made after a fragment is detached from an activity 826 final ControllableActivity activity = (ControllableActivity) getActivity(); 827 if (activity == null || !mViewsCreated) { 828 LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url, 829 ConversationViewFragment.this); 830 return; 831 } 832 833 LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s", url, 834 ConversationViewFragment.this); 835 836 super.onPageFinished(view, url); 837 838 // TODO: save off individual message unread state (here, or in onLoadFinished?) so 839 // 'mark unread' restores the original unread state for each individual message 840 841 if (mUserVisible) { 842 onConversationSeen(); 843 } 844 845 final Set<String> emailAddresses = Sets.newHashSet(); 846 for (Address addr : mAddressCache.values()) { 847 emailAddresses.add(addr.getAddress()); 848 } 849 mContactLoaderCallbacks.setSenders(emailAddresses); 850 getLoaderManager().restartLoader(CONTACT_LOADER_ID, Bundle.EMPTY, 851 mContactLoaderCallbacks); 852 } 853 854 @Override 855 public boolean shouldOverrideUrlLoading(WebView view, String url) { 856 final Activity activity = getActivity(); 857 if (!mViewsCreated || activity == null) { 858 return false; 859 } 860 861 boolean result = false; 862 final Uri uri = Uri.parse(url); 863 Intent intent = new Intent(Intent.ACTION_VIEW, uri); 864 intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName()); 865 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 866 867 // FIXME: give provider a chance to customize url intents? 868 // Utils.addGoogleUriAccountIntentExtras(mContext, uri, mAccount, intent); 869 870 try { 871 activity.startActivity(intent); 872 result = true; 873 } catch (ActivityNotFoundException ex) { 874 // If no application can handle the URL, assume that the 875 // caller can handle it. 876 } 877 878 return result; 879 } 880 881 } 882 883 /** 884 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 885 * via reflection and not stripped. 886 * 887 */ 888 private class MailJsBridge { 889 890 @SuppressWarnings("unused") 891 public void onWebContentGeometryChange(final String[] overlayBottomStrs) { 892 try { 893 mHandler.post(new Runnable() { 894 @Override 895 public void run() { 896 if (!mViewsCreated) { 897 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" + 898 " are gone, %s", ConversationViewFragment.this); 899 return; 900 } 901 902 mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs)); 903 } 904 }); 905 } catch (Throwable t) { 906 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange"); 907 } 908 } 909 910 @SuppressWarnings("unused") 911 public String getTempMessageBodies() { 912 try { 913 if (!mViewsCreated) { 914 return ""; 915 } 916 917 final String s = mTempBodiesHtml; 918 mTempBodiesHtml = null; 919 return s; 920 } catch (Throwable t) { 921 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies"); 922 return ""; 923 } 924 } 925 926 } 927 928 private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { 929 930 @Override 931 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 932 return new MessageLoader(mContext, mConversation, mActivity.getConversationUpdater()); 933 } 934 935 @Override 936 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 937 MessageCursor messageCursor = (MessageCursor) data; 938 939 // ignore truly duplicate results 940 // this can happen when restoring after rotation 941 if (mCursor == messageCursor) { 942 return; 943 } 944 945 // TODO: handle Gmail loading states (like LOADING and ERROR) 946 if (messageCursor.getCount() == 0) { 947 if (mCursor != null) { 948 // TODO: need to exit this view- conversation may have been deleted, or for 949 // whatever reason is now invalid 950 } else { 951 // ignore zero-sized cursors during initial load 952 } 953 return; 954 } 955 956 // TODO: if this is not user-visible, delay render until user-visible fragment is done. 957 // This is needed in addition to the showConversation() delay to speed up rotation and 958 // restoration. 959 960 renderConversation(messageCursor); 961 } 962 963 @Override 964 public void onLoaderReset(Loader<Cursor> loader) { 965 mCursor = null; 966 // TODO: null out all Message.mMessageCursor references 967 } 968 969 } 970 971 /** 972 * Inner class to to asynchronously load contact data for all senders in the conversation, 973 * and notify observers when the data is ready. 974 * 975 */ 976 private class ContactLoaderCallbacks implements ContactInfoSource, 977 LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> { 978 979 private Set<String> mSenders; 980 private ImmutableMap<String, ContactInfo> mContactInfoMap; 981 private DataSetObservable mObservable = new DataSetObservable(); 982 983 public void setSenders(Set<String> emailAddresses) { 984 mSenders = emailAddresses; 985 } 986 987 @Override 988 public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) { 989 return new SenderInfoLoader(mContext, mSenders); 990 } 991 992 @Override 993 public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader, 994 ImmutableMap<String, ContactInfo> data) { 995 mContactInfoMap = data; 996 mObservable.notifyChanged(); 997 } 998 999 @Override 1000 public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) { 1001 } 1002 1003 @Override 1004 public ContactInfo getContactInfo(String email) { 1005 if (mContactInfoMap == null) { 1006 return null; 1007 } 1008 return mContactInfoMap.get(email); 1009 } 1010 1011 @Override 1012 public void registerObserver(DataSetObserver observer) { 1013 mObservable.registerObserver(observer); 1014 } 1015 1016 @Override 1017 public void unregisterObserver(DataSetObserver observer) { 1018 mObservable.unregisterObserver(observer); 1019 } 1020 1021 } 1022 1023 private class MarkReadObserver extends DataSetObserver { 1024 1025 private final ConversationUpdater mListController; 1026 1027 private MarkReadObserver(ConversationUpdater listController) { 1028 mListController = listController; 1029 } 1030 1031 @Override 1032 public void onChanged() { 1033 if (mListController.getConversationListCursor() == null) { 1034 // nothing yet, keep watching 1035 return; 1036 } 1037 // done loading, safe to mark read now 1038 mListController.unregisterConversationListObserver(this); 1039 mMarkReadObserver = null; 1040 LogUtils.i(LOG_TAG, "running deferred conv mark read on open, id=%d", mConversation.id); 1041 mListController.markConversationsRead(Arrays.asList(mConversation), 1042 true /* read */); 1043 } 1044 } 1045 1046 @Override 1047 public Settings getSettings() { 1048 return mAccount.settings; 1049 } 1050 1051} 1052