ConversationViewFragment.java revision 9d3fd92ed6091dbd0d38799222a1cf841f1c3f29
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.ui; 19 20 21import android.content.Context; 22import android.content.Loader; 23import android.content.res.Resources; 24import android.database.Cursor; 25import android.database.DataSetObserver; 26import android.os.AsyncTask; 27import android.os.Bundle; 28import android.os.SystemClock; 29import android.text.TextUtils; 30import android.view.LayoutInflater; 31import android.view.View; 32import android.view.View.OnLayoutChangeListener; 33import android.view.ViewGroup; 34import android.webkit.ConsoleMessage; 35import android.webkit.CookieManager; 36import android.webkit.CookieSyncManager; 37import android.webkit.JavascriptInterface; 38import android.webkit.WebChromeClient; 39import android.webkit.WebSettings; 40import android.webkit.WebView; 41import android.webkit.WebViewClient; 42import android.widget.TextView; 43 44import com.android.mail.FormattedDateBuilder; 45import com.android.mail.R; 46import com.android.mail.browse.ConversationContainer; 47import com.android.mail.browse.ConversationOverlayItem; 48import com.android.mail.browse.ConversationViewAdapter; 49import com.android.mail.browse.ScrollIndicatorsView; 50import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController; 51import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem; 52import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem; 53import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem; 54import com.android.mail.browse.ConversationViewHeader; 55import com.android.mail.browse.ConversationWebView; 56import com.android.mail.browse.ConversationWebView.ContentSizeChangeListener; 57import com.android.mail.browse.MessageCursor; 58import com.android.mail.browse.MessageCursor.ConversationController; 59import com.android.mail.browse.MessageCursor.ConversationMessage; 60import com.android.mail.browse.MessageHeaderView; 61import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; 62import com.android.mail.browse.SuperCollapsedBlock; 63import com.android.mail.browse.WebViewContextMenu; 64import com.android.mail.providers.Account; 65import com.android.mail.providers.Address; 66import com.android.mail.providers.Conversation; 67import com.android.mail.providers.Message; 68import com.android.mail.ui.ConversationViewState.ExpansionState; 69import com.android.mail.utils.LogTag; 70import com.android.mail.utils.LogUtils; 71import com.android.mail.utils.Utils; 72import com.google.common.collect.Lists; 73import com.google.common.collect.Sets; 74 75import java.util.List; 76import java.util.Set; 77 78 79/** 80 * The conversation view UI component. 81 */ 82public final class ConversationViewFragment extends AbstractConversationViewFragment implements 83 MessageHeaderViewCallbacks, 84 SuperCollapsedBlock.OnClickListener, 85 ConversationController, 86 ConversationAccountController, 87 OnLayoutChangeListener { 88 89 private static final String LOG_TAG = LogTag.getLogTag(); 90 public static final String LAYOUT_TAG = "ConvLayout"; 91 92 /** 93 * Default value for {@link #mLoadWaitReason}. Conversation load will happen immediately. 94 */ 95 private final int LOAD_NOW = 0; 96 /** 97 * Value for {@link #mLoadWaitReason} that means we are offscreen and waiting for the visible 98 * conversation to finish loading before beginning our load. 99 * <p> 100 * When this value is set, the fragment should register with {@link ConversationListCallbacks} 101 * to know when the visible conversation is loaded. When it is unset, it should unregister. 102 */ 103 private final int LOAD_WAIT_FOR_INITIAL_CONVERSATION = 1; 104 /** 105 * Value for {@link #mLoadWaitReason} used when a conversation is too heavyweight to load at 106 * all when not visible (e.g. requires network fetch, or too complex). Conversation load will 107 * wait until this fragment is visible. 108 */ 109 private final int LOAD_WAIT_UNTIL_VISIBLE = 2; 110 111 private ConversationContainer mConversationContainer; 112 113 private ConversationWebView mWebView; 114 115 private ScrollIndicatorsView mScrollIndicators; 116 117 private View mNewMessageBar; 118 119 private HtmlConversationTemplates mTemplates; 120 121 private final MailJsBridge mJsBridge = new MailJsBridge(); 122 123 private final WebViewClient mWebViewClient = new ConversationWebViewClient(); 124 125 private ConversationViewAdapter mAdapter; 126 127 private boolean mViewsCreated; 128 // True if we attempted to render before the views were laid out 129 // We will render immediately once layout is done 130 private boolean mNeedRender; 131 132 /** 133 * Temporary string containing the message bodies of the messages within a super-collapsed 134 * block, for one-time use during block expansion. We cannot easily pass the body HTML 135 * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it 136 * using {@link MailJsBridge}. 137 */ 138 private String mTempBodiesHtml; 139 140 private int mMaxAutoLoadMessages; 141 142 /** 143 * If this conversation fragment is not visible, and it's inappropriate to load up front, 144 * this is the reason we are waiting. This flag should be cleared once it's okay to load 145 * the conversation. 146 */ 147 private int mLoadWaitReason = LOAD_NOW; 148 149 private boolean mEnableContentReadySignal; 150 151 private ContentSizeChangeListener mWebViewSizeChangeListener; 152 153 private final DataSetObserver mLoadedObserver = new DataSetObserver() { 154 @Override 155 public void onChanged() { 156 getHandler().post(new FragmentRunnable("delayedConversationLoad") { 157 @Override 158 public void go() { 159 LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s", 160 ConversationViewFragment.this); 161 handleDelayedConversationLoad(); 162 } 163 }); 164 } 165 }; 166 167 private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false; 168 private static final boolean DISABLE_OFFSCREEN_LOADING = false; 169 protected static final String AUTO_LOAD_KEY = "auto-load"; 170 171 /** 172 * Constructor needs to be public to handle orientation changes and activity lifecycle events. 173 */ 174 public ConversationViewFragment() { 175 super(); 176 } 177 178 /** 179 * Creates a new instance of {@link ConversationViewFragment}, initialized 180 * to display a conversation with other parameters inherited/copied from an existing bundle, 181 * typically one created using {@link #makeBasicArgs}. 182 */ 183 public static ConversationViewFragment newInstance(Bundle existingArgs, 184 Conversation conversation) { 185 ConversationViewFragment f = new ConversationViewFragment(); 186 Bundle args = new Bundle(existingArgs); 187 args.putParcelable(ARG_CONVERSATION, conversation); 188 f.setArguments(args); 189 return f; 190 } 191 192 @Override 193 public void onAccountChanged() { 194 // settings may have been updated; refresh views that are known to 195 // depend on settings 196 mConversationContainer.getSnapHeader().onAccountChanged(); 197 mAdapter.notifyDataSetChanged(); 198 } 199 200 @Override 201 public void onActivityCreated(Bundle savedInstanceState) { 202 LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s visible=%s", this, isUserVisible()); 203 super.onActivityCreated(savedInstanceState); 204 Context context = getContext(); 205 mTemplates = new HtmlConversationTemplates(context); 206 207 final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(context); 208 209 mAdapter = new ConversationViewAdapter(mActivity, this, 210 getLoaderManager(), this, getContactInfoSource(), this, 211 this, mAddressCache, dateBuilder); 212 mConversationContainer.setOverlayAdapter(mAdapter); 213 214 // set up snap header (the adapter usually does this with the other ones) 215 final MessageHeaderView snapHeader = mConversationContainer.getSnapHeader(); 216 snapHeader.initialize(dateBuilder, this, mAddressCache); 217 snapHeader.setCallbacks(this); 218 snapHeader.setContactInfoSource(getContactInfoSource()); 219 220 mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages); 221 222 mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(getActivity())); 223 224 // Defer the call to initLoader with a Handler. 225 // We want to wait until we know which fragments are present and their final visibility 226 // states before going off and doing work. This prevents extraneous loading from occurring 227 // as the ViewPager shifts about before the initial position is set. 228 // 229 // e.g. click on item #10 230 // ViewPager.setAdapter() actually first loads #0 and #1 under the assumption that #0 is 231 // the initial primary item 232 // Then CPC immediately sets the primary item to #10, which tears down #0/#1 and sets up 233 // #9/#10/#11. 234 getHandler().post(new FragmentRunnable("showConversation") { 235 @Override 236 public void go() { 237 showConversation(); 238 } 239 }); 240 241 if (mConversation.conversationBaseUri != null && 242 !TextUtils.isEmpty(mConversation.conversationCookie)) { 243 // Set the cookie for this base url 244 new SetCookieTask(mConversation.conversationBaseUri.toString(), 245 mConversation.conversationCookie).execute(); 246 } 247 } 248 249 @Override 250 public View onCreateView(LayoutInflater inflater, 251 ViewGroup container, Bundle savedInstanceState) { 252 253 View rootView = inflater.inflate(R.layout.conversation_view, container, false); 254 mConversationContainer = (ConversationContainer) rootView 255 .findViewById(R.id.conversation_container); 256 257 mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar); 258 mNewMessageBar.setOnClickListener(new View.OnClickListener() { 259 @Override 260 public void onClick(View v) { 261 onNewMessageBarClick(); 262 } 263 }); 264 265 instantiateProgressIndicators(rootView); 266 267 mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview); 268 269 mWebView.addJavascriptInterface(mJsBridge, "mail"); 270 // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete 271 // Below JB, try to speed up initial render by having the webview do supplemental draws to 272 // custom a software canvas. 273 // TODO(mindyp): 274 //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER 275 // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op 276 // animation that immediately runs on page load. The app uses this as a signal that the 277 // content is loaded and ready to draw, since WebView delays firing this event until the 278 // layers are composited and everything is ready to draw. 279 // This signal does not seem to be reliable, so just use the old method for now. 280 mEnableContentReadySignal = Utils.isRunningJellybeanOrLater(); 281 mWebView.setUseSoftwareLayer(!mEnableContentReadySignal); 282 mWebView.setWebViewClient(mWebViewClient); 283 mWebView.setWebChromeClient(new WebChromeClient() { 284 @Override 285 public boolean onConsoleMessage(ConsoleMessage consoleMessage) { 286 LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(), 287 consoleMessage.sourceId(), consoleMessage.lineNumber()); 288 return true; 289 } 290 }); 291 292 final WebSettings settings = mWebView.getSettings(); 293 294 mScrollIndicators = (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators); 295 mScrollIndicators.setSourceView(mWebView); 296 297 settings.setJavaScriptEnabled(true); 298 settings.setUseWideViewPort(true); 299 settings.setLoadWithOverviewMode(true); 300 301 settings.setSupportZoom(true); 302 settings.setBuiltInZoomControls(true); 303 settings.setDisplayZoomControls(false); 304 305 final float fontScale = getResources().getConfiguration().fontScale; 306 final int desiredFontSizePx = getResources() 307 .getInteger(R.integer.conversation_desired_font_size_px); 308 final int unstyledFontSizePx = getResources() 309 .getInteger(R.integer.conversation_unstyled_font_size_px); 310 311 int textZoom = settings.getTextZoom(); 312 // apply a correction to the default body text style to get regular text to the size we want 313 textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx; 314 // then apply any system font scaling 315 textZoom = (int) (textZoom * fontScale); 316 settings.setTextZoom(textZoom); 317 318 mViewsCreated = true; 319 320 return rootView; 321 } 322 323 @Override 324 public void onResume() { 325 super.onResume(); 326 327 // Hacky workaround for http://b/6946182 328 Utils.fixSubTreeLayoutIfOrphaned(getView(), "ConversationViewFragment"); 329 } 330 331 @Override 332 public void onDestroyView() { 333 super.onDestroyView(); 334 mConversationContainer.setOverlayAdapter(null); 335 mAdapter = null; 336 resetLoadWaiting(); // be sure to unregister any active load observer 337 mViewsCreated = false; 338 } 339 340 private void resetLoadWaiting() { 341 if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) { 342 getListController().unregisterConversationLoadedObserver(mLoadedObserver); 343 } 344 mLoadWaitReason = LOAD_NOW; 345 } 346 347 @Override 348 protected void markUnread() { 349 // Ignore unsafe calls made after a fragment is detached from an activity 350 final ControllableActivity activity = (ControllableActivity) getActivity(); 351 if (activity == null) { 352 LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id); 353 return; 354 } 355 356 if (mViewState == null) { 357 LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)", 358 mConversation.id); 359 return; 360 } 361 activity.getConversationUpdater().markConversationMessagesUnread(mConversation, 362 mViewState.getUnreadMessageUris(), mViewState.getConversationInfo()); 363 } 364 365 @Override 366 public void onUserVisibleHintChanged() { 367 final boolean userVisible = isUserVisible(); 368 369 if (!userVisible) { 370 dismissLoadingStatus(); 371 } else if (mViewsCreated) { 372 if (getMessageCursor() != null) { 373 LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this); 374 onConversationSeen(); 375 } else if (isLoadWaiting()) { 376 LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this); 377 handleDelayedConversationLoad(); 378 } 379 } 380 } 381 382 /** 383 * Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do 384 * nothing (in which case you should later call {@link #handleDelayedConversationLoad()}). 385 */ 386 private void showConversation() { 387 final int reason; 388 389 if (isUserVisible()) { 390 LogUtils.i(LOG_TAG, 391 "SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this); 392 reason = LOAD_NOW; 393 } else { 394 final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING 395 || (mConversation.isRemote 396 || mConversation.getNumMessages() > mMaxAutoLoadMessages); 397 398 // When not visible, we should not immediately load if either this conversation is 399 // too heavyweight, or if the main/initial conversation is busy loading. 400 if (disableOffscreenLoading) { 401 reason = LOAD_WAIT_UNTIL_VISIBLE; 402 LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this); 403 } else if (getListController().isInitialConversationLoading()) { 404 reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION; 405 LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this); 406 getListController().registerConversationLoadedObserver(mLoadedObserver); 407 } else { 408 LogUtils.i(LOG_TAG, 409 "SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)", 410 this); 411 reason = LOAD_NOW; 412 } 413 } 414 415 mLoadWaitReason = reason; 416 if (mLoadWaitReason == LOAD_NOW) { 417 startConversationLoad(); 418 } 419 } 420 421 private void handleDelayedConversationLoad() { 422 resetLoadWaiting(); 423 startConversationLoad(); 424 } 425 426 private void startConversationLoad() { 427 mWebView.setVisibility(View.VISIBLE); 428 getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks()); 429 if (isUserVisible()) { 430 final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger(); 431 if (sdc != null) { 432 sdc.setSubject(mConversation.subject); 433 } 434 } 435 // TODO(mindyp): don't show loading status for a previously rendered 436 // conversation. Ielieve this is better done by making sure don't show loading status 437 // until XX ms have passed without loading completed. 438 showLoadingStatus(); 439 } 440 441 private boolean isLoadWaiting() { 442 return mLoadWaitReason != LOAD_NOW; 443 } 444 445 private void renderConversation(MessageCursor messageCursor) { 446 final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal); 447 448 if (DEBUG_DUMP_CONVERSATION_HTML) { 449 java.io.FileWriter fw = null; 450 try { 451 fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id 452 + ".html"); 453 fw.write(convHtml); 454 } catch (java.io.IOException e) { 455 e.printStackTrace(); 456 } finally { 457 if (fw != null) { 458 try { 459 fw.close(); 460 } catch (java.io.IOException e) { 461 e.printStackTrace(); 462 } 463 } 464 } 465 } 466 467 mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null); 468 } 469 470 /** 471 * Populate the adapter with overlay views (message headers, super-collapsed blocks, a 472 * conversation header), and return an HTML document with spacer divs inserted for all overlays. 473 * 474 */ 475 private String renderMessageBodies(MessageCursor messageCursor, 476 boolean enableContentReadySignal) { 477 int pos = -1; 478 479 LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this); 480 boolean allowNetworkImages = false; 481 482 // TODO: re-use any existing adapter item state (expanded, details expanded, show pics) 483 484 // Walk through the cursor and build up an overlay adapter as you go. 485 // Each overlay has an entry in the adapter for easy scroll handling in the container. 486 // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks. 487 // When adding adapter items, also add their heights to help the container later determine 488 // overlay dimensions. 489 490 // When re-rendering, prevent ConversationContainer from laying out overlays until after 491 // the new spacers are positioned by WebView. 492 mConversationContainer.invalidateSpacerGeometry(); 493 494 mAdapter.clear(); 495 496 // re-evaluate the message parts of the view state, since the messages may have changed 497 // since the previous render 498 final ConversationViewState prevState = mViewState; 499 mViewState = new ConversationViewState(prevState); 500 501 // N.B. the units of height for spacers are actually dp and not px because WebView assumes 502 // a pixel is an mdpi pixel, unless you set device-dpi. 503 504 // add a single conversation header item 505 final int convHeaderPos = mAdapter.addConversationHeader(mConversation); 506 final int convHeaderPx = measureOverlayHeight(convHeaderPos); 507 508 final int sideMarginPx = getResources().getDimensionPixelOffset( 509 R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset( 510 R.dimen.conversation_message_content_margin_side); 511 512 mTemplates.startConversation(mWebView.screenPxToWebPx(sideMarginPx), 513 mWebView.screenPxToWebPx(convHeaderPx)); 514 515 int collapsedStart = -1; 516 ConversationMessage prevCollapsedMsg = null; 517 boolean prevSafeForImages = false; 518 519 while (messageCursor.moveToPosition(++pos)) { 520 final ConversationMessage msg = messageCursor.getMessage(); 521 522 // TODO: save/restore 'show pics' state 523 final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */; 524 allowNetworkImages |= safeForImages; 525 526 final Integer savedExpanded = prevState.getExpansionState(msg); 527 final int expandedState; 528 if (savedExpanded != null) { 529 if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) { 530 // override saved state when this is now the new last message 531 // this happens to the second-to-last message when you discard a draft 532 expandedState = ExpansionState.EXPANDED; 533 } else { 534 expandedState = savedExpanded; 535 } 536 } else { 537 // new messages that are not expanded default to being eligible for super-collapse 538 expandedState = (!msg.read || msg.starred || messageCursor.isLast()) ? 539 ExpansionState.EXPANDED : ExpansionState.SUPER_COLLAPSED; 540 } 541 mViewState.setExpansionState(msg, expandedState); 542 543 // save off "read" state from the cursor 544 // later, the view may not match the cursor (e.g. conversation marked read on open) 545 // however, if a previous state indicated this message was unread, trust that instead 546 // so "mark unread" marks all originally unread messages 547 mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg)); 548 549 // We only want to consider this for inclusion in the super collapsed block if 550 // 1) The we don't have previous state about this message (The first time that the 551 // user opens a conversation) 552 // 2) The previously saved state for this message indicates that this message is 553 // in the super collapsed block. 554 if (ExpansionState.isSuperCollapsed(expandedState)) { 555 // contribute to a super-collapsed block that will be emitted just before the 556 // next expanded header 557 if (collapsedStart < 0) { 558 collapsedStart = pos; 559 } 560 prevCollapsedMsg = msg; 561 prevSafeForImages = safeForImages; 562 continue; 563 } 564 565 // resolve any deferred decisions on previous collapsed items 566 if (collapsedStart >= 0) { 567 if (pos - collapsedStart == 1) { 568 // special-case for a single collapsed message: no need to super-collapse it 569 renderMessage(prevCollapsedMsg, false /* expanded */, 570 prevSafeForImages); 571 } else { 572 renderSuperCollapsedBlock(collapsedStart, pos - 1); 573 } 574 prevCollapsedMsg = null; 575 collapsedStart = -1; 576 } 577 578 renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages); 579 } 580 581 mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages); 582 583 // If the conversation has specified a base uri, use it here, use mBaseUri 584 final String conversationBaseUri = mConversation.conversationBaseUri != null ? 585 mConversation.conversationBaseUri.toString() : mBaseUri; 586 return mTemplates.endConversation(mBaseUri, conversationBaseUri, 320, 587 mWebView.getViewportWidth(), enableContentReadySignal); 588 } 589 590 private void renderSuperCollapsedBlock(int start, int end) { 591 final int blockPos = mAdapter.addSuperCollapsedBlock(start, end); 592 final int blockPx = measureOverlayHeight(blockPos); 593 mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx)); 594 } 595 596 private void renderMessage(ConversationMessage msg, boolean expanded, 597 boolean safeForImages) { 598 final int headerPos = mAdapter.addMessageHeader(msg, expanded); 599 final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos); 600 601 final int footerPos = mAdapter.addMessageFooter(headerItem); 602 603 // Measure item header and footer heights to allocate spacers in HTML 604 // But since the views themselves don't exist yet, render each item temporarily into 605 // a host view for measurement. 606 final int headerPx = measureOverlayHeight(headerPos); 607 final int footerPx = measureOverlayHeight(footerPos); 608 609 mTemplates.appendMessageHtml(msg, expanded, safeForImages, 610 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx)); 611 } 612 613 private String renderCollapsedHeaders(MessageCursor cursor, 614 SuperCollapsedBlockItem blockToReplace) { 615 final List<ConversationOverlayItem> replacements = Lists.newArrayList(); 616 617 mTemplates.reset(); 618 619 // In devices with non-integral density multiplier, screen pixels translate to non-integral 620 // web pixels. Keep track of the error that occurs when we cast all heights to int 621 float error = 0f; 622 for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) { 623 cursor.moveToPosition(i); 624 final ConversationMessage msg = cursor.getMessage(); 625 final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg, 626 false /* expanded */); 627 final MessageFooterItem footer = mAdapter.newMessageFooterItem(header); 628 629 final int headerPx = measureOverlayHeight(header); 630 final int footerPx = measureOverlayHeight(footer); 631 error += mWebView.screenPxToWebPxError(headerPx) 632 + mWebView.screenPxToWebPxError(footerPx); 633 634 // When the error becomes greater than 1 pixel, make the next header 1 pixel taller 635 int correction = 0; 636 if (error >= 1) { 637 correction = 1; 638 error -= 1; 639 } 640 641 mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages, 642 mWebView.screenPxToWebPx(headerPx) + correction, 643 mWebView.screenPxToWebPx(footerPx)); 644 replacements.add(header); 645 replacements.add(footer); 646 647 mViewState.setExpansionState(msg, ExpansionState.COLLAPSED); 648 } 649 650 mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements); 651 652 return mTemplates.emit(); 653 } 654 655 private int measureOverlayHeight(int position) { 656 return measureOverlayHeight(mAdapter.getItem(position)); 657 } 658 659 /** 660 * Measure the height of an adapter view by rendering an adapter item into a temporary 661 * host view, and asking the view to immediately measure itself. This method will reuse 662 * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated 663 * earlier. 664 * <p> 665 * After measuring the height, this method also saves the height in the 666 * {@link ConversationOverlayItem} for later use in overlay positioning. 667 * 668 * @param convItem adapter item with data to render and measure 669 * @return height of the rendered view in screen px 670 */ 671 private int measureOverlayHeight(ConversationOverlayItem convItem) { 672 final int type = convItem.getType(); 673 674 final View convertView = mConversationContainer.getScrapView(type); 675 final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer, 676 true /* measureOnly */); 677 if (convertView == null) { 678 mConversationContainer.addScrapView(type, hostView); 679 } 680 681 final int heightPx = mConversationContainer.measureOverlay(hostView); 682 convItem.setHeight(heightPx); 683 convItem.markMeasurementValid(); 684 685 return heightPx; 686 } 687 688 @Override 689 public void onConversationViewHeaderHeightChange(int newHeight) { 690 final int h = mWebView.screenPxToWebPx(newHeight); 691 692 mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h)); 693 } 694 695 // END conversation header callbacks 696 697 // START message header callbacks 698 @Override 699 public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) { 700 mConversationContainer.invalidateSpacerGeometry(); 701 702 // update message HTML spacer height 703 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 704 LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h, 705 newSpacerHeightPx); 706 mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);", 707 mTemplates.getMessageDomId(item.getMessage()), h)); 708 } 709 710 @Override 711 public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) { 712 mConversationContainer.invalidateSpacerGeometry(); 713 714 // show/hide the HTML message body and update the spacer height 715 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 716 LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)", 717 item.isExpanded(), h, newSpacerHeightPx); 718 mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);", 719 mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h)); 720 721 mViewState.setExpansionState(item.getMessage(), 722 item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED); 723 } 724 725 @Override 726 public void showExternalResources(Message msg) { 727 mWebView.getSettings().setBlockNetworkImage(false); 728 mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');"); 729 } 730 // END message header callbacks 731 732 @Override 733 public void onSuperCollapsedClick(SuperCollapsedBlockItem item) { 734 MessageCursor cursor = getMessageCursor(); 735 if (cursor == null || !mViewsCreated) { 736 return; 737 } 738 739 mTempBodiesHtml = renderCollapsedHeaders(cursor, item); 740 mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")"); 741 } 742 743 private void showNewMessageNotification(NewMessagesInfo info) { 744 final TextView descriptionView = (TextView) mNewMessageBar.findViewById( 745 R.id.new_message_description); 746 descriptionView.setText(info.getNotificationText()); 747 mNewMessageBar.setVisibility(View.VISIBLE); 748 } 749 750 private void onNewMessageBarClick() { 751 mNewMessageBar.setVisibility(View.GONE); 752 753 renderConversation(getMessageCursor()); // mCursor is already up-to-date 754 // per onLoadFinished() 755 } 756 757 private static int[] parseInts(final String[] stringArray) { 758 final int len = stringArray.length; 759 final int[] ints = new int[len]; 760 for (int i = 0; i < len; i++) { 761 ints[i] = Integer.parseInt(stringArray[i]); 762 } 763 return ints; 764 } 765 766 @Override 767 public String toString() { 768 // log extra info at DEBUG level or finer 769 final String s = super.toString(); 770 if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) { 771 return s; 772 } 773 return "(" + s + " subj=" + mConversation.subject + ")"; 774 } 775 776 private Address getAddress(String rawFrom) { 777 Address addr = mAddressCache.get(rawFrom); 778 if (addr == null) { 779 addr = Address.getEmailAddress(rawFrom); 780 mAddressCache.put(rawFrom, addr); 781 } 782 return addr; 783 } 784 785 @Override 786 public Account getAccount() { 787 return mAccount; 788 } 789 790 private void ensureContentSizeChangeListener() { 791 if (mWebViewSizeChangeListener == null) { 792 mWebViewSizeChangeListener = new ConversationWebView.ContentSizeChangeListener() { 793 @Override 794 public void onHeightChange(int h) { 795 // When WebKit says the DOM height has changed, re-measure 796 // bodies and re-position their headers. 797 // This is separate from the typical JavaScript DOM change 798 // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM 799 // events. 800 mWebView.loadUrl("javascript:measurePositions();"); 801 } 802 }; 803 } 804 mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener); 805 } 806 807 private class ConversationWebViewClient extends AbstractConversationWebViewClient { 808 @Override 809 public void onPageFinished(WebView view, String url) { 810 // Ignore unsafe calls made after a fragment is detached from an activity 811 final ControllableActivity activity = (ControllableActivity) getActivity(); 812 if (activity == null || !mViewsCreated) { 813 LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url, 814 ConversationViewFragment.this); 815 return; 816 } 817 818 LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s act=%s", url, 819 ConversationViewFragment.this, getActivity()); 820 821 super.onPageFinished(view, url); 822 823 ensureContentSizeChangeListener(); 824 825 // TODO: save off individual message unread state (here, or in onLoadFinished?) so 826 // 'mark unread' restores the original unread state for each individual message 827 828 if (isUserVisible()) { 829 onConversationSeen(); 830 } 831 if (!mEnableContentReadySignal) { 832 dismissLoadingStatus(); 833 } 834 835 // We are not able to use the loader manager unless this fragment is added to the 836 // activity 837 if (isAdded()) { 838 final Set<String> emailAddresses = Sets.newHashSet(); 839 for (Address addr : mAddressCache.values()) { 840 emailAddresses.add(addr.getAddress()); 841 } 842 ContactLoaderCallbacks callbacks = getContactInfoSource(); 843 getContactInfoSource().setSenders(emailAddresses); 844 getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks); 845 } 846 } 847 848 @Override 849 public boolean shouldOverrideUrlLoading(WebView view, String url) { 850 return mViewsCreated && super.shouldOverrideUrlLoading(view, url); 851 } 852 } 853 854 /** 855 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 856 * via reflection and not stripped. 857 * 858 */ 859 private class MailJsBridge { 860 861 @SuppressWarnings("unused") 862 @JavascriptInterface 863 public void onWebContentGeometryChange(final String[] overlayBottomStrs) { 864 try { 865 getHandler().post(new Runnable() { 866 @Override 867 public void run() { 868 if (!mViewsCreated) { 869 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" + 870 " are gone, %s", ConversationViewFragment.this); 871 return; 872 } 873 874 mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs)); 875 } 876 }); 877 } catch (Throwable t) { 878 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange"); 879 } 880 } 881 882 @SuppressWarnings("unused") 883 @JavascriptInterface 884 public String getTempMessageBodies() { 885 try { 886 if (!mViewsCreated) { 887 return ""; 888 } 889 890 final String s = mTempBodiesHtml; 891 mTempBodiesHtml = null; 892 return s; 893 } catch (Throwable t) { 894 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies"); 895 return ""; 896 } 897 } 898 899 @SuppressWarnings("unused") 900 @JavascriptInterface 901 public String getMessageBody(String domId) { 902 try { 903 final MessageCursor cursor = getMessageCursor(); 904 if (!mViewsCreated || cursor == null) { 905 return ""; 906 } 907 908 int pos = -1; 909 while (cursor.moveToPosition(++pos)) { 910 final ConversationMessage msg = cursor.getMessage(); 911 if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) { 912 return msg.getBodyAsHtml(); 913 } 914 } 915 916 return ""; 917 918 } catch (Throwable t) { 919 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody"); 920 return ""; 921 } 922 } 923 924 private void showConversation(Conversation conv) { 925 dismissLoadingStatus(); 926 } 927 928 @SuppressWarnings("unused") 929 @JavascriptInterface 930 public void onContentReady() { 931 final Conversation conv = mConversation; 932 try { 933 getHandler().post(new Runnable() { 934 @Override 935 public void run() { 936 LogUtils.d(LOG_TAG, "ANIMATION STARTED, ready to draw. t=%s", 937 SystemClock.uptimeMillis()); 938 showConversation(conv); 939 } 940 }); 941 } catch (Throwable t) { 942 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady"); 943 // Still try to show the conversation. 944 showConversation(conv); 945 } 946 } 947 } 948 949 private class NewMessagesInfo { 950 int count; 951 String senderAddress; 952 953 /** 954 * Return the display text for the new message notification overlay. It will be formatted 955 * appropriately for a single new message vs. multiple new messages. 956 * 957 * @return display text 958 */ 959 public String getNotificationText() { 960 Resources res = getResources(); 961 if (count > 1) { 962 return res.getString(R.string.new_incoming_messages_many, count); 963 } else { 964 final Address addr = getAddress(senderAddress); 965 return res.getString(R.string.new_incoming_messages_one, 966 TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName()); 967 } 968 } 969 } 970 971 @Override 972 public void onMessageCursorLoadFinished(Loader<Cursor> loader, MessageCursor newCursor, 973 MessageCursor oldCursor) { 974 /* 975 * what kind of changes affect the MessageCursor? 1. new message(s) 2. 976 * read/unread state change 3. deleted message, either regular or draft 977 * 4. updated message, either from self or from others, updated in 978 * content or state or sender 5. star/unstar of message (technically 979 * similar to #1) 6. other label change Use MessageCursor.hashCode() to 980 * sort out interesting vs. no-op cursor updates. 981 */ 982 final boolean changed = newCursor != null && oldCursor != null 983 && newCursor.hashCode() != oldCursor.hashCode(); 984 985 if (oldCursor != null) { 986 final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor); 987 988 if (info.count > 0) { 989 // don't immediately render new incoming messages from other 990 // senders 991 // (to avoid a new message from losing the user's focus) 992 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 993 + ", holding cursor for new incoming message (%s)", this); 994 showNewMessageNotification(info); 995 return; 996 } 997 998 if (!changed) { 999 final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor); 1000 if (processedInPlace) { 1001 LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this); 1002 } else { 1003 LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update" 1004 + ", ignoring this conversation update (%s)", this); 1005 } 1006 return; 1007 } 1008 } 1009 1010 // cursors are different, and not due to an incoming message. fall 1011 // through and render. 1012 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1013 + ", but not due to incoming message. rendering. (%s)", this); 1014 1015 // if layout hasn't happened, delay render 1016 // This is needed in addition to the showConversation() delay to speed 1017 // up rotation and restoration. 1018 if (mConversationContainer.getWidth() == 0) { 1019 mNeedRender = true; 1020 mConversationContainer.addOnLayoutChangeListener(this); 1021 } else { 1022 renderConversation(newCursor); 1023 } 1024 } 1025 1026 private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) { 1027 final NewMessagesInfo info = new NewMessagesInfo(); 1028 1029 int pos = -1; 1030 while (newCursor.moveToPosition(++pos)) { 1031 final Message m = newCursor.getMessage(); 1032 if (!mViewState.contains(m)) { 1033 LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri); 1034 1035 final Address from = getAddress(m.from); 1036 // distinguish ours from theirs 1037 // new messages from the account owner should not trigger a 1038 // notification 1039 if (mAccount.ownsFromAddress(from.getAddress())) { 1040 LogUtils.i(LOG_TAG, "found message from self: %s", m.uri); 1041 continue; 1042 } 1043 1044 info.count++; 1045 info.senderAddress = m.from; 1046 } 1047 } 1048 return info; 1049 } 1050 1051 private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) { 1052 final Set<String> idsOfChangedBodies = Sets.newHashSet(); 1053 boolean changed = false; 1054 1055 int pos = 0; 1056 while (true) { 1057 if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) { 1058 break; 1059 } 1060 1061 final ConversationMessage newMsg = newCursor.getMessage(); 1062 final ConversationMessage oldMsg = oldCursor.getMessage(); 1063 1064 if (!TextUtils.equals(newMsg.from, oldMsg.from)) { 1065 mAdapter.updateItemsForMessage(newMsg); 1066 LogUtils.i(LOG_TAG, "msg #%d (%d): detected sender change", pos, newMsg.id); 1067 changed = true; 1068 } 1069 1070 // update changed message bodies in-place 1071 if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) || 1072 !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) { 1073 // maybe just set a flag to notify JS to re-request changed bodies 1074 idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"'); 1075 LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id); 1076 } 1077 1078 pos++; 1079 } 1080 1081 if (!idsOfChangedBodies.isEmpty()) { 1082 mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);", 1083 TextUtils.join(",", idsOfChangedBodies))); 1084 changed = true; 1085 } 1086 1087 return changed; 1088 } 1089 1090 private class SetCookieTask extends AsyncTask<Void, Void, Void> { 1091 final String mUri; 1092 final String mCookie; 1093 1094 SetCookieTask(String uri, String cookie) { 1095 mUri = uri; 1096 mCookie = cookie; 1097 } 1098 1099 @Override 1100 public Void doInBackground(Void... args) { 1101 final CookieSyncManager csm = 1102 CookieSyncManager.createInstance(getContext()); 1103 CookieManager.getInstance().setCookie(mUri, mCookie); 1104 csm.sync(); 1105 return null; 1106 } 1107 } 1108 1109 @Override 1110 public void onConversationUpdated(Conversation conv) { 1111 final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer 1112 .findViewById(R.id.conversation_header); 1113 mConversation = conv; 1114 if (headerView != null) { 1115 headerView.onConversationUpdated(conv); 1116 } 1117 } 1118 1119 @Override 1120 public void onLayoutChange(View v, int left, int top, int right, 1121 int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { 1122 boolean sizeChanged = mNeedRender 1123 && mConversationContainer.getWidth() != 0; 1124 if (sizeChanged) { 1125 mNeedRender = false; 1126 mConversationContainer.removeOnLayoutChangeListener(this); 1127 renderConversation(getMessageCursor()); 1128 } 1129 } 1130} 1131