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.providers.Account; 67import com.android.mail.providers.Address; 68import com.android.mail.providers.Conversation; 69import com.android.mail.providers.Message; 70import com.android.mail.providers.UIProvider; 71import com.android.mail.ui.ConversationViewState.ExpansionState; 72import com.android.mail.utils.ConversationViewUtils; 73import com.android.mail.utils.LogTag; 74import com.android.mail.utils.LogUtils; 75import com.android.mail.utils.Utils; 76import com.google.common.collect.ImmutableList; 77import com.google.common.collect.Lists; 78import com.google.common.collect.Maps; 79import com.google.common.collect.Sets; 80 81import java.util.ArrayList; 82import java.util.List; 83import java.util.Map; 84import java.util.Set; 85 86/** 87 * The conversation view UI component. 88 */ 89public 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 */ 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 */ 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 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 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 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 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 328 protected ConversationWebViewClient createConversationWebViewClient() { 329 return new ConversationWebViewClient(mAccount); 330 } 331 332 @Override 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 398 public void onResume() { 399 super.onResume(); 400 if (mWebView != null) { 401 mWebView.onResume(); 402 } 403 } 404 405 @Override 406 public void onPause() { 407 super.onPause(); 408 if (mWebView != null) { 409 mWebView.onPause(); 410 } 411 } 412 413 @Override 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 423 public void onSaveInstanceState(Bundle outState) { 424 super.onSaveInstanceState(outState); 425 426 outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent()); 427 } 428 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 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 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 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 */ 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 541 private void handleDelayedConversationLoad() { 542 resetLoadWaiting(); 543 startConversationLoad(); 544 } 545 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 */ 559 protected void loadContent() { 560 getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks()); 561 } 562 563 private void revealConversation() { 564 timerMark("revealing conversation"); 565 mProgressController.dismissLoadingStatus(mOnProgressDismiss); 566 } 567 568 private boolean isLoadWaiting() { 569 return mLoadWaitReason != LOAD_NOW; 570 } 571 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 */ 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 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 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 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 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 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 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 */ 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 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 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 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 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 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 955 public boolean supportsMessageTransforms() { 956 return true; 957 } 958 959 @Override 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 968 public void showUntransformedConversation() { 969 super.showUntransformedConversation(); 970 renderConversation(getMessageCursor()); 971 } 972 973 @Override 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 984 private void showNewMessageNotification(NewMessagesInfo info) { 985 mNewMessageBar.setText(info.getNotificationText()); 986 mNewMessageBar.setVisibility(View.VISIBLE); 987 } 988 989 private void onNewMessageBarClick() { 990 mNewMessageBar.setVisibility(View.GONE); 991 992 renderConversation(getMessageCursor()); // mCursor is already up-to-date 993 // per onLoadFinished() 994 } 995 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 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 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 1036 public static boolean isOverviewMode(Account acct) { 1037 return acct.settings.isOverviewMode(); 1038 } 1039 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 { 1060 public ConversationWebViewClient(Account account) { 1061 super(account); 1062 } 1063 1064 @Override 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 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 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 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 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 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 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 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 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 */ 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 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 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 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 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 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 1475 SetCookieTask(Context context, Uri baseUri, Uri accountCookieQueryUri) { 1476 mUri = baseUri.toString(); 1477 mAccountCookieQueryUri = accountCookieQueryUri; 1478 mResolver = context.getContentResolver(); 1479 } 1480 1481 @Override 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 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 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 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 1543 private float getFocusXWebPx(ScaleGestureDetector detector) { 1544 return (detector.getFocusX() - mSideMarginPx) / mWebView.getInitialScale(); 1545 } 1546 1547 private float getFocusYWebPx(ScaleGestureDetector detector) { 1548 return detector.getFocusY() / mWebView.getInitialScale(); 1549 } 1550 1551 @Override 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 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 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