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