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 18 package com.android.mail.ui; 19 20 import android.content.ContentResolver; 21 import android.content.Context; 22 import android.content.Loader; 23 import android.content.res.Resources; 24 import android.database.Cursor; 25 import android.database.DataSetObserver; 26 import android.graphics.Rect; 27 import android.net.Uri; 28 import android.os.AsyncTask; 29 import android.os.Bundle; 30 import android.os.SystemClock; 31 import android.support.v4.text.BidiFormatter; 32 import android.support.v4.util.ArrayMap; 33 import android.text.TextUtils; 34 import android.view.KeyEvent; 35 import android.view.LayoutInflater; 36 import android.view.View; 37 import android.view.View.OnLayoutChangeListener; 38 import android.view.ViewGroup; 39 import android.webkit.ConsoleMessage; 40 import android.webkit.CookieManager; 41 import android.webkit.CookieSyncManager; 42 import android.webkit.JavascriptInterface; 43 import android.webkit.WebChromeClient; 44 import android.webkit.WebSettings; 45 import android.webkit.WebView; 46 import android.widget.Button; 47 48 import com.android.emailcommon.mail.Address; 49 import com.android.mail.FormattedDateBuilder; 50 import com.android.mail.R; 51 import com.android.mail.analytics.Analytics; 52 import com.android.mail.analytics.AnalyticsTimer; 53 import com.android.mail.browse.ConversationContainer; 54 import com.android.mail.browse.ConversationContainer.OverlayPosition; 55 import com.android.mail.browse.ConversationFooterView.ConversationFooterCallbacks; 56 import com.android.mail.browse.ConversationMessage; 57 import com.android.mail.browse.ConversationOverlayItem; 58 import com.android.mail.browse.ConversationViewAdapter; 59 import com.android.mail.browse.ConversationViewAdapter.ConversationFooterItem; 60 import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem; 61 import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem; 62 import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem; 63 import com.android.mail.browse.ConversationViewHeader; 64 import com.android.mail.browse.ConversationWebView; 65 import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreator; 66 import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreatorHolder; 67 import com.android.mail.browse.MailWebView.ContentSizeChangeListener; 68 import com.android.mail.browse.MessageCursor; 69 import com.android.mail.browse.MessageFooterView; 70 import com.android.mail.browse.MessageHeaderView; 71 import com.android.mail.browse.ScrollIndicatorsView; 72 import com.android.mail.browse.SuperCollapsedBlock; 73 import com.android.mail.browse.WebViewContextMenu; 74 import com.android.mail.content.ObjectCursor; 75 import com.android.mail.print.PrintUtils; 76 import com.android.mail.providers.Account; 77 import com.android.mail.providers.Conversation; 78 import com.android.mail.providers.Message; 79 import com.android.mail.providers.Settings; 80 import com.android.mail.providers.UIProvider; 81 import com.android.mail.ui.ConversationViewState.ExpansionState; 82 import com.android.mail.utils.ConversationViewUtils; 83 import com.android.mail.utils.LogTag; 84 import com.android.mail.utils.LogUtils; 85 import com.android.mail.utils.Utils; 86 import com.google.common.collect.ImmutableList; 87 import com.google.common.collect.Lists; 88 import com.google.common.collect.Maps; 89 import com.google.common.collect.Sets; 90 91 import java.util.ArrayList; 92 import java.util.List; 93 import java.util.Map; 94 import java.util.Set; 95 96 /** 97 * The conversation view UI component. 98 */ 99 public 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 */ ConversationViewFragment()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 */ newInstance(Bundle existingArgs, Conversation conversation)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 onAccountChanged(Account newAccount, Account oldAccount)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 onActivityCreated(Bundle savedInstanceState)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 onCreate(Bundle savedState)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 createConversationWebViewClient()368 protected ConversationWebViewClient createConversationWebViewClient() { 369 return new ConversationWebViewClient(mAccount); 370 } 371 372 @Override onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)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 inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater)445 protected void inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater) { 446 inflater.inflate(R.layout.conversation_topmost_overlay_items, topmostOverlay, true); 447 } 448 setupNewMessageBar()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 onResume()461 public void onResume() { 462 super.onResume(); 463 if (mWebView != null) { 464 mWebView.onResume(); 465 } 466 } 467 468 @Override onPause()469 public void onPause() { 470 super.onPause(); 471 if (mWebView != null) { 472 mWebView.onPause(); 473 } 474 } 475 476 @Override onDestroyView()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 onSaveInstanceState(Bundle outState)486 public void onSaveInstanceState(Bundle outState) { 487 super.onSaveInstanceState(outState); 488 489 outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent()); 490 } 491 calculateScrollYPercent()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 resetLoadWaiting()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 markUnread()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 onUserVisibleHintChanged()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 */ showConversation()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 handleDelayedConversationLoad()627 private void handleDelayedConversationLoad() { 628 resetLoadWaiting(); 629 startConversationLoad(); 630 } 631 startConversationLoad()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 */ loadContent()645 protected void loadContent() { 646 getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks()); 647 } 648 revealConversation()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 isLoadWaiting()658 private boolean isLoadWaiting() { 659 return mLoadWaitReason != LOAD_NOW; 660 } 661 renderConversation(MessageCursor messageCursor)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 getSdCardFilePath()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 */ renderMessageBodies(MessageCursor messageCursor, boolean enableContentReadySignal)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 getLastMessageHeaderItem()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 renderSuperCollapsedBlock(int start, int end, boolean hasDraft)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 renderMessage(ConversationMessage msg, boolean expanded, boolean safeForImages)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 renderCollapsedHeaders(MessageCursor cursor, SuperCollapsedBlockItem blockToReplace)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 measureOverlayHeight(int position)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 */ measureOverlayHeight(ConversationOverlayItem convItem)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 onConversationViewHeaderHeightChange(int newHeight)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 onConversationFooterHeightChange(int newHeight)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 setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx)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 setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx)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 showExternalResources(final Message msg)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 showExternalResources(final String senderRawAddress)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 supportsMessageTransforms()1034 public boolean supportsMessageTransforms() { 1035 return true; 1036 } 1037 1038 @Override getMessageTransforms(final Message msg)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 isSecure()1045 public boolean isSecure() { 1046 return false; 1047 } 1048 1049 // END message header callbacks 1050 1051 @Override showUntransformedConversation()1052 public void showUntransformedConversation() { 1053 super.showUntransformedConversation(); 1054 renderConversation(getMessageCursor()); 1055 } 1056 1057 @Override onSuperCollapsedClick(SuperCollapsedBlockItem item)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 showNewMessageNotification(NewMessagesInfo info)1069 private void showNewMessageNotification(NewMessagesInfo info) { 1070 mNewMessageBar.setText(info.getNotificationText()); 1071 mNewMessageBar.setVisibility(View.VISIBLE); 1072 } 1073 onNewMessageBarClick()1074 private void onNewMessageBarClick() { 1075 mNewMessageBar.setVisibility(View.GONE); 1076 1077 renderConversation(getMessageCursor()); // mCursor is already up-to-date 1078 // per onLoadFinished() 1079 } 1080 parsePositions(final int[] topArray, final int[] bottomArray)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 getAddress(String rawFrom)1090 protected Address getAddress(String rawFrom) { 1091 return Utils.getAddress(mAddressCache, rawFrom); 1092 } 1093 ensureContentSizeChangeListener()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 isOverviewMode(Account acct)1111 public static boolean isOverviewMode(Account acct) { 1112 return acct.settings.isOverviewMode(); 1113 } 1114 setupOverviewMode()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 getMessageForClickedUrl(String url)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 onKey(View view, int keyCode, KeyEvent keyEvent)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 { ConversationWebViewClient(Account account)1232 public ConversationWebViewClient(Account account) { 1233 super(account); 1234 } 1235 1236 @Override onPageFinished(WebView view, String url)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 shouldOverrideUrlLoading(WebView view, String url)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 onWebContentGeometryChange(final int[] overlayTopStrs, final int[] overlayBottomStrs)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 getTempMessageBodies()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 getMessageBody(String domId)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 getMessageSender(String domId)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 onContentReady()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 getScrollYPercent()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 onMessageTransform(String messageDomId, String transformText)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 onInlineAttachmentsParsed(final String[] urls, final String[] messageIds)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 */ getNotificationText()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 onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader, MessageCursor newCursor, MessageCursor oldCursor)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 renderContent(MessageCursor messageCursor)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 getNewIncomingMessagesInfo(MessageCursor newCursor)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 processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor)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 processNewOutgoingMessage(ConversationMessage msg)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 SetCookieTask(Context context, String baseUri, Uri accountCookieQueryUri)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 doInBackground(Void... args)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 onConversationUpdated(Conversation conv)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 onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)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 setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded, int heightBefore)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 shouldShowPrintInOverflow()1735 protected boolean shouldShowPrintInOverflow() { 1736 return true; 1737 } 1738 1739 @Override printConversation()1740 protected void printConversation() { 1741 PrintUtils.printConversation(mActivity.getActivityContext(), getMessageCursor(), 1742 mAddressCache, mConversation.getBaseUri(mBaseUri), true /* useJavascript */); 1743 } 1744 } 1745