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