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