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