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