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