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