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