ConversationViewFragment.java revision 91fa034d624d8690905f30a13ae3ffb9601cf948
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.content.ContentResolver; 21import android.content.Context; 22import android.content.Loader; 23import android.content.res.Resources; 24import android.database.Cursor; 25import android.database.DataSetObserver; 26import android.graphics.Rect; 27import android.net.Uri; 28import android.os.AsyncTask; 29import android.os.Bundle; 30import android.os.SystemClock; 31import android.support.v4.text.BidiFormatter; 32import android.support.v4.util.ArrayMap; 33import android.text.TextUtils; 34import android.view.KeyEvent; 35import android.view.LayoutInflater; 36import android.view.View; 37import android.view.View.OnLayoutChangeListener; 38import android.view.ViewGroup; 39import android.webkit.ConsoleMessage; 40import android.webkit.CookieManager; 41import android.webkit.CookieSyncManager; 42import android.webkit.JavascriptInterface; 43import android.webkit.WebChromeClient; 44import android.webkit.WebSettings; 45import android.webkit.WebView; 46import android.widget.Button; 47 48import com.android.emailcommon.mail.Address; 49import com.android.mail.FormattedDateBuilder; 50import com.android.mail.R; 51import com.android.mail.analytics.Analytics; 52import com.android.mail.analytics.AnalyticsTimer; 53import com.android.mail.browse.ConversationContainer; 54import com.android.mail.browse.ConversationContainer.OverlayPosition; 55import com.android.mail.browse.ConversationFooterView.ConversationFooterCallbacks; 56import com.android.mail.browse.ConversationMessage; 57import com.android.mail.browse.ConversationOverlayItem; 58import com.android.mail.browse.ConversationViewAdapter; 59import com.android.mail.browse.ConversationViewAdapter.ConversationFooterItem; 60import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem; 61import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem; 62import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem; 63import com.android.mail.browse.ConversationViewHeader; 64import com.android.mail.browse.ConversationWebView; 65import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreator; 66import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreatorHolder; 67import com.android.mail.browse.MailWebView.ContentSizeChangeListener; 68import com.android.mail.browse.MessageCursor; 69import com.android.mail.browse.MessageFooterView; 70import com.android.mail.browse.MessageHeaderView; 71import com.android.mail.browse.ScrollIndicatorsView; 72import com.android.mail.browse.SuperCollapsedBlock; 73import com.android.mail.browse.WebViewContextMenu; 74import com.android.mail.content.ObjectCursor; 75import com.android.mail.print.PrintUtils; 76import com.android.mail.providers.Account; 77import com.android.mail.providers.Conversation; 78import com.android.mail.providers.Message; 79import com.android.mail.providers.Settings; 80import com.android.mail.providers.UIProvider; 81import com.android.mail.ui.ConversationViewState.ExpansionState; 82import com.android.mail.utils.ConversationViewUtils; 83import com.android.mail.utils.LogTag; 84import com.android.mail.utils.LogUtils; 85import com.android.mail.utils.Utils; 86import com.google.common.collect.ImmutableList; 87import com.google.common.collect.Lists; 88import com.google.common.collect.Maps; 89import com.google.common.collect.Sets; 90 91import java.util.ArrayList; 92import java.util.List; 93import java.util.Map; 94import java.util.Set; 95 96/** 97 * The conversation view UI component. 98 */ 99public class ConversationViewFragment extends AbstractConversationViewFragment implements 100 SuperCollapsedBlock.OnClickListener, OnLayoutChangeListener, 101 MessageHeaderView.MessageHeaderViewCallbacks, MessageFooterView.MessageFooterCallbacks, 102 WebViewContextMenu.Callbacks, ConversationFooterCallbacks, View.OnKeyListener { 103 104 private static final String LOG_TAG = LogTag.getLogTag(); 105 public static final String LAYOUT_TAG = "ConvLayout"; 106 107 /** 108 * Difference in the height of the message header whose details have been expanded/collapsed 109 */ 110 private int mDiff = 0; 111 112 /** 113 * Default value for {@link #mLoadWaitReason}. Conversation load will happen immediately. 114 */ 115 private final int LOAD_NOW = 0; 116 /** 117 * Value for {@link #mLoadWaitReason} that means we are offscreen and waiting for the visible 118 * conversation to finish loading before beginning our load. 119 * <p> 120 * When this value is set, the fragment should register with {@link ConversationListCallbacks} 121 * to know when the visible conversation is loaded. When it is unset, it should unregister. 122 */ 123 private final int LOAD_WAIT_FOR_INITIAL_CONVERSATION = 1; 124 /** 125 * Value for {@link #mLoadWaitReason} used when a conversation is too heavyweight to load at 126 * all when not visible (e.g. requires network fetch, or too complex). Conversation load will 127 * wait until this fragment is visible. 128 */ 129 private final int LOAD_WAIT_UNTIL_VISIBLE = 2; 130 131 // Keyboard navigation 132 private KeyboardNavigationController mNavigationController; 133 // Since we manually control navigation for most of the conversation view due to problems 134 // with two-pane layout but still rely on the system for SOME navigation, we need to keep track 135 // of the view that had focus when KeyEvent.ACTION_DOWN was fired. This is because we only 136 // manually change focus on KeyEvent.ACTION_UP (to prevent holding down the DOWN button and 137 // lagging the app), however, the view in focus might have changed between ACTION_UP and 138 // ACTION_DOWN since the system might have handled the ACTION_DOWN and moved focus. 139 private View mOriginalKeyedView; 140 private int mMaxScreenHeight; 141 private int mTopOfVisibleScreen; 142 143 protected ConversationContainer mConversationContainer; 144 145 protected ConversationWebView mWebView; 146 147 private ViewGroup mTopmostOverlay; 148 149 private ConversationViewProgressController mProgressController; 150 151 private Button mNewMessageBar; 152 153 protected HtmlConversationTemplates mTemplates; 154 155 private final MailJsBridge mJsBridge = new MailJsBridge(); 156 157 protected ConversationViewAdapter mAdapter; 158 159 protected boolean mViewsCreated; 160 // True if we attempted to render before the views were laid out 161 // We will render immediately once layout is done 162 private boolean mNeedRender; 163 164 /** 165 * Temporary string containing the message bodies of the messages within a super-collapsed 166 * block, for one-time use during block expansion. We cannot easily pass the body HTML 167 * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it 168 * using {@link MailJsBridge}. 169 */ 170 private String mTempBodiesHtml; 171 172 private int mMaxAutoLoadMessages; 173 174 protected int mSideMarginPx; 175 176 /** 177 * If this conversation fragment is not visible, and it's inappropriate to load up front, 178 * this is the reason we are waiting. This flag should be cleared once it's okay to load 179 * the conversation. 180 */ 181 private int mLoadWaitReason = LOAD_NOW; 182 183 private boolean mEnableContentReadySignal; 184 185 private ContentSizeChangeListener mWebViewSizeChangeListener; 186 187 private float mWebViewYPercent; 188 189 /** 190 * Has loadData been called on the WebView yet? 191 */ 192 private boolean mWebViewLoadedData; 193 194 private long mWebViewLoadStartMs; 195 196 private final Map<String, String> mMessageTransforms = Maps.newHashMap(); 197 198 private final DataSetObserver mLoadedObserver = new DataSetObserver() { 199 @Override 200 public void onChanged() { 201 getHandler().post(new FragmentRunnable("delayedConversationLoad", 202 ConversationViewFragment.this) { 203 @Override 204 public void go() { 205 LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s", 206 ConversationViewFragment.this); 207 handleDelayedConversationLoad(); 208 } 209 }); 210 } 211 }; 212 213 private final Runnable mOnProgressDismiss = new FragmentRunnable("onProgressDismiss", this) { 214 @Override 215 public void go() { 216 LogUtils.d(LOG_TAG, "onProgressDismiss go() - isUserVisible() = %b", isUserVisible()); 217 if (isUserVisible()) { 218 onConversationSeen(); 219 } 220 mWebView.onRenderComplete(); 221 } 222 }; 223 224 private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false; 225 private static final boolean DISABLE_OFFSCREEN_LOADING = false; 226 private static final boolean DEBUG_DUMP_CURSOR_CONTENTS = false; 227 228 private static final String BUNDLE_KEY_WEBVIEW_Y_PERCENT = 229 ConversationViewFragment.class.getName() + "webview-y-percent"; 230 231 private BidiFormatter mBidiFormatter; 232 233 /** 234 * Contains a mapping between inline image attachments and their local message id. 235 */ 236 private Map<String, String> mUrlToMessageIdMap; 237 238 /** 239 * Constructor needs to be public to handle orientation changes and activity lifecycle events. 240 */ 241 public ConversationViewFragment() {} 242 243 /** 244 * Creates a new instance of {@link ConversationViewFragment}, initialized 245 * to display a conversation with other parameters inherited/copied from an existing bundle, 246 * typically one created using {@link #makeBasicArgs}. 247 */ 248 public static ConversationViewFragment newInstance(Bundle existingArgs, 249 Conversation conversation) { 250 ConversationViewFragment f = new ConversationViewFragment(); 251 Bundle args = new Bundle(existingArgs); 252 args.putParcelable(ARG_CONVERSATION, conversation); 253 f.setArguments(args); 254 return f; 255 } 256 257 @Override 258 public void onAccountChanged(Account newAccount, Account oldAccount) { 259 // if overview mode has changed, re-render completely (no need to also update headers) 260 if (isOverviewMode(newAccount) != isOverviewMode(oldAccount)) { 261 setupOverviewMode(); 262 final MessageCursor c = getMessageCursor(); 263 if (c != null) { 264 renderConversation(c); 265 } else { 266 // Null cursor means this fragment is either waiting to load or in the middle of 267 // loading. Either way, a future render will happen anyway, and the new setting 268 // will take effect when that happens. 269 } 270 return; 271 } 272 273 // settings may have been updated; refresh views that are known to 274 // depend on settings 275 mAdapter.notifyDataSetChanged(); 276 } 277 278 @Override 279 public void onActivityCreated(Bundle savedInstanceState) { 280 LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s visible=%s", this, isUserVisible()); 281 super.onActivityCreated(savedInstanceState); 282 283 if (mActivity == null || mActivity.isFinishing()) { 284 // Activity is finishing, just bail. 285 return; 286 } 287 288 Context context = getContext(); 289 mTemplates = new HtmlConversationTemplates(context); 290 291 final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(context); 292 293 mNavigationController = mActivity.getKeyboardNavigationController(); 294 295 mAdapter = new ConversationViewAdapter(mActivity, this, 296 getLoaderManager(), this, this, getContactInfoSource(), this, this, 297 getListController(), this, mAddressCache, dateBuilder, mBidiFormatter, this); 298 mConversationContainer.setOverlayAdapter(mAdapter); 299 300 // set up snap header (the adapter usually does this with the other ones) 301 mConversationContainer.getSnapHeader().initialize( 302 this, mAddressCache, this, getContactInfoSource(), 303 mActivity.getAccountController().getVeiledAddressMatcher()); 304 305 final Resources resources = getResources(); 306 mMaxAutoLoadMessages = resources.getInteger(R.integer.max_auto_load_messages); 307 308 mSideMarginPx = resources.getDimensionPixelOffset( 309 R.dimen.conversation_message_content_margin_side); 310 311 mUrlToMessageIdMap = new ArrayMap<String, String>(); 312 final InlineAttachmentViewIntentBuilderCreator creator = 313 InlineAttachmentViewIntentBuilderCreatorHolder. 314 getInlineAttachmentViewIntentCreator(); 315 final WebViewContextMenu contextMenu = new WebViewContextMenu(getActivity(), 316 creator.createInlineAttachmentViewIntentBuilder(mAccount, 317 mConversation != null ? mConversation.id : -1)); 318 contextMenu.setCallbacks(this); 319 mWebView.setOnCreateContextMenuListener(contextMenu); 320 321 // set this up here instead of onCreateView to ensure the latest Account is loaded 322 setupOverviewMode(); 323 324 // Defer the call to initLoader with a Handler. 325 // We want to wait until we know which fragments are present and their final visibility 326 // states before going off and doing work. This prevents extraneous loading from occurring 327 // as the ViewPager shifts about before the initial position is set. 328 // 329 // e.g. click on item #10 330 // ViewPager.setAdapter() actually first loads #0 and #1 under the assumption that #0 is 331 // the initial primary item 332 // Then CPC immediately sets the primary item to #10, which tears down #0/#1 and sets up 333 // #9/#10/#11. 334 getHandler().post(new FragmentRunnable("showConversation", this) { 335 @Override 336 public void go() { 337 showConversation(); 338 } 339 }); 340 341 if (mConversation != null && mConversation.conversationBaseUri != null && 342 !Utils.isEmpty(mAccount.accountCookieQueryUri)) { 343 // Set the cookie for this base url 344 new SetCookieTask(getContext(), mConversation.conversationBaseUri.toString(), 345 mAccount.accountCookieQueryUri).execute(); 346 } 347 348 // Find the height of the screen for manually scrolling the webview via keyboard. 349 final Rect screen = new Rect(); 350 mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(screen); 351 mMaxScreenHeight = screen.bottom; 352 mTopOfVisibleScreen = screen.top + mActivity.getSupportActionBar().getHeight(); 353 } 354 355 @Override 356 public void onCreate(Bundle savedState) { 357 super.onCreate(savedState); 358 359 mWebViewClient = createConversationWebViewClient(); 360 361 if (savedState != null) { 362 mWebViewYPercent = savedState.getFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT); 363 } 364 365 mBidiFormatter = BidiFormatter.getInstance(); 366 } 367 368 protected ConversationWebViewClient createConversationWebViewClient() { 369 return new ConversationWebViewClient(mAccount); 370 } 371 372 @Override 373 public View onCreateView(LayoutInflater inflater, 374 ViewGroup container, Bundle savedInstanceState) { 375 376 View rootView = inflater.inflate(R.layout.conversation_view, container, false); 377 mConversationContainer = (ConversationContainer) rootView 378 .findViewById(R.id.conversation_container); 379 mConversationContainer.setAccountController(this); 380 381 mTopmostOverlay = 382 (ViewGroup) mConversationContainer.findViewById(R.id.conversation_topmost_overlay); 383 mTopmostOverlay.setOnKeyListener(this); 384 inflateSnapHeader(mTopmostOverlay, inflater); 385 mConversationContainer.setupSnapHeader(); 386 387 setupNewMessageBar(); 388 389 mProgressController = new ConversationViewProgressController(this, getHandler()); 390 mProgressController.instantiateProgressIndicators(rootView); 391 392 mWebView = (ConversationWebView) 393 mConversationContainer.findViewById(R.id.conversation_webview); 394 395 mWebView.addJavascriptInterface(mJsBridge, "mail"); 396 // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete 397 // Below JB, try to speed up initial render by having the webview do supplemental draws to 398 // custom a software canvas. 399 // TODO(mindyp): 400 //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER 401 // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op 402 // animation that immediately runs on page load. The app uses this as a signal that the 403 // content is loaded and ready to draw, since WebView delays firing this event until the 404 // layers are composited and everything is ready to draw. 405 // This signal does not seem to be reliable, so just use the old method for now. 406 final boolean isJBOrLater = Utils.isRunningJellybeanOrLater(); 407 final boolean isUserVisible = isUserVisible(); 408 mWebView.setUseSoftwareLayer(!isJBOrLater); 409 mEnableContentReadySignal = isJBOrLater && isUserVisible; 410 mWebView.onUserVisibilityChanged(isUserVisible); 411 mWebView.setWebViewClient(mWebViewClient); 412 final WebChromeClient wcc = new WebChromeClient() { 413 @Override 414 public boolean onConsoleMessage(ConsoleMessage consoleMessage) { 415 if (consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { 416 LogUtils.wtf(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(), 417 consoleMessage.sourceId(), consoleMessage.lineNumber(), 418 ConversationViewFragment.this); 419 } else { 420 LogUtils.i(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(), 421 consoleMessage.sourceId(), consoleMessage.lineNumber(), 422 ConversationViewFragment.this); 423 } 424 return true; 425 } 426 }; 427 mWebView.setWebChromeClient(wcc); 428 429 final WebSettings settings = mWebView.getSettings(); 430 431 final ScrollIndicatorsView scrollIndicators = 432 (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators); 433 scrollIndicators.setSourceView(mWebView); 434 435 settings.setJavaScriptEnabled(true); 436 437 ConversationViewUtils.setTextZoom(getResources(), settings); 438 439 mViewsCreated = true; 440 mWebViewLoadedData = false; 441 442 return rootView; 443 } 444 445 protected void inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater) { 446 inflater.inflate(R.layout.conversation_topmost_overlay_items, topmostOverlay, true); 447 } 448 449 protected void setupNewMessageBar() { 450 mNewMessageBar = (Button) mConversationContainer.findViewById( 451 R.id.new_message_notification_bar); 452 mNewMessageBar.setOnClickListener(new View.OnClickListener() { 453 @Override 454 public void onClick(View v) { 455 onNewMessageBarClick(); 456 } 457 }); 458 } 459 460 @Override 461 public void onResume() { 462 super.onResume(); 463 if (mWebView != null) { 464 mWebView.onResume(); 465 } 466 } 467 468 @Override 469 public void onPause() { 470 super.onPause(); 471 if (mWebView != null) { 472 mWebView.onPause(); 473 } 474 } 475 476 @Override 477 public void onDestroyView() { 478 super.onDestroyView(); 479 mConversationContainer.setOverlayAdapter(null); 480 mAdapter = null; 481 resetLoadWaiting(); // be sure to unregister any active load observer 482 mViewsCreated = false; 483 } 484 485 @Override 486 public void onSaveInstanceState(Bundle outState) { 487 super.onSaveInstanceState(outState); 488 489 outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent()); 490 } 491 492 private float calculateScrollYPercent() { 493 final float p; 494 if (mWebView == null) { 495 // onCreateView hasn't been called, return 0 as the user hasn't scrolled the view. 496 return 0; 497 } 498 499 final int scrollY = mWebView.getScrollY(); 500 final int viewH = mWebView.getHeight(); 501 final int webH = (int) (mWebView.getContentHeight() * mWebView.getScale()); 502 503 if (webH == 0 || webH <= viewH) { 504 p = 0; 505 } else if (scrollY + viewH >= webH) { 506 // The very bottom is a special case, it acts as a stronger anchor than the scroll top 507 // at that point. 508 p = 1.0f; 509 } else { 510 p = (float) scrollY / webH; 511 } 512 return p; 513 } 514 515 private void resetLoadWaiting() { 516 if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) { 517 getListController().unregisterConversationLoadedObserver(mLoadedObserver); 518 } 519 mLoadWaitReason = LOAD_NOW; 520 } 521 522 @Override 523 protected void markUnread() { 524 super.markUnread(); 525 // Ignore unsafe calls made after a fragment is detached from an activity 526 final ControllableActivity activity = (ControllableActivity) getActivity(); 527 if (activity == null) { 528 LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id); 529 return; 530 } 531 532 if (mViewState == null) { 533 LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)", 534 mConversation.id); 535 return; 536 } 537 activity.getConversationUpdater().markConversationMessagesUnread(mConversation, 538 mViewState.getUnreadMessageUris(), mViewState.getConversationInfo()); 539 } 540 541 @Override 542 public void onUserVisibleHintChanged() { 543 final boolean userVisible = isUserVisible(); 544 LogUtils.d(LOG_TAG, "ConversationViewFragment#onUserVisibleHintChanged(), userVisible = %b", 545 userVisible); 546 547 if (!userVisible) { 548 mProgressController.dismissLoadingStatus(); 549 } else if (mViewsCreated) { 550 String loadTag = null; 551 final boolean isInitialLoading; 552 if (mActivity != null) { 553 isInitialLoading = mActivity.getConversationUpdater() 554 .isInitialConversationLoading(); 555 } else { 556 isInitialLoading = true; 557 } 558 559 if (getMessageCursor() != null) { 560 LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this); 561 if (!isInitialLoading) { 562 loadTag = "preloaded"; 563 } 564 onConversationSeen(); 565 } else if (isLoadWaiting()) { 566 LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this); 567 if (!isInitialLoading) { 568 loadTag = "load_deferred"; 569 } 570 handleDelayedConversationLoad(); 571 } 572 573 if (loadTag != null) { 574 // pager swipes are visibility transitions to 'visible' except during initial 575 // pager load on A) enter conversation mode B) rotate C) 2-pane conv-mode list-tap 576 Analytics.getInstance().sendEvent("pager_swipe", loadTag, 577 getCurrentFolderTypeDesc(), 0); 578 } 579 } 580 581 if (mWebView != null) { 582 mWebView.onUserVisibilityChanged(userVisible); 583 } 584 } 585 586 /** 587 * Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do 588 * nothing (in which case you should later call {@link #handleDelayedConversationLoad()}). 589 */ 590 private void showConversation() { 591 final int reason; 592 593 if (isUserVisible()) { 594 LogUtils.i(LOG_TAG, 595 "SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this); 596 reason = LOAD_NOW; 597 timerMark("CVF.showConversation"); 598 } else { 599 final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING 600 || Utils.isLowRamDevice(getContext()) 601 || (mConversation != null && (mConversation.isRemote 602 || mConversation.getNumMessages() > mMaxAutoLoadMessages)); 603 604 // When not visible, we should not immediately load if either this conversation is 605 // too heavyweight, or if the main/initial conversation is busy loading. 606 if (disableOffscreenLoading) { 607 reason = LOAD_WAIT_UNTIL_VISIBLE; 608 LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this); 609 } else if (getListController().isInitialConversationLoading()) { 610 reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION; 611 LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this); 612 getListController().registerConversationLoadedObserver(mLoadedObserver); 613 } else { 614 LogUtils.i(LOG_TAG, 615 "SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)", 616 this); 617 reason = LOAD_NOW; 618 } 619 } 620 621 mLoadWaitReason = reason; 622 if (mLoadWaitReason == LOAD_NOW) { 623 startConversationLoad(); 624 } 625 } 626 627 private void handleDelayedConversationLoad() { 628 resetLoadWaiting(); 629 startConversationLoad(); 630 } 631 632 private void startConversationLoad() { 633 mWebView.setVisibility(View.VISIBLE); 634 loadContent(); 635 // TODO(mindyp): don't show loading status for a previously rendered 636 // conversation. Ielieve this is better done by making sure don't show loading status 637 // until XX ms have passed without loading completed. 638 mProgressController.showLoadingStatus(isUserVisible()); 639 } 640 641 /** 642 * Can be overridden in case a subclass needs to load something other than 643 * the messages of a conversation. 644 */ 645 protected void loadContent() { 646 getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks()); 647 } 648 649 private void revealConversation() { 650 timerMark("revealing conversation"); 651 mProgressController.dismissLoadingStatus(mOnProgressDismiss); 652 if (isUserVisible()) { 653 AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST, 654 true /* isDestructive */, "open_conversation", "from_list", null); 655 } 656 } 657 658 private boolean isLoadWaiting() { 659 return mLoadWaitReason != LOAD_NOW; 660 } 661 662 private void renderConversation(MessageCursor messageCursor) { 663 final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal); 664 timerMark("rendered conversation"); 665 666 if (DEBUG_DUMP_CONVERSATION_HTML) { 667 java.io.FileWriter fw = null; 668 try { 669 fw = new java.io.FileWriter(getSdCardFilePath()); 670 fw.write(convHtml); 671 } catch (java.io.IOException e) { 672 e.printStackTrace(); 673 } finally { 674 if (fw != null) { 675 try { 676 fw.close(); 677 } catch (java.io.IOException e) { 678 e.printStackTrace(); 679 } 680 } 681 } 682 } 683 684 // save off existing scroll position before re-rendering 685 if (mWebViewLoadedData) { 686 mWebViewYPercent = calculateScrollYPercent(); 687 } 688 689 mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null); 690 mWebViewLoadedData = true; 691 mWebViewLoadStartMs = SystemClock.uptimeMillis(); 692 } 693 694 protected String getSdCardFilePath() { 695 return "/sdcard/conv" + mConversation.id + ".html"; 696 } 697 698 /** 699 * Populate the adapter with overlay views (message headers, super-collapsed blocks, a 700 * conversation header), and return an HTML document with spacer divs inserted for all overlays. 701 * 702 */ 703 protected String renderMessageBodies(MessageCursor messageCursor, 704 boolean enableContentReadySignal) { 705 int pos = -1; 706 707 LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this); 708 boolean allowNetworkImages = false; 709 710 // TODO: re-use any existing adapter item state (expanded, details expanded, show pics) 711 712 // Walk through the cursor and build up an overlay adapter as you go. 713 // Each overlay has an entry in the adapter for easy scroll handling in the container. 714 // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks. 715 // When adding adapter items, also add their heights to help the container later determine 716 // overlay dimensions. 717 718 // When re-rendering, prevent ConversationContainer from laying out overlays until after 719 // the new spacers are positioned by WebView. 720 mConversationContainer.invalidateSpacerGeometry(); 721 722 mAdapter.clear(); 723 724 // re-evaluate the message parts of the view state, since the messages may have changed 725 // since the previous render 726 final ConversationViewState prevState = mViewState; 727 mViewState = new ConversationViewState(prevState); 728 729 // N.B. the units of height for spacers are actually dp and not px because WebView assumes 730 // a pixel is an mdpi pixel, unless you set device-dpi. 731 732 // add a single conversation header item 733 final int convHeaderPos = mAdapter.addConversationHeader(mConversation); 734 final int convHeaderPx = measureOverlayHeight(convHeaderPos); 735 736 mTemplates.startConversation(mWebView.getViewportWidth(), 737 mWebView.screenPxToWebPx(mSideMarginPx), mWebView.screenPxToWebPx(convHeaderPx)); 738 739 int collapsedStart = -1; 740 ConversationMessage prevCollapsedMsg = null; 741 742 final boolean alwaysShowImages = shouldAlwaysShowImages(); 743 744 boolean prevSafeForImages = alwaysShowImages; 745 746 boolean hasDraft = false; 747 while (messageCursor.moveToPosition(++pos)) { 748 final ConversationMessage msg = messageCursor.getMessage(); 749 750 final boolean safeForImages = alwaysShowImages || 751 msg.alwaysShowImages || prevState.getShouldShowImages(msg); 752 allowNetworkImages |= safeForImages; 753 754 final Integer savedExpanded = prevState.getExpansionState(msg); 755 final int expandedState; 756 if (savedExpanded != null) { 757 if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) { 758 // override saved state when this is now the new last message 759 // this happens to the second-to-last message when you discard a draft 760 expandedState = ExpansionState.EXPANDED; 761 } else { 762 expandedState = savedExpanded; 763 } 764 } else { 765 // new messages that are not expanded default to being eligible for super-collapse 766 if (!msg.read || messageCursor.isLast()) { 767 expandedState = ExpansionState.EXPANDED; 768 } else if (messageCursor.isFirst()) { 769 expandedState = ExpansionState.COLLAPSED; 770 } else { 771 expandedState = ExpansionState.SUPER_COLLAPSED; 772 hasDraft |= msg.isDraft(); 773 } 774 } 775 mViewState.setShouldShowImages(msg, prevState.getShouldShowImages(msg)); 776 mViewState.setExpansionState(msg, expandedState); 777 778 // save off "read" state from the cursor 779 // later, the view may not match the cursor (e.g. conversation marked read on open) 780 // however, if a previous state indicated this message was unread, trust that instead 781 // so "mark unread" marks all originally unread messages 782 mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg)); 783 784 // We only want to consider this for inclusion in the super collapsed block if 785 // 1) The we don't have previous state about this message (The first time that the 786 // user opens a conversation) 787 // 2) The previously saved state for this message indicates that this message is 788 // in the super collapsed block. 789 if (ExpansionState.isSuperCollapsed(expandedState)) { 790 // contribute to a super-collapsed block that will be emitted just before the 791 // next expanded header 792 if (collapsedStart < 0) { 793 collapsedStart = pos; 794 } 795 prevCollapsedMsg = msg; 796 prevSafeForImages = safeForImages; 797 798 // This line puts the from address in the address cache so that 799 // we get the sender image for it if it's in a super-collapsed block. 800 getAddress(msg.getFrom()); 801 continue; 802 } 803 804 // resolve any deferred decisions on previous collapsed items 805 if (collapsedStart >= 0) { 806 if (pos - collapsedStart == 1) { 807 // Special-case for a single collapsed message: no need to super-collapse it. 808 renderMessage(prevCollapsedMsg, false /* expanded */, prevSafeForImages); 809 } else { 810 renderSuperCollapsedBlock(collapsedStart, pos - 1, hasDraft); 811 } 812 hasDraft = false; // reset hasDraft 813 prevCollapsedMsg = null; 814 collapsedStart = -1; 815 } 816 817 renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages); 818 } 819 820 final MessageHeaderItem lastHeaderItem = getLastMessageHeaderItem(); 821 final int convFooterPos = mAdapter.addConversationFooter(lastHeaderItem); 822 final int convFooterPx = measureOverlayHeight(convFooterPos); 823 824 mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages); 825 826 final boolean applyTransforms = shouldApplyTransforms(); 827 828 // If the conversation has specified a base uri, use it here, otherwise use mBaseUri 829 return mTemplates.endConversation(convFooterPx, mBaseUri, 830 mConversation.getBaseUri(mBaseUri), 831 mWebView.getViewportWidth(), mWebView.getWidthInDp(mSideMarginPx), 832 enableContentReadySignal, isOverviewMode(mAccount), applyTransforms, 833 applyTransforms); 834 } 835 836 private MessageHeaderItem getLastMessageHeaderItem() { 837 final int count = mAdapter.getCount(); 838 if (count < 3) { 839 LogUtils.wtf(LOG_TAG, "not enough items in the adapter. count: %s", count); 840 return null; 841 } 842 return (MessageHeaderItem) mAdapter.getItem(count - 2); 843 } 844 845 private void renderSuperCollapsedBlock(int start, int end, boolean hasDraft) { 846 final int blockPos = mAdapter.addSuperCollapsedBlock(start, end, hasDraft); 847 final int blockPx = measureOverlayHeight(blockPos); 848 mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx)); 849 } 850 851 private void renderMessage(ConversationMessage msg, boolean expanded, boolean safeForImages) { 852 853 final int headerPos = mAdapter.addMessageHeader(msg, expanded, 854 mViewState.getShouldShowImages(msg)); 855 final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos); 856 857 final int footerPos = mAdapter.addMessageFooter(headerItem); 858 859 // Measure item header and footer heights to allocate spacers in HTML 860 // But since the views themselves don't exist yet, render each item temporarily into 861 // a host view for measurement. 862 final int headerPx = measureOverlayHeight(headerPos); 863 final int footerPx = measureOverlayHeight(footerPos); 864 865 mTemplates.appendMessageHtml(msg, expanded, safeForImages, 866 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx)); 867 timerMark("rendered message"); 868 } 869 870 private String renderCollapsedHeaders(MessageCursor cursor, 871 SuperCollapsedBlockItem blockToReplace) { 872 final List<ConversationOverlayItem> replacements = Lists.newArrayList(); 873 874 mTemplates.reset(); 875 876 final boolean alwaysShowImages = (mAccount != null) && 877 (mAccount.settings.showImages == Settings.ShowImages.ALWAYS); 878 879 // In devices with non-integral density multiplier, screen pixels translate to non-integral 880 // web pixels. Keep track of the error that occurs when we cast all heights to int 881 float error = 0f; 882 boolean first = true; 883 for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) { 884 cursor.moveToPosition(i); 885 final ConversationMessage msg = cursor.getMessage(); 886 887 final MessageHeaderItem header = ConversationViewAdapter.newMessageHeaderItem( 888 mAdapter, mAdapter.getDateBuilder(), msg, false /* expanded */, 889 alwaysShowImages || mViewState.getShouldShowImages(msg)); 890 final MessageFooterItem footer = mAdapter.newMessageFooterItem(mAdapter, header); 891 892 final int headerPx = measureOverlayHeight(header); 893 final int footerPx = measureOverlayHeight(footer); 894 error += mWebView.screenPxToWebPxError(headerPx) 895 + mWebView.screenPxToWebPxError(footerPx); 896 897 // When the error becomes greater than 1 pixel, make the next header 1 pixel taller 898 int correction = 0; 899 if (error >= 1) { 900 correction = 1; 901 error -= 1; 902 } 903 904 mTemplates.appendMessageHtml(msg, false /* expanded */, 905 alwaysShowImages || msg.alwaysShowImages, 906 mWebView.screenPxToWebPx(headerPx) + correction, 907 mWebView.screenPxToWebPx(footerPx)); 908 replacements.add(header); 909 replacements.add(footer); 910 911 mViewState.setExpansionState(msg, ExpansionState.COLLAPSED); 912 } 913 914 mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements); 915 mAdapter.notifyDataSetChanged(); 916 917 return mTemplates.emit(); 918 } 919 920 protected int measureOverlayHeight(int position) { 921 return measureOverlayHeight(mAdapter.getItem(position)); 922 } 923 924 /** 925 * Measure the height of an adapter view by rendering an adapter item into a temporary 926 * host view, and asking the view to immediately measure itself. This method will reuse 927 * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated 928 * earlier. 929 * <p> 930 * After measuring the height, this method also saves the height in the 931 * {@link ConversationOverlayItem} for later use in overlay positioning. 932 * 933 * @param convItem adapter item with data to render and measure 934 * @return height of the rendered view in screen px 935 */ 936 private int measureOverlayHeight(ConversationOverlayItem convItem) { 937 final int type = convItem.getType(); 938 939 final View convertView = mConversationContainer.getScrapView(type); 940 final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer, 941 true /* measureOnly */); 942 if (convertView == null) { 943 mConversationContainer.addScrapView(type, hostView); 944 } 945 946 final int heightPx = mConversationContainer.measureOverlay(hostView); 947 convItem.setHeight(heightPx); 948 convItem.markMeasurementValid(); 949 950 return heightPx; 951 } 952 953 @Override 954 public void onConversationViewHeaderHeightChange(int newHeight) { 955 final int h = mWebView.screenPxToWebPx(newHeight); 956 957 mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h)); 958 } 959 960 // END conversation header callbacks 961 962 // START conversation footer callbacks 963 964 @Override 965 public void onConversationFooterHeightChange(int newHeight) { 966 final int h = mWebView.screenPxToWebPx(newHeight); 967 968 mWebView.loadUrl(String.format("javascript:setConversationFooterSpacerHeight(%s);", h)); 969 } 970 971 // END conversation footer callbacks 972 973 // START message header callbacks 974 @Override 975 public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) { 976 mConversationContainer.invalidateSpacerGeometry(); 977 978 // update message HTML spacer height 979 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 980 LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h, 981 newSpacerHeightPx); 982 mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);", 983 mTemplates.getMessageDomId(item.getMessage()), h)); 984 } 985 986 @Override 987 public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) { 988 mConversationContainer.invalidateSpacerGeometry(); 989 990 // show/hide the HTML message body and update the spacer height 991 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 992 LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)", 993 item.isExpanded(), h, newSpacerHeightPx); 994 mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);", 995 mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h)); 996 997 mViewState.setExpansionState(item.getMessage(), 998 item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED); 999 } 1000 1001 @Override 1002 public void showExternalResources(final Message msg) { 1003 mViewState.setShouldShowImages(msg, true); 1004 mWebView.getSettings().setBlockNetworkImage(false); 1005 mWebView.loadUrl("javascript:unblockImages(['" + mTemplates.getMessageDomId(msg) + "']);"); 1006 } 1007 1008 @Override 1009 public void showExternalResources(final String senderRawAddress) { 1010 mWebView.getSettings().setBlockNetworkImage(false); 1011 1012 final Address sender = getAddress(senderRawAddress); 1013 final MessageCursor cursor = getMessageCursor(); 1014 1015 final List<String> messageDomIds = new ArrayList<String>(); 1016 1017 int pos = -1; 1018 while (cursor.moveToPosition(++pos)) { 1019 final ConversationMessage message = cursor.getMessage(); 1020 if (sender.equals(getAddress(message.getFrom()))) { 1021 message.alwaysShowImages = true; 1022 1023 mViewState.setShouldShowImages(message, true); 1024 messageDomIds.add(mTemplates.getMessageDomId(message)); 1025 } 1026 } 1027 1028 final String url = String.format( 1029 "javascript:unblockImages(['%s']);", TextUtils.join("','", messageDomIds)); 1030 mWebView.loadUrl(url); 1031 } 1032 1033 @Override 1034 public boolean supportsMessageTransforms() { 1035 return true; 1036 } 1037 1038 @Override 1039 public String getMessageTransforms(final Message msg) { 1040 final String domId = mTemplates.getMessageDomId(msg); 1041 return (domId == null) ? null : mMessageTransforms.get(domId); 1042 } 1043 1044 @Override 1045 public boolean isSecure() { 1046 return false; 1047 } 1048 1049 // END message header callbacks 1050 1051 @Override 1052 public void showUntransformedConversation() { 1053 super.showUntransformedConversation(); 1054 renderConversation(getMessageCursor()); 1055 } 1056 1057 @Override 1058 public void onSuperCollapsedClick(SuperCollapsedBlockItem item) { 1059 MessageCursor cursor = getMessageCursor(); 1060 if (cursor == null || !mViewsCreated) { 1061 return; 1062 } 1063 1064 mTempBodiesHtml = renderCollapsedHeaders(cursor, item); 1065 mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")"); 1066 mConversationContainer.focusFirstMessageHeader(); 1067 } 1068 1069 private void showNewMessageNotification(NewMessagesInfo info) { 1070 mNewMessageBar.setText(info.getNotificationText()); 1071 mNewMessageBar.setVisibility(View.VISIBLE); 1072 } 1073 1074 private void onNewMessageBarClick() { 1075 mNewMessageBar.setVisibility(View.GONE); 1076 1077 renderConversation(getMessageCursor()); // mCursor is already up-to-date 1078 // per onLoadFinished() 1079 } 1080 1081 private static OverlayPosition[] parsePositions(final int[] topArray, final int[] bottomArray) { 1082 final int len = topArray.length; 1083 final OverlayPosition[] positions = new OverlayPosition[len]; 1084 for (int i = 0; i < len; i++) { 1085 positions[i] = new OverlayPosition(topArray[i], bottomArray[i]); 1086 } 1087 return positions; 1088 } 1089 1090 protected Address getAddress(String rawFrom) { 1091 return Utils.getAddress(mAddressCache, rawFrom); 1092 } 1093 1094 private void ensureContentSizeChangeListener() { 1095 if (mWebViewSizeChangeListener == null) { 1096 mWebViewSizeChangeListener = new ContentSizeChangeListener() { 1097 @Override 1098 public void onHeightChange(int h) { 1099 // When WebKit says the DOM height has changed, re-measure 1100 // bodies and re-position their headers. 1101 // This is separate from the typical JavaScript DOM change 1102 // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM 1103 // events. 1104 mWebView.loadUrl("javascript:measurePositions();"); 1105 } 1106 }; 1107 } 1108 mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener); 1109 } 1110 1111 public static boolean isOverviewMode(Account acct) { 1112 return acct.settings.isOverviewMode(); 1113 } 1114 1115 private void setupOverviewMode() { 1116 // for now, overview mode means use the built-in WebView zoom and disable custom scale 1117 // gesture handling 1118 final boolean overviewMode = isOverviewMode(mAccount); 1119 final WebSettings settings = mWebView.getSettings(); 1120 final WebSettings.LayoutAlgorithm layout; 1121 settings.setUseWideViewPort(overviewMode); 1122 settings.setSupportZoom(overviewMode); 1123 settings.setBuiltInZoomControls(overviewMode); 1124 settings.setLoadWithOverviewMode(overviewMode); 1125 if (overviewMode) { 1126 settings.setDisplayZoomControls(false); 1127 layout = WebSettings.LayoutAlgorithm.NORMAL; 1128 } else { 1129 layout = WebSettings.LayoutAlgorithm.NARROW_COLUMNS; 1130 } 1131 settings.setLayoutAlgorithm(layout); 1132 } 1133 1134 @Override 1135 public ConversationMessage getMessageForClickedUrl(String url) { 1136 final String domMessageId = mUrlToMessageIdMap.get(url); 1137 if (domMessageId == null) { 1138 return null; 1139 } 1140 final String messageId = mTemplates.getMessageIdForDomId(domMessageId); 1141 return getMessageCursor().getMessageForId(Long.parseLong(messageId)); 1142 } 1143 1144 @Override 1145 public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { 1146 if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { 1147 mOriginalKeyedView = view; 1148 } 1149 1150 if (mOriginalKeyedView != null) { 1151 final int id = mOriginalKeyedView.getId(); 1152 final boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; 1153 final boolean isLeft = keyCode == KeyEvent.KEYCODE_DPAD_LEFT; 1154 final boolean isRight = keyCode == KeyEvent.KEYCODE_DPAD_RIGHT; 1155 final boolean isUp = keyCode == KeyEvent.KEYCODE_DPAD_UP; 1156 final boolean isDown = keyCode == KeyEvent.KEYCODE_DPAD_DOWN; 1157 1158 // First we run the event by the controller 1159 // We manually check if the view+direction combination should shift focus away from the 1160 // conversation view to the thread list in two-pane landscape mode. 1161 final boolean isTwoPaneLand = mNavigationController.isTwoPaneLandscape(); 1162 final boolean navigateAway = mConversationContainer.shouldNavigateAway(id, isLeft, 1163 isTwoPaneLand); 1164 if (mNavigationController.onInterceptKeyFromCV(keyCode, keyEvent, navigateAway)) { 1165 return true; 1166 } 1167 1168 // If controller didn't handle the event, check directional interception. 1169 if ((isLeft || isRight) && mConversationContainer.shouldInterceptLeftRightEvents( 1170 id, isLeft, isRight, isTwoPaneLand)) { 1171 return true; 1172 } else if (isUp || isDown) { 1173 // We don't do anything on up/down for overlay 1174 if (id == R.id.conversation_topmost_overlay) { 1175 return true; 1176 } 1177 1178 // We manually handle up/down navigation through the overlay items because the 1179 // system's default isn't optimal for two-pane landscape since it's not a real list. 1180 final int position = mConversationContainer.getViewPosition(mOriginalKeyedView); 1181 final View next = mConversationContainer.getNextOverlayView(position, isDown); 1182 if (next != null) { 1183 if (isActionUp) { 1184 next.requestFocus(); 1185 1186 // Make sure that v is in view 1187 final int[] coords = new int[2]; 1188 next.getLocationOnScreen(coords); 1189 final int bottom = coords[1] + next.getHeight(); 1190 if (bottom > mMaxScreenHeight) { 1191 mWebView.scrollBy(0, bottom - mMaxScreenHeight); 1192 } else if (coords[1] < mTopOfVisibleScreen) { 1193 mWebView.scrollBy(0, coords[1] - mTopOfVisibleScreen); 1194 } 1195 } 1196 return true; 1197 } else { 1198 // Special case two end points 1199 // Start is marked as index 1 because we are currently not allowing focus on 1200 // conversation view header. 1201 if ((position == mConversationContainer.getOverlayCount() - 1 && isDown) || 1202 (position == 1 && isUp)) { 1203 mTopmostOverlay.requestFocus(); 1204 // Scroll to the the top if we hit the first item 1205 if (isUp) { 1206 mWebView.scrollTo(0, 0); 1207 } 1208 return true; 1209 } 1210 } 1211 } 1212 1213 // Finally we handle the special keys 1214 if (keyCode == KeyEvent.KEYCODE_BACK && id != R.id.conversation_topmost_overlay) { 1215 if (isActionUp) { 1216 mTopmostOverlay.requestFocus(); 1217 } 1218 return true; 1219 } else if (keyCode == KeyEvent.KEYCODE_ENTER && 1220 id == R.id.conversation_topmost_overlay) { 1221 if (isActionUp) { 1222 mConversationContainer.focusFirstMessageHeader(); 1223 mWebView.scrollTo(0, 0); 1224 } 1225 return true; 1226 } 1227 } 1228 return false; 1229 } 1230 1231 public class ConversationWebViewClient extends AbstractConversationWebViewClient { 1232 public ConversationWebViewClient(Account account) { 1233 super(account); 1234 } 1235 1236 @Override 1237 public void onPageFinished(WebView view, String url) { 1238 // Ignore unsafe calls made after a fragment is detached from an activity. 1239 // This method needs to, for example, get at the loader manager, which needs 1240 // the fragment to be added. 1241 if (!isAdded() || !mViewsCreated) { 1242 LogUtils.d(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url, 1243 ConversationViewFragment.this); 1244 return; 1245 } 1246 1247 LogUtils.d(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s wv=%s t=%sms", url, 1248 ConversationViewFragment.this, view, 1249 (SystemClock.uptimeMillis() - mWebViewLoadStartMs)); 1250 1251 ensureContentSizeChangeListener(); 1252 1253 if (!mEnableContentReadySignal) { 1254 revealConversation(); 1255 } 1256 1257 final Set<String> emailAddresses = Sets.newHashSet(); 1258 final List<Address> cacheCopy; 1259 synchronized (mAddressCache) { 1260 cacheCopy = ImmutableList.copyOf(mAddressCache.values()); 1261 } 1262 for (Address addr : cacheCopy) { 1263 emailAddresses.add(addr.getAddress()); 1264 } 1265 final ContactLoaderCallbacks callbacks = getContactInfoSource(); 1266 callbacks.setSenders(emailAddresses); 1267 getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks); 1268 } 1269 1270 @Override 1271 public boolean shouldOverrideUrlLoading(WebView view, String url) { 1272 return mViewsCreated && super.shouldOverrideUrlLoading(view, url); 1273 } 1274 } 1275 1276 /** 1277 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 1278 * via reflection and not stripped. 1279 * 1280 */ 1281 private class MailJsBridge { 1282 @JavascriptInterface 1283 public void onWebContentGeometryChange(final int[] overlayTopStrs, 1284 final int[] overlayBottomStrs) { 1285 try { 1286 getHandler().post(new FragmentRunnable("onWebContentGeometryChange", 1287 ConversationViewFragment.this) { 1288 @Override 1289 public void go() { 1290 if (!mViewsCreated) { 1291 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" 1292 + " are gone, %s", ConversationViewFragment.this); 1293 return; 1294 } 1295 mConversationContainer.onGeometryChange( 1296 parsePositions(overlayTopStrs, overlayBottomStrs)); 1297 if (mDiff != 0) { 1298 // SCROLL! 1299 int scale = (int) (mWebView.getScale() / mWebView.getInitialScale()); 1300 if (scale > 1) { 1301 mWebView.scrollBy(0, (mDiff * (scale - 1))); 1302 } 1303 mDiff = 0; 1304 } 1305 } 1306 }); 1307 } catch (Throwable t) { 1308 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange"); 1309 } 1310 } 1311 1312 @JavascriptInterface 1313 public String getTempMessageBodies() { 1314 try { 1315 if (!mViewsCreated) { 1316 return ""; 1317 } 1318 1319 final String s = mTempBodiesHtml; 1320 mTempBodiesHtml = null; 1321 return s; 1322 } catch (Throwable t) { 1323 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies"); 1324 return ""; 1325 } 1326 } 1327 1328 @JavascriptInterface 1329 public String getMessageBody(String domId) { 1330 try { 1331 final MessageCursor cursor = getMessageCursor(); 1332 if (!mViewsCreated || cursor == null) { 1333 return ""; 1334 } 1335 1336 int pos = -1; 1337 while (cursor.moveToPosition(++pos)) { 1338 final ConversationMessage msg = cursor.getMessage(); 1339 if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) { 1340 return HtmlConversationTemplates.wrapMessageBody(msg.getBodyAsHtml()); 1341 } 1342 } 1343 1344 return ""; 1345 1346 } catch (Throwable t) { 1347 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody"); 1348 return ""; 1349 } 1350 } 1351 1352 @JavascriptInterface 1353 public String getMessageSender(String domId) { 1354 try { 1355 final MessageCursor cursor = getMessageCursor(); 1356 if (!mViewsCreated || cursor == null) { 1357 return ""; 1358 } 1359 1360 int pos = -1; 1361 while (cursor.moveToPosition(++pos)) { 1362 final ConversationMessage msg = cursor.getMessage(); 1363 if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) { 1364 return getAddress(msg.getFrom()).getAddress(); 1365 } 1366 } 1367 1368 return ""; 1369 1370 } catch (Throwable t) { 1371 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageSender"); 1372 return ""; 1373 } 1374 } 1375 1376 @JavascriptInterface 1377 public void onContentReady() { 1378 try { 1379 getHandler().post(new FragmentRunnable("onContentReady", 1380 ConversationViewFragment.this) { 1381 @Override 1382 public void go() { 1383 try { 1384 if (mWebViewLoadStartMs != 0) { 1385 LogUtils.i(LOG_TAG, "IN CVF.onContentReady, f=%s vis=%s t=%sms", 1386 ConversationViewFragment.this, 1387 isUserVisible(), 1388 (SystemClock.uptimeMillis() - mWebViewLoadStartMs)); 1389 } 1390 revealConversation(); 1391 } catch (Throwable t) { 1392 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady"); 1393 // Still try to show the conversation. 1394 revealConversation(); 1395 } 1396 } 1397 }); 1398 } catch (Throwable t) { 1399 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady"); 1400 } 1401 } 1402 1403 @JavascriptInterface 1404 public float getScrollYPercent() { 1405 try { 1406 return mWebViewYPercent; 1407 } catch (Throwable t) { 1408 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent"); 1409 return 0f; 1410 } 1411 } 1412 1413 @JavascriptInterface 1414 public void onMessageTransform(String messageDomId, String transformText) { 1415 try { 1416 LogUtils.i(LOG_TAG, "TRANSFORM: (%s) %s", messageDomId, transformText); 1417 mMessageTransforms.put(messageDomId, transformText); 1418 onConversationTransformed(); 1419 } catch (Throwable t) { 1420 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onMessageTransform"); 1421 } 1422 } 1423 1424 @JavascriptInterface 1425 public void onInlineAttachmentsParsed(final String[] urls, final String[] messageIds) { 1426 try { 1427 getHandler().post(new FragmentRunnable("onInlineAttachmentsParsed", 1428 ConversationViewFragment.this) { 1429 @Override 1430 public void go() { 1431 try { 1432 for (int i = 0, size = urls.length; i < size; i++) { 1433 mUrlToMessageIdMap.put(urls[i], messageIds[i]); 1434 } 1435 } catch (ArrayIndexOutOfBoundsException e) { 1436 LogUtils.e(LOG_TAG, e, 1437 "Number of urls does not match number of message ids - %s:%s", 1438 urls.length, messageIds.length); 1439 } 1440 } 1441 }); 1442 } catch (Throwable t) { 1443 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onInlineAttachmentsParsed"); 1444 } 1445 } 1446 } 1447 1448 private class NewMessagesInfo { 1449 int count; 1450 int countFromSelf; 1451 String senderAddress; 1452 1453 /** 1454 * Return the display text for the new message notification overlay. It will be formatted 1455 * appropriately for a single new message vs. multiple new messages. 1456 * 1457 * @return display text 1458 */ 1459 public String getNotificationText() { 1460 Resources res = getResources(); 1461 if (count > 1) { 1462 return res.getQuantityString(R.plurals.new_incoming_messages_many, count, count); 1463 } else { 1464 final Address addr = getAddress(senderAddress); 1465 return res.getString(R.string.new_incoming_messages_one, 1466 mBidiFormatter.unicodeWrap(TextUtils.isEmpty(addr.getPersonal()) 1467 ? addr.getAddress() : addr.getPersonal())); 1468 } 1469 } 1470 } 1471 1472 @Override 1473 public void onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader, 1474 MessageCursor newCursor, MessageCursor oldCursor) { 1475 /* 1476 * what kind of changes affect the MessageCursor? 1. new message(s) 2. 1477 * read/unread state change 3. deleted message, either regular or draft 1478 * 4. updated message, either from self or from others, updated in 1479 * content or state or sender 5. star/unstar of message (technically 1480 * similar to #1) 6. other label change Use MessageCursor.hashCode() to 1481 * sort out interesting vs. no-op cursor updates. 1482 */ 1483 1484 if (oldCursor != null && !oldCursor.isClosed()) { 1485 final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor); 1486 1487 if (info.count > 0) { 1488 // don't immediately render new incoming messages from other 1489 // senders 1490 // (to avoid a new message from losing the user's focus) 1491 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1492 + ", holding cursor for new incoming message (%s)", this); 1493 showNewMessageNotification(info); 1494 return; 1495 } 1496 1497 final int oldState = oldCursor.getStateHashCode(); 1498 final boolean changed = newCursor.getStateHashCode() != oldState; 1499 1500 if (!changed) { 1501 final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor); 1502 if (processedInPlace) { 1503 LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this); 1504 } else { 1505 LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update" 1506 + ", ignoring this conversation update (%s)", this); 1507 } 1508 return; 1509 } else if (info.countFromSelf == 1) { 1510 // Special-case the very common case of a new cursor that is the same as the old 1511 // one, except that there is a new message from yourself. This happens upon send. 1512 final boolean sameExceptNewLast = newCursor.getStateHashCode(1) == oldState; 1513 if (sameExceptNewLast) { 1514 LogUtils.i(LOG_TAG, "CONV RENDER: update is a single new message from self" 1515 + " (%s)", this); 1516 newCursor.moveToLast(); 1517 processNewOutgoingMessage(newCursor.getMessage()); 1518 return; 1519 } 1520 } 1521 // cursors are different, and not due to an incoming message. fall 1522 // through and render. 1523 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1524 + ", but not due to incoming message. rendering. (%s)", this); 1525 1526 if (DEBUG_DUMP_CURSOR_CONTENTS) { 1527 LogUtils.i(LOG_TAG, "old cursor: %s", oldCursor.getDebugDump()); 1528 LogUtils.i(LOG_TAG, "new cursor: %s", newCursor.getDebugDump()); 1529 } 1530 } else { 1531 LogUtils.i(LOG_TAG, "CONV RENDER: initial render. (%s)", this); 1532 timerMark("message cursor load finished"); 1533 } 1534 1535 renderContent(newCursor); 1536 } 1537 1538 protected void renderContent(MessageCursor messageCursor) { 1539 // if layout hasn't happened, delay render 1540 // This is needed in addition to the showConversation() delay to speed 1541 // up rotation and restoration. 1542 if (mConversationContainer.getWidth() == 0) { 1543 mNeedRender = true; 1544 mConversationContainer.addOnLayoutChangeListener(this); 1545 } else { 1546 renderConversation(messageCursor); 1547 } 1548 } 1549 1550 private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) { 1551 final NewMessagesInfo info = new NewMessagesInfo(); 1552 1553 int pos = -1; 1554 while (newCursor.moveToPosition(++pos)) { 1555 final Message m = newCursor.getMessage(); 1556 if (!mViewState.contains(m)) { 1557 LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri); 1558 1559 final Address from = getAddress(m.getFrom()); 1560 // distinguish ours from theirs 1561 // new messages from the account owner should not trigger a 1562 // notification 1563 if (from == null || mAccount.ownsFromAddress(from.getAddress())) { 1564 LogUtils.i(LOG_TAG, "found message from self: %s", m.uri); 1565 info.countFromSelf++; 1566 continue; 1567 } 1568 1569 info.count++; 1570 info.senderAddress = m.getFrom(); 1571 } 1572 } 1573 return info; 1574 } 1575 1576 private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) { 1577 final Set<String> idsOfChangedBodies = Sets.newHashSet(); 1578 final List<Integer> changedOverlayPositions = Lists.newArrayList(); 1579 1580 boolean changed = false; 1581 1582 int pos = 0; 1583 while (true) { 1584 if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) { 1585 break; 1586 } 1587 1588 final ConversationMessage newMsg = newCursor.getMessage(); 1589 final ConversationMessage oldMsg = oldCursor.getMessage(); 1590 1591 // We are going to update the data in the adapter whenever any input fields change. 1592 // This ensures that the Message object that ComposeActivity uses will be correctly 1593 // aligned with the most up-to-date data. 1594 if (!newMsg.isEqual(oldMsg)) { 1595 mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions); 1596 LogUtils.i(LOG_TAG, "msg #%d (%d): detected field(s) change. sendingState=%s", 1597 pos, newMsg.id, newMsg.sendingState); 1598 } 1599 1600 // update changed message bodies in-place 1601 if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) || 1602 !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) { 1603 // maybe just set a flag to notify JS to re-request changed bodies 1604 idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"'); 1605 LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id); 1606 } 1607 1608 pos++; 1609 } 1610 1611 1612 if (!changedOverlayPositions.isEmpty()) { 1613 // notify once after the entire adapter is updated 1614 mConversationContainer.onOverlayModelUpdate(changedOverlayPositions); 1615 changed = true; 1616 } 1617 1618 final ConversationFooterItem footerItem = mAdapter.getFooterItem(); 1619 if (footerItem != null) { 1620 footerItem.invalidateMeasurement(); 1621 } 1622 if (!idsOfChangedBodies.isEmpty()) { 1623 mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);", 1624 TextUtils.join(",", idsOfChangedBodies))); 1625 changed = true; 1626 } 1627 1628 return changed; 1629 } 1630 1631 private void processNewOutgoingMessage(ConversationMessage msg) { 1632 // Temporarily remove the ConversationFooterItem and its view. 1633 // It will get re-added right after the new message is added. 1634 final ConversationFooterItem footerItem = mAdapter.removeFooterItem(); 1635 mConversationContainer.removeViewAtAdapterIndex(footerItem.getPosition()); 1636 mTemplates.reset(); 1637 // this method will add some items to mAdapter, but we deliberately want to avoid notifying 1638 // adapter listeners (i.e. ConversationContainer) until onWebContentGeometryChange is next 1639 // called, to prevent N+1 headers rendering with N message bodies. 1640 renderMessage(msg, true /* expanded */, msg.alwaysShowImages); 1641 mTempBodiesHtml = mTemplates.emit(); 1642 1643 if (footerItem != null) { 1644 footerItem.setLastMessageHeaderItem(getLastMessageHeaderItem()); 1645 footerItem.invalidateMeasurement(); 1646 mAdapter.addItem(footerItem); 1647 } 1648 1649 mViewState.setExpansionState(msg, ExpansionState.EXPANDED); 1650 // FIXME: should the provider set this as initial state? 1651 mViewState.setReadState(msg, false /* read */); 1652 1653 // From now until the updated spacer geometry is returned, the adapter items are mismatched 1654 // with the existing spacers. Do not let them layout. 1655 mConversationContainer.invalidateSpacerGeometry(); 1656 1657 mWebView.loadUrl("javascript:appendMessageHtml();"); 1658 } 1659 1660 private static class SetCookieTask extends AsyncTask<Void, Void, Void> { 1661 private final Context mContext; 1662 private final String mUri; 1663 private final Uri mAccountCookieQueryUri; 1664 private final ContentResolver mResolver; 1665 1666 /* package */ SetCookieTask(Context context, String baseUri, Uri accountCookieQueryUri) { 1667 mContext = context; 1668 mUri = baseUri; 1669 mAccountCookieQueryUri = accountCookieQueryUri; 1670 mResolver = context.getContentResolver(); 1671 } 1672 1673 @Override 1674 public Void doInBackground(Void... args) { 1675 // First query for the cookie string from the UI provider 1676 final Cursor cookieCursor = mResolver.query(mAccountCookieQueryUri, 1677 UIProvider.ACCOUNT_COOKIE_PROJECTION, null, null, null); 1678 if (cookieCursor == null) { 1679 return null; 1680 } 1681 1682 try { 1683 if (cookieCursor.moveToFirst()) { 1684 final String cookie = cookieCursor.getString( 1685 cookieCursor.getColumnIndex(UIProvider.AccountCookieColumns.COOKIE)); 1686 1687 if (cookie != null) { 1688 final CookieSyncManager csm = 1689 CookieSyncManager.createInstance(mContext); 1690 CookieManager.getInstance().setCookie(mUri, cookie); 1691 csm.sync(); 1692 } 1693 } 1694 1695 } finally { 1696 cookieCursor.close(); 1697 } 1698 1699 1700 return null; 1701 } 1702 } 1703 1704 @Override 1705 public void onConversationUpdated(Conversation conv) { 1706 final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer 1707 .findViewById(R.id.conversation_header); 1708 mConversation = conv; 1709 if (headerView != null) { 1710 headerView.onConversationUpdated(conv); 1711 } 1712 } 1713 1714 @Override 1715 public void onLayoutChange(View v, int left, int top, int right, 1716 int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { 1717 boolean sizeChanged = mNeedRender 1718 && mConversationContainer.getWidth() != 0; 1719 if (sizeChanged) { 1720 mNeedRender = false; 1721 mConversationContainer.removeOnLayoutChangeListener(this); 1722 renderConversation(getMessageCursor()); 1723 } 1724 } 1725 1726 @Override 1727 public void setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded, int heightBefore) { 1728 mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore); 1729 } 1730 1731 /** 1732 * @return {@code true} because either the Print or Print All menu item is shown in GMail 1733 */ 1734 @Override 1735 protected boolean shouldShowPrintInOverflow() { 1736 return true; 1737 } 1738 1739 @Override 1740 protected void printConversation() { 1741 PrintUtils.printConversation(mActivity.getActivityContext(), getMessageCursor(), 1742 mAddressCache, mConversation.getBaseUri(mBaseUri), true /* useJavascript */); 1743 } 1744} 1745