ConversationViewFragment.java revision f0ea4849bf7a2c11f99ca0b42307ae8ba665b1dc
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.TextView; 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 View 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 mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages); 275 276 mSideMarginPx = getResources().getDimensionPixelOffset( 277 R.dimen.conversation_view_margin_side) + getResources().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 = 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), 320, 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 final TextView descriptionView = (TextView) mNewMessageBar.findViewById( 986 R.id.new_message_description); 987 descriptionView.setText(info.getNotificationText()); 988 mNewMessageBar.setVisibility(View.VISIBLE); 989 } 990 991 private void onNewMessageBarClick() { 992 mNewMessageBar.setVisibility(View.GONE); 993 994 renderConversation(getMessageCursor()); // mCursor is already up-to-date 995 // per onLoadFinished() 996 } 997 998 private static OverlayPosition[] parsePositions(final String[] topArray, 999 final String[] bottomArray) { 1000 final int len = topArray.length; 1001 final OverlayPosition[] positions = new OverlayPosition[len]; 1002 for (int i = 0; i < len; i++) { 1003 positions[i] = new OverlayPosition( 1004 Integer.parseInt(topArray[i]), Integer.parseInt(bottomArray[i])); 1005 } 1006 return positions; 1007 } 1008 1009 protected Address getAddress(String rawFrom) { 1010 Address addr; 1011 synchronized (mAddressCache) { 1012 addr = mAddressCache.get(rawFrom); 1013 if (addr == null) { 1014 addr = Address.getEmailAddress(rawFrom); 1015 mAddressCache.put(rawFrom, addr); 1016 } 1017 } 1018 return addr; 1019 } 1020 1021 private void ensureContentSizeChangeListener() { 1022 if (mWebViewSizeChangeListener == null) { 1023 mWebViewSizeChangeListener = new ContentSizeChangeListener() { 1024 @Override 1025 public void onHeightChange(int h) { 1026 // When WebKit says the DOM height has changed, re-measure 1027 // bodies and re-position their headers. 1028 // This is separate from the typical JavaScript DOM change 1029 // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM 1030 // events. 1031 mWebView.loadUrl("javascript:measurePositions();"); 1032 } 1033 }; 1034 } 1035 mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener); 1036 } 1037 1038 public static boolean isOverviewMode(Account acct) { 1039 return acct.settings.isOverviewMode(); 1040 } 1041 1042 private void setupOverviewMode() { 1043 // for now, overview mode means use the built-in WebView zoom and disable custom scale 1044 // gesture handling 1045 final boolean overviewMode = isOverviewMode(mAccount); 1046 final WebSettings settings = mWebView.getSettings(); 1047 settings.setUseWideViewPort(overviewMode); 1048 1049 final OnScaleGestureListener listener; 1050 1051 settings.setSupportZoom(overviewMode); 1052 settings.setBuiltInZoomControls(overviewMode); 1053 if (overviewMode) { 1054 settings.setDisplayZoomControls(false); 1055 } 1056 listener = ENABLE_CSS_ZOOM && !overviewMode ? new CssScaleInterceptor() : null; 1057 1058 mWebView.setOnScaleGestureListener(listener); 1059 } 1060 1061 public class ConversationWebViewClient extends AbstractConversationWebViewClient { 1062 public ConversationWebViewClient(Account account) { 1063 super(account); 1064 } 1065 1066 @Override 1067 public void onPageFinished(WebView view, String url) { 1068 // Ignore unsafe calls made after a fragment is detached from an activity. 1069 // This method needs to, for example, get at the loader manager, which needs 1070 // the fragment to be added. 1071 if (!isAdded() || !mViewsCreated) { 1072 LogUtils.d(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url, 1073 ConversationViewFragment.this); 1074 return; 1075 } 1076 1077 LogUtils.d(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s wv=%s t=%sms", url, 1078 ConversationViewFragment.this, view, 1079 (SystemClock.uptimeMillis() - mWebViewLoadStartMs)); 1080 1081 ensureContentSizeChangeListener(); 1082 1083 if (!mEnableContentReadySignal) { 1084 revealConversation(); 1085 } 1086 1087 final Set<String> emailAddresses = Sets.newHashSet(); 1088 final List<Address> cacheCopy; 1089 synchronized (mAddressCache) { 1090 cacheCopy = ImmutableList.copyOf(mAddressCache.values()); 1091 } 1092 for (Address addr : cacheCopy) { 1093 emailAddresses.add(addr.getAddress()); 1094 } 1095 final ContactLoaderCallbacks callbacks = getContactInfoSource(); 1096 callbacks.setSenders(emailAddresses); 1097 getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks); 1098 } 1099 1100 @Override 1101 public boolean shouldOverrideUrlLoading(WebView view, String url) { 1102 return mViewsCreated && super.shouldOverrideUrlLoading(view, url); 1103 } 1104 } 1105 1106 /** 1107 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 1108 * via reflection and not stripped. 1109 * 1110 */ 1111 private class MailJsBridge { 1112 1113 @SuppressWarnings("unused") 1114 @JavascriptInterface 1115 public void onWebContentGeometryChange(final String[] overlayTopStrs, 1116 final String[] overlayBottomStrs) { 1117 getHandler().post(new FragmentRunnable("onWebContentGeometryChange", 1118 ConversationViewFragment.this) { 1119 1120 @Override 1121 public void go() { 1122 try { 1123 if (!mViewsCreated) { 1124 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" 1125 + " are gone, %s", ConversationViewFragment.this); 1126 return; 1127 } 1128 mConversationContainer.onGeometryChange( 1129 parsePositions(overlayTopStrs, overlayBottomStrs)); 1130 if (mDiff != 0) { 1131 // SCROLL! 1132 int scale = (int) (mWebView.getScale() / mWebView.getInitialScale()); 1133 if (scale > 1) { 1134 mWebView.scrollBy(0, (mDiff * (scale - 1))); 1135 } 1136 mDiff = 0; 1137 } 1138 } catch (Throwable t) { 1139 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange"); 1140 } 1141 } 1142 }); 1143 } 1144 1145 @SuppressWarnings("unused") 1146 @JavascriptInterface 1147 public String getTempMessageBodies() { 1148 try { 1149 if (!mViewsCreated) { 1150 return ""; 1151 } 1152 1153 final String s = mTempBodiesHtml; 1154 mTempBodiesHtml = null; 1155 return s; 1156 } catch (Throwable t) { 1157 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies"); 1158 return ""; 1159 } 1160 } 1161 1162 @SuppressWarnings("unused") 1163 @JavascriptInterface 1164 public String getMessageBody(String domId) { 1165 try { 1166 final MessageCursor cursor = getMessageCursor(); 1167 if (!mViewsCreated || cursor == null) { 1168 return ""; 1169 } 1170 1171 int pos = -1; 1172 while (cursor.moveToPosition(++pos)) { 1173 final ConversationMessage msg = cursor.getMessage(); 1174 if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) { 1175 return msg.getBodyAsHtml(); 1176 } 1177 } 1178 1179 return ""; 1180 1181 } catch (Throwable t) { 1182 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody"); 1183 return ""; 1184 } 1185 } 1186 1187 @SuppressWarnings("unused") 1188 @JavascriptInterface 1189 public String getMessageSender(String domId) { 1190 try { 1191 final MessageCursor cursor = getMessageCursor(); 1192 if (!mViewsCreated || cursor == null) { 1193 return ""; 1194 } 1195 1196 int pos = -1; 1197 while (cursor.moveToPosition(++pos)) { 1198 final ConversationMessage msg = cursor.getMessage(); 1199 if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) { 1200 return getAddress(msg.getFrom()).getAddress(); 1201 } 1202 } 1203 1204 return ""; 1205 1206 } catch (Throwable t) { 1207 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageSender"); 1208 return ""; 1209 } 1210 } 1211 1212 @SuppressWarnings("unused") 1213 @JavascriptInterface 1214 public void onContentReady() { 1215 getHandler().post(new FragmentRunnable("onContentReady", 1216 ConversationViewFragment.this) { 1217 @Override 1218 public void go() { 1219 try { 1220 if (mWebViewLoadStartMs != 0) { 1221 LogUtils.i(LOG_TAG, "IN CVF.onContentReady, f=%s vis=%s t=%sms", 1222 ConversationViewFragment.this, 1223 isUserVisible(), 1224 (SystemClock.uptimeMillis() - mWebViewLoadStartMs)); 1225 } 1226 revealConversation(); 1227 } catch (Throwable t) { 1228 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady"); 1229 // Still try to show the conversation. 1230 revealConversation(); 1231 } 1232 } 1233 }); 1234 } 1235 1236 @SuppressWarnings("unused") 1237 @JavascriptInterface 1238 public float getScrollYPercent() { 1239 try { 1240 return mWebViewYPercent; 1241 } catch (Throwable t) { 1242 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent"); 1243 return 0f; 1244 } 1245 } 1246 1247 @SuppressWarnings("unused") 1248 @JavascriptInterface 1249 public void onMessageTransform(String messageDomId, String transformText) { 1250 try { 1251 LogUtils.i(LOG_TAG, "TRANSFORM: (%s) %s", messageDomId, transformText); 1252 mMessageTransforms.put(messageDomId, transformText); 1253 onConversationTransformed(); 1254 } catch (Throwable t) { 1255 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onMessageTransform"); 1256 return; 1257 } 1258 } 1259 } 1260 1261 private class NewMessagesInfo { 1262 int count; 1263 int countFromSelf; 1264 String senderAddress; 1265 1266 /** 1267 * Return the display text for the new message notification overlay. It will be formatted 1268 * appropriately for a single new message vs. multiple new messages. 1269 * 1270 * @return display text 1271 */ 1272 public String getNotificationText() { 1273 Resources res = getResources(); 1274 if (count > 1) { 1275 return res.getString(R.string.new_incoming_messages_many, count); 1276 } else { 1277 final Address addr = getAddress(senderAddress); 1278 return res.getString(R.string.new_incoming_messages_one, 1279 TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName()); 1280 } 1281 } 1282 } 1283 1284 @Override 1285 public void onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader, 1286 MessageCursor newCursor, MessageCursor oldCursor) { 1287 /* 1288 * what kind of changes affect the MessageCursor? 1. new message(s) 2. 1289 * read/unread state change 3. deleted message, either regular or draft 1290 * 4. updated message, either from self or from others, updated in 1291 * content or state or sender 5. star/unstar of message (technically 1292 * similar to #1) 6. other label change Use MessageCursor.hashCode() to 1293 * sort out interesting vs. no-op cursor updates. 1294 */ 1295 1296 if (oldCursor != null && !oldCursor.isClosed()) { 1297 final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor); 1298 1299 if (info.count > 0) { 1300 // don't immediately render new incoming messages from other 1301 // senders 1302 // (to avoid a new message from losing the user's focus) 1303 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1304 + ", holding cursor for new incoming message (%s)", this); 1305 showNewMessageNotification(info); 1306 return; 1307 } 1308 1309 final int oldState = oldCursor.getStateHashCode(); 1310 final boolean changed = newCursor.getStateHashCode() != oldState; 1311 1312 if (!changed) { 1313 final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor); 1314 if (processedInPlace) { 1315 LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this); 1316 } else { 1317 LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update" 1318 + ", ignoring this conversation update (%s)", this); 1319 } 1320 return; 1321 } else if (info.countFromSelf == 1) { 1322 // Special-case the very common case of a new cursor that is the same as the old 1323 // one, except that there is a new message from yourself. This happens upon send. 1324 final boolean sameExceptNewLast = newCursor.getStateHashCode(1) == oldState; 1325 if (sameExceptNewLast) { 1326 LogUtils.i(LOG_TAG, "CONV RENDER: update is a single new message from self" 1327 + " (%s)", this); 1328 newCursor.moveToLast(); 1329 processNewOutgoingMessage(newCursor.getMessage()); 1330 return; 1331 } 1332 } 1333 // cursors are different, and not due to an incoming message. fall 1334 // through and render. 1335 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1336 + ", but not due to incoming message. rendering. (%s)", this); 1337 1338 if (DEBUG_DUMP_CURSOR_CONTENTS) { 1339 LogUtils.i(LOG_TAG, "old cursor: %s", oldCursor.getDebugDump()); 1340 LogUtils.i(LOG_TAG, "new cursor: %s", newCursor.getDebugDump()); 1341 } 1342 } else { 1343 LogUtils.i(LOG_TAG, "CONV RENDER: initial render. (%s)", this); 1344 timerMark("message cursor load finished"); 1345 } 1346 1347 renderContent(newCursor); 1348 } 1349 1350 protected void renderContent(MessageCursor messageCursor) { 1351 // if layout hasn't happened, delay render 1352 // This is needed in addition to the showConversation() delay to speed 1353 // up rotation and restoration. 1354 if (mConversationContainer.getWidth() == 0) { 1355 mNeedRender = true; 1356 mConversationContainer.addOnLayoutChangeListener(this); 1357 } else { 1358 renderConversation(messageCursor); 1359 } 1360 } 1361 1362 private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) { 1363 final NewMessagesInfo info = new NewMessagesInfo(); 1364 1365 int pos = -1; 1366 while (newCursor.moveToPosition(++pos)) { 1367 final Message m = newCursor.getMessage(); 1368 if (!mViewState.contains(m)) { 1369 LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri); 1370 1371 final Address from = getAddress(m.getFrom()); 1372 // distinguish ours from theirs 1373 // new messages from the account owner should not trigger a 1374 // notification 1375 if (mAccount.ownsFromAddress(from.getAddress())) { 1376 LogUtils.i(LOG_TAG, "found message from self: %s", m.uri); 1377 info.countFromSelf++; 1378 continue; 1379 } 1380 1381 info.count++; 1382 info.senderAddress = m.getFrom(); 1383 } 1384 } 1385 return info; 1386 } 1387 1388 private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) { 1389 final Set<String> idsOfChangedBodies = Sets.newHashSet(); 1390 final List<Integer> changedOverlayPositions = Lists.newArrayList(); 1391 1392 boolean changed = false; 1393 1394 int pos = 0; 1395 while (true) { 1396 if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) { 1397 break; 1398 } 1399 1400 final ConversationMessage newMsg = newCursor.getMessage(); 1401 final ConversationMessage oldMsg = oldCursor.getMessage(); 1402 1403 if (!TextUtils.equals(newMsg.getFrom(), oldMsg.getFrom()) || 1404 newMsg.isSending != oldMsg.isSending) { 1405 mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions); 1406 LogUtils.i(LOG_TAG, "msg #%d (%d): detected from/sending change. isSending=%s", 1407 pos, newMsg.id, newMsg.isSending); 1408 } 1409 1410 // update changed message bodies in-place 1411 if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) || 1412 !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) { 1413 // maybe just set a flag to notify JS to re-request changed bodies 1414 idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"'); 1415 LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id); 1416 } 1417 1418 pos++; 1419 } 1420 1421 1422 if (!changedOverlayPositions.isEmpty()) { 1423 // notify once after the entire adapter is updated 1424 mConversationContainer.onOverlayModelUpdate(changedOverlayPositions); 1425 changed = true; 1426 } 1427 1428 if (!idsOfChangedBodies.isEmpty()) { 1429 mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);", 1430 TextUtils.join(",", idsOfChangedBodies))); 1431 changed = true; 1432 } 1433 1434 return changed; 1435 } 1436 1437 private void processNewOutgoingMessage(ConversationMessage msg) { 1438 // make the last border no longer be the border 1439 ((BorderItem) mAdapter.getItem(mAdapter.getCount() - 1)).setIsLastBorder(false); 1440 1441 mTemplates.reset(); 1442 // this method will add some items to mAdapter, but we deliberately want to avoid notifying 1443 // adapter listeners (i.e. ConversationContainer) until onWebContentGeometryChange is next 1444 // called, to prevent N+1 headers rendering with N message bodies. 1445 1446 // We can just call previousCollapsed false here since the border 1447 // above the message we're about to render should always show 1448 // (which it also will since the message being render is expanded). 1449 renderMessage(msg, false /* previousCollapsed */, true /* expanded */, 1450 msg.alwaysShowImages, false /* renderBorder */, false /* firstBorder */); 1451 renderBorder(true /* contiguous */, true /* expanded */, 1452 false /* firstBorder */, true /* lastBorder */); 1453 mTempBodiesHtml = mTemplates.emit(); 1454 1455 mViewState.setExpansionState(msg, ExpansionState.EXPANDED); 1456 // FIXME: should the provider set this as initial state? 1457 mViewState.setReadState(msg, false /* read */); 1458 1459 // From now until the updated spacer geometry is returned, the adapter items are mismatched 1460 // with the existing spacers. Do not let them layout. 1461 mConversationContainer.invalidateSpacerGeometry(); 1462 1463 mWebView.loadUrl("javascript:appendMessageHtml();"); 1464 } 1465 1466 private class SetCookieTask extends AsyncTask<Void, Void, Void> { 1467 final String mUri; 1468 final Uri mAccountCookieQueryUri; 1469 final ContentResolver mResolver; 1470 1471 SetCookieTask(Context context, Uri baseUri, Uri accountCookieQueryUri) { 1472 mUri = baseUri.toString(); 1473 mAccountCookieQueryUri = accountCookieQueryUri; 1474 mResolver = context.getContentResolver(); 1475 } 1476 1477 @Override 1478 public Void doInBackground(Void... args) { 1479 // First query for the coookie string from the UI provider 1480 final Cursor cookieCursor = mResolver.query(mAccountCookieQueryUri, 1481 UIProvider.ACCOUNT_COOKIE_PROJECTION, null, null, null); 1482 if (cookieCursor == null) { 1483 return null; 1484 } 1485 1486 try { 1487 if (cookieCursor.moveToFirst()) { 1488 final String cookie = cookieCursor.getString( 1489 cookieCursor.getColumnIndex(UIProvider.AccountCookieColumns.COOKIE)); 1490 1491 if (cookie != null) { 1492 final CookieSyncManager csm = 1493 CookieSyncManager.createInstance(getContext()); 1494 CookieManager.getInstance().setCookie(mUri, cookie); 1495 csm.sync(); 1496 } 1497 } 1498 1499 } finally { 1500 cookieCursor.close(); 1501 } 1502 1503 1504 return null; 1505 } 1506 } 1507 1508 @Override 1509 public void onConversationUpdated(Conversation conv) { 1510 final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer 1511 .findViewById(R.id.conversation_header); 1512 mConversation = conv; 1513 if (headerView != null) { 1514 headerView.onConversationUpdated(conv); 1515 headerView.setSubject(conv.subject); 1516 } 1517 } 1518 1519 @Override 1520 public void onLayoutChange(View v, int left, int top, int right, 1521 int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { 1522 boolean sizeChanged = mNeedRender 1523 && mConversationContainer.getWidth() != 0; 1524 if (sizeChanged) { 1525 mNeedRender = false; 1526 mConversationContainer.removeOnLayoutChangeListener(this); 1527 renderConversation(getMessageCursor()); 1528 } 1529 } 1530 1531 @Override 1532 public void setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded, 1533 int heightBefore) { 1534 mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore); 1535 } 1536 1537 private class CssScaleInterceptor implements OnScaleGestureListener { 1538 1539 private float getFocusXWebPx(ScaleGestureDetector detector) { 1540 return (detector.getFocusX() - mSideMarginPx) / mWebView.getInitialScale(); 1541 } 1542 1543 private float getFocusYWebPx(ScaleGestureDetector detector) { 1544 return detector.getFocusY() / mWebView.getInitialScale(); 1545 } 1546 1547 @Override 1548 public boolean onScale(ScaleGestureDetector detector) { 1549 mWebView.loadUrl(String.format("javascript:onScale(%s, %s, %s);", 1550 detector.getScaleFactor(), getFocusXWebPx(detector), 1551 getFocusYWebPx(detector))); 1552 return false; 1553 } 1554 1555 @Override 1556 public boolean onScaleBegin(ScaleGestureDetector detector) { 1557 mWebView.loadUrl(String.format("javascript:onScaleBegin(%s, %s);", 1558 getFocusXWebPx(detector), getFocusYWebPx(detector))); 1559 return true; 1560 } 1561 1562 @Override 1563 public void onScaleEnd(ScaleGestureDetector detector) { 1564 mWebView.loadUrl(String.format("javascript:onScaleEnd(%s, %s);", 1565 getFocusXWebPx(detector), getFocusYWebPx(detector))); 1566 } 1567 1568 } 1569} 1570