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