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