ConversationViewFragment.java revision 3bcf180f8104bc27319086a9a6ece5a3c2917c37
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 20import android.animation.Animator; 21import android.animation.AnimatorInflater; 22import android.animation.Animator.AnimatorListener; 23import android.app.Activity; 24import android.app.Fragment; 25import android.app.LoaderManager; 26import android.content.ActivityNotFoundException; 27import android.content.Context; 28import android.content.CursorLoader; 29import android.content.Intent; 30import android.content.Loader; 31import android.content.res.Resources; 32import android.database.Cursor; 33import android.database.DataSetObservable; 34import android.database.DataSetObserver; 35import android.net.Uri; 36import android.os.AsyncTask; 37import android.os.Bundle; 38import android.os.Handler; 39import android.os.SystemClock; 40import android.provider.Browser; 41import android.text.Spannable; 42import android.text.SpannableStringBuilder; 43import android.text.TextUtils; 44import android.text.style.ForegroundColorSpan; 45import android.view.LayoutInflater; 46import android.view.Menu; 47import android.view.MenuInflater; 48import android.view.MenuItem; 49import android.view.View; 50import android.view.ViewGroup; 51import android.webkit.ConsoleMessage; 52import android.webkit.CookieManager; 53import android.webkit.CookieSyncManager; 54import android.webkit.WebChromeClient; 55import android.webkit.WebSettings; 56import android.webkit.WebView; 57import android.webkit.WebViewClient; 58import android.widget.TextView; 59 60import com.android.mail.ContactInfo; 61import com.android.mail.ContactInfoSource; 62import com.android.mail.FormattedDateBuilder; 63import com.android.mail.R; 64import com.android.mail.SenderInfoLoader; 65import com.android.mail.browse.ConversationContainer; 66import com.android.mail.browse.ConversationOverlayItem; 67import com.android.mail.browse.ConversationViewAdapter; 68import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController; 69import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem; 70import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem; 71import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem; 72import com.android.mail.browse.ConversationViewHeader; 73import com.android.mail.browse.ConversationWebView; 74import com.android.mail.browse.MessageCursor; 75import com.android.mail.browse.MessageCursor.ConversationController; 76import com.android.mail.browse.MessageCursor.ConversationMessage; 77import com.android.mail.browse.MessageHeaderView; 78import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; 79import com.android.mail.browse.SuperCollapsedBlock; 80import com.android.mail.browse.WebViewContextMenu; 81import com.android.mail.providers.Account; 82import com.android.mail.providers.AccountObserver; 83import com.android.mail.providers.Address; 84import com.android.mail.providers.Conversation; 85import com.android.mail.providers.Folder; 86import com.android.mail.providers.ListParams; 87import com.android.mail.providers.Message; 88import com.android.mail.providers.UIProvider; 89import com.android.mail.providers.UIProvider.AccountCapabilities; 90import com.android.mail.providers.UIProvider.FolderCapabilities; 91import com.android.mail.providers.UIProvider.ViewProxyExtras; 92import com.android.mail.ui.ConversationViewState.ExpansionState; 93import com.android.mail.utils.LogTag; 94import com.android.mail.utils.LogUtils; 95import com.android.mail.utils.Utils; 96import com.google.common.collect.ImmutableMap; 97import com.google.common.collect.Lists; 98import com.google.common.collect.Maps; 99import com.google.common.collect.Sets; 100 101import java.util.Arrays; 102import java.util.List; 103import java.util.Map; 104import java.util.Set; 105 106 107/** 108 * The conversation view UI component. 109 */ 110public final class ConversationViewFragment extends Fragment implements 111 ConversationViewHeader.ConversationViewHeaderCallbacks, 112 MessageHeaderViewCallbacks, 113 SuperCollapsedBlock.OnClickListener, 114 ConversationController, 115 ConversationAccountController { 116 117 private static final String LOG_TAG = LogTag.getLogTag(); 118 public static final String LAYOUT_TAG = "ConvLayout"; 119 120 private static final int MESSAGE_LOADER_ID = 0; 121 private static final int CONTACT_LOADER_ID = 1; 122 123 /** Do not auto load data when create this {@link ConversationView}. */ 124 public static final int NO_AUTO_LOAD = 0; 125 /** Auto load data but do not show any animation. */ 126 public static final int AUTO_LOAD_BACKGROUND = 1; 127 /** Auto load data and show animation. */ 128 public static final int AUTO_LOAD_VISIBLE = 2; 129 130 private ControllableActivity mActivity; 131 132 private Context mContext; 133 134 private Conversation mConversation; 135 136 private ConversationContainer mConversationContainer; 137 138 private Account mAccount; 139 140 private ConversationWebView mWebView; 141 142 private View mNewMessageBar; 143 144 private View mBackgroundView; 145 146 private View mInfoView; 147 148 private TextView mSendersView; 149 150 private TextView mSubjectView; 151 152 private View mProgressView; 153 154 private HtmlConversationTemplates mTemplates; 155 156 private String mBaseUri; 157 158 private final Handler mHandler = new Handler(); 159 160 private final MailJsBridge mJsBridge = new MailJsBridge(); 161 162 private final WebViewClient mWebViewClient = new ConversationWebViewClient(); 163 164 private ConversationViewAdapter mAdapter; 165 private MessageCursor mCursor; 166 167 private boolean mViewsCreated; 168 169 private MenuItem mChangeFoldersMenuItem; 170 /** 171 * Folder is used to help determine valid menu actions for this conversation. 172 */ 173 private Folder mFolder; 174 175 private final Map<String, Address> mAddressCache = Maps.newHashMap(); 176 177 /** 178 * Temporary string containing the message bodies of the messages within a super-collapsed 179 * block, for one-time use during block expansion. We cannot easily pass the body HTML 180 * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it 181 * using {@link MailJsBridge}. 182 */ 183 private String mTempBodiesHtml; 184 185 private boolean mUserVisible; 186 187 private int mMaxAutoLoadMessages; 188 189 private boolean mDeferredConversationLoad; 190 191 /** 192 * Handles a deferred 'mark read' operation, necessary when the conversation view has finished 193 * loading before the conversation cursor. Normally null unless this situation occurs. 194 * When finally able to 'mark read', this observer will also be unregistered and cleaned up. 195 */ 196 private MarkReadObserver mMarkReadObserver; 197 198 /** 199 * Parcelable state of the conversation view. Can safely be used without null checking any time 200 * after {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)}. 201 */ 202 private ConversationViewState mViewState; 203 204 private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks(); 205 private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks(); 206 207 private final AccountObserver mAccountObserver = new AccountObserver() { 208 @Override 209 public void onChanged(Account newAccount) { 210 mAccount = newAccount; 211 212 // settings may have been updated; refresh views that are known to depend on settings 213 mConversationContainer.getSnapHeader().onAccountChanged(); 214 mAdapter.notifyDataSetChanged(); 215 } 216 }; 217 private boolean mEnableContentReadySignal; 218 219 private static final String ARG_ACCOUNT = "account"; 220 public static final String ARG_CONVERSATION = "conversation"; 221 private static final String ARG_FOLDER = "folder"; 222 private static final String BUNDLE_VIEW_STATE = "viewstate"; 223 private static int sSubjectColor = Integer.MIN_VALUE; 224 private static int sSnippetColor = Integer.MIN_VALUE; 225 226 private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false; 227 private static final boolean DISABLE_OFFSCREEN_LOADING = false; 228 protected static final String AUTO_LOAD_KEY = "auto-load"; 229 230 /** 231 * Constructor needs to be public to handle orientation changes and activity lifecycle events. 232 */ 233 public ConversationViewFragment() { 234 super(); 235 } 236 237 /** 238 * Creates a new instance of {@link ConversationViewFragment}, initialized 239 * to display a conversation with other parameters inherited/copied from an existing bundle, 240 * typically one created using {@link #makeBasicArgs}. 241 */ 242 public static ConversationViewFragment newInstance(Bundle existingArgs, 243 Conversation conversation) { 244 ConversationViewFragment f = new ConversationViewFragment(); 245 Bundle args = new Bundle(existingArgs); 246 args.putParcelable(ARG_CONVERSATION, conversation); 247 f.setArguments(args); 248 return f; 249 } 250 251 public static Bundle makeBasicArgs(Account account, Folder folder) { 252 Bundle args = new Bundle(); 253 args.putParcelable(ARG_ACCOUNT, account); 254 args.putParcelable(ARG_FOLDER, folder); 255 return args; 256 } 257 258 @Override 259 public void onActivityCreated(Bundle savedInstanceState) { 260 LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s subj=%s", this, 261 mConversation.subject); 262 super.onActivityCreated(savedInstanceState); 263 // Strictly speaking, we get back an android.app.Activity from getActivity. However, the 264 // only activity creating a ConversationListContext is a MailActivity which is of type 265 // ControllableActivity, so this cast should be safe. If this cast fails, some other 266 // activity is creating ConversationListFragments. This activity must be of type 267 // ControllableActivity. 268 final Activity activity = getActivity(); 269 if (!(activity instanceof ControllableActivity)) { 270 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 271 + "create it. Cannot proceed."); 272 } 273 mActivity = (ControllableActivity) activity; 274 mContext = mActivity.getApplicationContext(); 275 if (mActivity.isFinishing()) { 276 // Activity is finishing, just bail. 277 return; 278 } 279 mTemplates = new HtmlConversationTemplates(mContext); 280 mAccount = mAccountObserver.initialize(mActivity.getAccountController()); 281 282 final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(mContext); 283 284 mAdapter = new ConversationViewAdapter(mActivity.getActivityContext(), this, 285 getLoaderManager(), this, mContactLoaderCallbacks, this, this, mAddressCache, 286 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 snapHeader.initialize(dateBuilder, this, mAddressCache); 292 snapHeader.setCallbacks(this); 293 snapHeader.setContactInfoSource(mContactLoaderCallbacks); 294 295 mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages); 296 297 mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(activity)); 298 299 showConversation(); 300 301 if (mConversation.conversationBaseUri != null && 302 !TextUtils.isEmpty(mConversation.conversationCookie)) { 303 // Set the cookie for this base url 304 new SetCookieTask(mConversation.conversationBaseUri.toString(), 305 mConversation.conversationCookie).execute(); 306 } 307 } 308 309 @Override 310 public void onCreate(Bundle savedState) { 311 super.onCreate(savedState); 312 313 final Bundle args = getArguments(); 314 mAccount = args.getParcelable(ARG_ACCOUNT); 315 mConversation = args.getParcelable(ARG_CONVERSATION); 316 mFolder = args.getParcelable(ARG_FOLDER); 317 // Since the uri specified in the conversation base uri may not be unique, we specify a 318 // base uri that us guaranteed to be unique for this conversation. 319 mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id; 320 LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this); 321 322 // Not really, we just want to get a crack to store a reference to the change_folder item 323 setHasOptionsMenu(true); 324 } 325 326 private CharSequence createSubjectSnippet(CharSequence subject, CharSequence snippet) { 327 SpannableStringBuilder subjectText = new SpannableStringBuilder(mContext.getString( 328 R.string.subject_and_snippet, subject, snippet)); 329 ensureSubjectSnippetColors(); 330 int snippetStart = 0; 331 int fontColor = sSubjectColor; 332 if (subject != null) { 333 subjectText.setSpan(new ForegroundColorSpan(fontColor), 0, subject.length(), 334 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 335 snippetStart = subject.length() + 1; 336 } 337 if (snippet != null) { 338 fontColor = sSnippetColor; 339 subjectText.setSpan(new ForegroundColorSpan(fontColor), snippetStart, subjectText 340 .length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 341 } 342 return subjectText; 343 } 344 345 private void ensureSubjectSnippetColors() { 346 if (sSubjectColor == Integer.MIN_VALUE) { 347 Resources res = mContext.getResources(); 348 sSubjectColor = res.getColor(R.color.subject_text_color_read); 349 sSnippetColor = res.getColor(R.color.snippet_text_color_read); 350 } 351 } 352 353 @Override 354 public View onCreateView(LayoutInflater inflater, 355 ViewGroup container, Bundle savedInstanceState) { 356 357 if (savedInstanceState != null) { 358 mViewState = savedInstanceState.getParcelable(BUNDLE_VIEW_STATE); 359 } else { 360 mViewState = new ConversationViewState(); 361 } 362 363 View rootView = inflater.inflate(R.layout.conversation_view, container, false); 364 mConversationContainer = (ConversationContainer) rootView 365 .findViewById(R.id.conversation_container); 366 367 mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar); 368 mNewMessageBar.setOnClickListener(new View.OnClickListener() { 369 @Override 370 public void onClick(View v) { 371 onNewMessageBarClick(); 372 } 373 }); 374 375 mBackgroundView = rootView.findViewById(R.id.background_view); 376 mInfoView = rootView.findViewById(R.id.info_view); 377 mSendersView = (TextView) rootView.findViewById(R.id.senders_view); 378 mSubjectView = (TextView) rootView.findViewById(R.id.info_subject_view); 379 mProgressView = rootView.findViewById(R.id.loading_progress); 380 381 mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview); 382 383 mWebView.addJavascriptInterface(mJsBridge, "mail"); 384 // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete 385 // Below JB, try to speed up initial render by having the webview do supplemental draws to 386 // custom a software canvas. 387 mEnableContentReadySignal = Utils.isRunningJellybeanOrLater(); 388 mWebView.setWebViewClient(mWebViewClient); 389 mWebView.setWebChromeClient(new WebChromeClient() { 390 @Override 391 public boolean onConsoleMessage(ConsoleMessage consoleMessage) { 392 LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(), 393 consoleMessage.sourceId(), consoleMessage.lineNumber()); 394 return true; 395 } 396 }); 397 mWebView.setContentSizeChangeListener(new ConversationWebView.ContentSizeChangeListener() { 398 @Override 399 public void onHeightChange(int h) { 400 // When WebKit says the DOM height has changed, re-measure bodies and re-position 401 // their headers. 402 // This is separate from the typical JavaScript DOM change listeners because 403 // cases like NARROW_COLUMNS text reflow do not trigger DOM events. 404 mWebView.loadUrl("javascript:measurePositions();"); 405 } 406 }); 407 408 final WebSettings settings = mWebView.getSettings(); 409 410 settings.setJavaScriptEnabled(true); 411 settings.setUseWideViewPort(true); 412 settings.setLoadWithOverviewMode(true); 413 414 settings.setSupportZoom(true); 415 settings.setBuiltInZoomControls(true); 416 settings.setDisplayZoomControls(false); 417 418 final float fontScale = getResources().getConfiguration().fontScale; 419 final int desiredFontSizePx = getResources() 420 .getInteger(R.integer.conversation_desired_font_size_px); 421 final int unstyledFontSizePx = getResources() 422 .getInteger(R.integer.conversation_unstyled_font_size_px); 423 424 int textZoom = settings.getTextZoom(); 425 // apply a correction to the default body text style to get regular text to the size we want 426 textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx; 427 // then apply any system font scaling 428 textZoom = (int) (textZoom * fontScale); 429 settings.setTextZoom(textZoom); 430 431 mViewsCreated = true; 432 433 return rootView; 434 } 435 436 @Override 437 public void onResume() { 438 super.onResume(); 439 440 // Hacky workaround for http://b/6946182 441 Utils.fixSubTreeLayoutIfOrphaned(getView(), "ConversationViewFragment"); 442 } 443 444 @Override 445 public void onSaveInstanceState(Bundle outState) { 446 if (mViewState != null) { 447 outState.putParcelable(BUNDLE_VIEW_STATE, mViewState); 448 } 449 } 450 451 @Override 452 public void onDestroyView() { 453 super.onDestroyView(); 454 mConversationContainer.setOverlayAdapter(null); 455 mAdapter = null; 456 if (mMarkReadObserver != null) { 457 mActivity.getConversationUpdater().unregisterConversationListObserver( 458 mMarkReadObserver); 459 mMarkReadObserver = null; 460 } 461 mViewsCreated = false; 462 mAccountObserver.unregisterAndDestroy(); 463 } 464 465 @Override 466 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 467 super.onCreateOptionsMenu(menu, inflater); 468 469 mChangeFoldersMenuItem = menu.findItem(R.id.change_folder); 470 } 471 472 @Override 473 public void onPrepareOptionsMenu(Menu menu) { 474 super.onPrepareOptionsMenu(menu); 475 final boolean showMarkImportant = !mConversation.isImportant(); 476 Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant 477 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 478 Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant 479 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 480 final boolean showDelete = mFolder != null && 481 mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE); 482 Utils.setMenuItemVisibility(menu, R.id.delete, showDelete); 483 // We only want to show the discard drafts menu item if we are not showing the delete menu 484 // item, and the current folder is a draft folder and the account supports discarding 485 // drafts for a conversation 486 final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() && 487 mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS); 488 Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts); 489 final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE) 490 && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE) 491 && !mFolder.isTrash(); 492 Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible); 493 Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null 494 && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 495 && !mFolder.isProviderFolder()); 496 final MenuItem removeFolder = menu.findItem(R.id.remove_folder); 497 if (removeFolder != null) { 498 removeFolder.setTitle(getString(R.string.remove_folder, mFolder.name)); 499 } 500 Utils.setMenuItemVisibility(menu, R.id.report_spam, 501 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 502 && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM) 503 && !mConversation.spam); 504 Utils.setMenuItemVisibility(menu, R.id.mark_not_spam, 505 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 506 && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM) 507 && mConversation.spam); 508 Utils.setMenuItemVisibility(menu, R.id.report_phishing, 509 mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null 510 && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING) 511 && !mConversation.phishing); 512 Utils.setMenuItemVisibility(menu, R.id.mute, 513 mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null 514 && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE) 515 && !mConversation.muted); 516 } 517 518 @Override 519 public boolean onOptionsItemSelected(MenuItem item) { 520 boolean handled = false; 521 522 switch (item.getItemId()) { 523 case R.id.inside_conversation_unread: 524 markUnread(); 525 handled = true; 526 break; 527 } 528 529 return handled; 530 } 531 532 @Override 533 public ConversationUpdater getListController() { 534 final ControllableActivity activity = (ControllableActivity) getActivity(); 535 return activity != null ? activity.getConversationUpdater() : null; 536 } 537 538 @Override 539 public MessageCursor getMessageCursor() { 540 return mCursor; 541 } 542 543 private void markUnread() { 544 // Ignore unsafe calls made after a fragment is detached from an activity 545 final ControllableActivity activity = (ControllableActivity) getActivity(); 546 if (activity == null) { 547 LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id); 548 return; 549 } 550 551 if (mViewState == null) { 552 LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)", 553 mConversation.id); 554 return; 555 } 556 activity.getConversationUpdater().markConversationMessagesUnread(mConversation, 557 mViewState.getUnreadMessageUris(), mViewState.getConversationInfo()); 558 } 559 560 /** 561 * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for 562 * reliability on older platforms. 563 */ 564 public void setExtraUserVisibleHint(boolean isVisibleToUser) { 565 LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this); 566 567 if (mUserVisible != isVisibleToUser) { 568 mUserVisible = isVisibleToUser; 569 570 if (isVisibleToUser && mViewsCreated) { 571 572 if (mCursor == null && mDeferredConversationLoad) { 573 // load 574 LogUtils.v(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", 575 mConversation.uri); 576 showConversation(); 577 mDeferredConversationLoad = false; 578 } else { 579 onConversationSeen(); 580 } 581 582 } 583 } 584 } 585 586 /** 587 * Handles a request to show a new conversation list, either from a search 588 * query or for viewing a folder. This will initiate a data load, and hence 589 * must be called on the UI thread. 590 */ 591 private void showConversation() { 592 final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING 593 || (mConversation.isRemote 594 || mConversation.getNumMessages() > mMaxAutoLoadMessages); 595 if (!mUserVisible && disableOffscreenLoading) { 596 LogUtils.v(LOG_TAG, "Fragment not user-visible, not showing conversation: %s", 597 mConversation.uri); 598 mDeferredConversationLoad = true; 599 return; 600 } 601 LogUtils.v(LOG_TAG, 602 "Fragment is short or user-visible, immediately rendering conversation: %s", 603 mConversation.uri); 604 mWebView.setVisibility(View.VISIBLE); 605 getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, mMessageLoaderCallbacks); 606 if (mUserVisible) { 607 final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger(); 608 if (sdc != null) { 609 sdc.setSubject(mConversation.subject); 610 } 611 } 612 // TODO(mindyp): don't show loading status for a previously rendered 613 // conversation. Ielieve this is better done by making sure don't show loading status 614 // until XX ms have passed without loading completed. 615 showLoadingStatus(); 616 } 617 618 public Conversation getConversation() { 619 return mConversation; 620 } 621 622 private void renderConversation(MessageCursor messageCursor) { 623 final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal); 624 625 if (DEBUG_DUMP_CONVERSATION_HTML) { 626 java.io.FileWriter fw = null; 627 try { 628 fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id 629 + ".html"); 630 fw.write(convHtml); 631 } catch (java.io.IOException e) { 632 e.printStackTrace(); 633 } finally { 634 if (fw != null) { 635 try { 636 fw.close(); 637 } catch (java.io.IOException e) { 638 e.printStackTrace(); 639 } 640 } 641 } 642 } 643 644 mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null); 645 mCursor = messageCursor; 646 } 647 648 /** 649 * Populate the adapter with overlay views (message headers, super-collapsed blocks, a 650 * conversation header), and return an HTML document with spacer divs inserted for all overlays. 651 * 652 */ 653 private String renderMessageBodies(MessageCursor messageCursor, 654 boolean enableContentReadySignal) { 655 int pos = -1; 656 657 LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this); 658 boolean allowNetworkImages = false; 659 660 // TODO: re-use any existing adapter item state (expanded, details expanded, show pics) 661 662 // Walk through the cursor and build up an overlay adapter as you go. 663 // Each overlay has an entry in the adapter for easy scroll handling in the container. 664 // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks. 665 // When adding adapter items, also add their heights to help the container later determine 666 // overlay dimensions. 667 668 // When re-rendering, prevent ConversationContainer from laying out overlays until after 669 // the new spacers are positioned by WebView. 670 mConversationContainer.invalidateSpacerGeometry(); 671 672 mAdapter.clear(); 673 674 // re-evaluate the message parts of the view state, since the messages may have changed 675 // since the previous render 676 final ConversationViewState prevState = mViewState; 677 mViewState = new ConversationViewState(prevState); 678 679 // N.B. the units of height for spacers are actually dp and not px because WebView assumes 680 // a pixel is an mdpi pixel, unless you set device-dpi. 681 682 // add a single conversation header item 683 final int convHeaderPos = mAdapter.addConversationHeader(mConversation); 684 final int convHeaderPx = measureOverlayHeight(convHeaderPos); 685 686 final int sideMarginPx = getResources().getDimensionPixelOffset( 687 R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset( 688 R.dimen.conversation_message_content_margin_side); 689 690 mTemplates.startConversation(mWebView.screenPxToWebPx(sideMarginPx), 691 mWebView.screenPxToWebPx(convHeaderPx)); 692 693 int collapsedStart = -1; 694 ConversationMessage prevCollapsedMsg = null; 695 boolean prevSafeForImages = false; 696 697 while (messageCursor.moveToPosition(++pos)) { 698 final ConversationMessage msg = messageCursor.getMessage(); 699 700 // TODO: save/restore 'show pics' state 701 final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */; 702 allowNetworkImages |= safeForImages; 703 704 final Integer savedExpanded = prevState.getExpansionState(msg); 705 final int expandedState; 706 if (savedExpanded != null) { 707 if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) { 708 // override saved state when this is now the new last message 709 // this happens to the second-to-last message when you discard a draft 710 expandedState = ExpansionState.EXPANDED; 711 } else { 712 expandedState = savedExpanded; 713 } 714 } else { 715 // new messages that are not expanded default to being eligible for super-collapse 716 expandedState = (!msg.read || msg.starred || messageCursor.isLast()) ? 717 ExpansionState.EXPANDED : ExpansionState.SUPER_COLLAPSED; 718 } 719 mViewState.setExpansionState(msg, expandedState); 720 721 // save off "read" state from the cursor 722 // later, the view may not match the cursor (e.g. conversation marked read on open) 723 // however, if a previous state indicated this message was unread, trust that instead 724 // so "mark unread" marks all originally unread messages 725 mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg)); 726 727 // We only want to consider this for inclusion in the super collapsed block if 728 // 1) The we don't have previous state about this message (The first time that the 729 // user opens a conversation) 730 // 2) The previously saved state for this message indicates that this message is 731 // in the super collapsed block. 732 if (ExpansionState.isSuperCollapsed(expandedState)) { 733 // contribute to a super-collapsed block that will be emitted just before the 734 // next expanded header 735 if (collapsedStart < 0) { 736 collapsedStart = pos; 737 } 738 prevCollapsedMsg = msg; 739 prevSafeForImages = safeForImages; 740 continue; 741 } 742 743 // resolve any deferred decisions on previous collapsed items 744 if (collapsedStart >= 0) { 745 if (pos - collapsedStart == 1) { 746 // special-case for a single collapsed message: no need to super-collapse it 747 renderMessage(prevCollapsedMsg, false /* expanded */, 748 prevSafeForImages); 749 } else { 750 renderSuperCollapsedBlock(collapsedStart, pos - 1); 751 } 752 prevCollapsedMsg = null; 753 collapsedStart = -1; 754 } 755 756 renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages); 757 } 758 759 mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages); 760 761 // If the conversation has specified a base uri, use it here, use mBaseUri 762 final String conversationBaseUri = mConversation.conversationBaseUri != null ? 763 mConversation.conversationBaseUri.toString() : mBaseUri; 764 return mTemplates.endConversation(mBaseUri, conversationBaseUri, 320, 765 mWebView.getViewportWidth(), enableContentReadySignal); 766 } 767 768 private void renderSuperCollapsedBlock(int start, int end) { 769 final int blockPos = mAdapter.addSuperCollapsedBlock(start, end); 770 final int blockPx = measureOverlayHeight(blockPos); 771 mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx)); 772 } 773 774 private void renderMessage(ConversationMessage msg, boolean expanded, 775 boolean safeForImages) { 776 final int headerPos = mAdapter.addMessageHeader(msg, expanded); 777 final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos); 778 779 final int footerPos = mAdapter.addMessageFooter(headerItem); 780 781 // Measure item header and footer heights to allocate spacers in HTML 782 // But since the views themselves don't exist yet, render each item temporarily into 783 // a host view for measurement. 784 final int headerPx = measureOverlayHeight(headerPos); 785 final int footerPx = measureOverlayHeight(footerPos); 786 787 mTemplates.appendMessageHtml(msg, expanded, safeForImages, 788 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx)); 789 } 790 791 private String renderCollapsedHeaders(MessageCursor cursor, 792 SuperCollapsedBlockItem blockToReplace) { 793 final List<ConversationOverlayItem> replacements = Lists.newArrayList(); 794 795 mTemplates.reset(); 796 797 for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) { 798 cursor.moveToPosition(i); 799 final ConversationMessage msg = cursor.getMessage(); 800 final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg, 801 false /* expanded */); 802 final MessageFooterItem footer = mAdapter.newMessageFooterItem(header); 803 804 final int headerPx = measureOverlayHeight(header); 805 final int footerPx = measureOverlayHeight(footer); 806 807 mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages, 808 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx)); 809 replacements.add(header); 810 replacements.add(footer); 811 812 mViewState.setExpansionState(msg, ExpansionState.COLLAPSED); 813 } 814 815 mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements); 816 817 return mTemplates.emit(); 818 } 819 820 private int measureOverlayHeight(int position) { 821 return measureOverlayHeight(mAdapter.getItem(position)); 822 } 823 824 /** 825 * Measure the height of an adapter view by rendering an adapter item into a temporary 826 * host view, and asking the view to immediately measure itself. This method will reuse 827 * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated 828 * earlier. 829 * <p> 830 * After measuring the height, this method also saves the height in the 831 * {@link ConversationOverlayItem} for later use in overlay positioning. 832 * 833 * @param convItem adapter item with data to render and measure 834 * @return height of the rendered view in screen px 835 */ 836 private int measureOverlayHeight(ConversationOverlayItem convItem) { 837 final int type = convItem.getType(); 838 839 final View convertView = mConversationContainer.getScrapView(type); 840 final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer, 841 true /* measureOnly */); 842 if (convertView == null) { 843 mConversationContainer.addScrapView(type, hostView); 844 } 845 846 final int heightPx = mConversationContainer.measureOverlay(hostView); 847 convItem.setHeight(heightPx); 848 convItem.markMeasurementValid(); 849 850 return heightPx; 851 } 852 853 private void onConversationSeen() { 854 // Ignore unsafe calls made after a fragment is detached from an activity 855 final ControllableActivity activity = (ControllableActivity) getActivity(); 856 if (activity == null) { 857 LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id); 858 return; 859 } 860 861 mViewState.setInfoForConversation(mConversation); 862 863 // mark viewed/read if not previously marked viewed by this conversation view, 864 // or if unread messages still exist in the message list cursor 865 // we don't want to keep marking viewed on rotation or restore 866 // but we do want future re-renders to mark read (e.g. "New message from X" case) 867 if (!mConversation.isViewed() || (mCursor != null && !mCursor.isConversationRead())) { 868 final ConversationUpdater listController = activity.getConversationUpdater(); 869 // The conversation cursor may not have finished loading by now (when launched via 870 // notification), so watch for when it finishes and mark it read then. 871 if (listController.getConversationListCursor() == null) { 872 LogUtils.i(LOG_TAG, "deferring conv mark read on open for id=%d", 873 mConversation.id); 874 mMarkReadObserver = new MarkReadObserver(listController); 875 listController.registerConversationListObserver(mMarkReadObserver); 876 } else { 877 markReadOnSeen(listController); 878 } 879 } 880 881 activity.getListHandler().onConversationSeen(mConversation); 882 } 883 884 private void markReadOnSeen(ConversationUpdater listController) { 885 // Mark the conversation viewed and read. 886 listController.markConversationsRead(Arrays.asList(mConversation), true /* read */, 887 true /* viewed */); 888 889 // and update the Message objects in the cursor so the next time a cursor update happens 890 // with these messages marked read, we know to ignore it 891 if (mCursor != null) { 892 mCursor.markMessagesRead(); 893 } 894 } 895 896 // BEGIN conversation header callbacks 897 @Override 898 public void onFoldersClicked() { 899 if (mChangeFoldersMenuItem == null) { 900 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 901 return; 902 } 903 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 904 } 905 906 @Override 907 public void onConversationViewHeaderHeightChange(int newHeight) { 908 // TODO: propagate the new height to the header's HTML spacer. This can happen when labels 909 // are added/removed 910 } 911 912 @Override 913 public String getSubjectRemainder(String subject) { 914 final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger(); 915 if (sdc == null) { 916 return subject; 917 } 918 return sdc.getUnshownSubject(subject); 919 } 920 // END conversation header callbacks 921 922 // START message header callbacks 923 @Override 924 public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) { 925 mConversationContainer.invalidateSpacerGeometry(); 926 927 // update message HTML spacer height 928 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 929 LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h, 930 newSpacerHeightPx); 931 mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %d);", 932 mTemplates.getMessageDomId(item.message), h)); 933 } 934 935 @Override 936 public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) { 937 mConversationContainer.invalidateSpacerGeometry(); 938 939 // show/hide the HTML message body and update the spacer height 940 final int h = mWebView.screenPxToWebPx(newSpacerHeightPx); 941 LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)", 942 item.isExpanded(), h, newSpacerHeightPx); 943 mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %d);", 944 mTemplates.getMessageDomId(item.message), item.isExpanded(), h)); 945 946 mViewState.setExpansionState(item.message, 947 item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED); 948 } 949 950 @Override 951 public void showExternalResources(Message msg) { 952 mWebView.getSettings().setBlockNetworkImage(false); 953 mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');"); 954 } 955 // END message header callbacks 956 957 @Override 958 public void onSuperCollapsedClick(SuperCollapsedBlockItem item) { 959 if (mCursor == null || !mViewsCreated) { 960 return; 961 } 962 963 mTempBodiesHtml = renderCollapsedHeaders(mCursor, item); 964 mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")"); 965 } 966 967 private void showNewMessageNotification(NewMessagesInfo info) { 968 final TextView descriptionView = (TextView) mNewMessageBar.findViewById( 969 R.id.new_message_description); 970 descriptionView.setText(info.getNotificationText()); 971 mNewMessageBar.setVisibility(View.VISIBLE); 972 } 973 974 private void onNewMessageBarClick() { 975 mNewMessageBar.setVisibility(View.GONE); 976 977 renderConversation(mCursor); // mCursor is already up-to-date per onLoadFinished() 978 } 979 980 private static class MessageLoader extends CursorLoader { 981 private boolean mDeliveredFirstResults = false; 982 private final Conversation mConversation; 983 private final ConversationController mController; 984 985 public MessageLoader(Context c, Conversation conv, ConversationController controller) { 986 super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null); 987 mConversation = conv; 988 mController = controller; 989 } 990 991 @Override 992 public Cursor loadInBackground() { 993 return new MessageCursor(super.loadInBackground(), mConversation, mController); 994 } 995 996 @Override 997 public void deliverResult(Cursor result) { 998 // We want to deliver these results, and then we want to make sure that any subsequent 999 // queries do not hit the network 1000 super.deliverResult(result); 1001 1002 if (!mDeliveredFirstResults) { 1003 mDeliveredFirstResults = true; 1004 Uri uri = getUri(); 1005 1006 // Create a ListParams that tells the provider to not hit the network 1007 final ListParams listParams = 1008 new ListParams(ListParams.NO_LIMIT, false /* useNetwork */); 1009 1010 // Build the new uri with this additional parameter 1011 uri = uri.buildUpon().appendQueryParameter( 1012 UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build(); 1013 setUri(uri); 1014 } 1015 } 1016 } 1017 1018 private static int[] parseInts(final String[] stringArray) { 1019 final int len = stringArray.length; 1020 final int[] ints = new int[len]; 1021 for (int i = 0; i < len; i++) { 1022 ints[i] = Integer.parseInt(stringArray[i]); 1023 } 1024 return ints; 1025 } 1026 1027 @Override 1028 public String toString() { 1029 // log extra info at DEBUG level or finer 1030 final String s = super.toString(); 1031 if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) { 1032 return s; 1033 } 1034 return "(" + s + " subj=" + mConversation.subject + ")"; 1035 } 1036 1037 private Address getAddress(String rawFrom) { 1038 Address addr = mAddressCache.get(rawFrom); 1039 if (addr == null) { 1040 addr = Address.getEmailAddress(rawFrom); 1041 mAddressCache.put(rawFrom, addr); 1042 } 1043 return addr; 1044 } 1045 1046 @Override 1047 public Account getAccount() { 1048 return mAccount; 1049 } 1050 1051 private class ConversationWebViewClient extends WebViewClient { 1052 1053 @Override 1054 public void onPageFinished(WebView view, String url) { 1055 // Ignore unsafe calls made after a fragment is detached from an activity 1056 final ControllableActivity activity = (ControllableActivity) getActivity(); 1057 if (activity == null || !mViewsCreated) { 1058 LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url, 1059 ConversationViewFragment.this); 1060 return; 1061 } 1062 1063 LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s act=%s", url, 1064 ConversationViewFragment.this, getActivity()); 1065 1066 super.onPageFinished(view, url); 1067 1068 // TODO: save off individual message unread state (here, or in onLoadFinished?) so 1069 // 'mark unread' restores the original unread state for each individual message 1070 1071 if (mUserVisible) { 1072 onConversationSeen(); 1073 } 1074 if (!mEnableContentReadySignal) { 1075 notifyConversationLoaded(mConversation); 1076 dismissLoadingStatus(); 1077 } 1078 final Set<String> emailAddresses = Sets.newHashSet(); 1079 for (Address addr : mAddressCache.values()) { 1080 emailAddresses.add(addr.getAddress()); 1081 } 1082 mContactLoaderCallbacks.setSenders(emailAddresses); 1083 getLoaderManager().restartLoader(CONTACT_LOADER_ID, Bundle.EMPTY, 1084 mContactLoaderCallbacks); 1085 } 1086 1087 @Override 1088 public boolean shouldOverrideUrlLoading(WebView view, String url) { 1089 final Activity activity = getActivity(); 1090 if (!mViewsCreated || activity == null) { 1091 return false; 1092 } 1093 1094 boolean result = false; 1095 final Intent intent; 1096 Uri uri = Uri.parse(url); 1097 if (!Utils.isEmpty(mAccount.viewIntentProxyUri)) { 1098 intent = new Intent(Intent.ACTION_VIEW, mAccount.viewIntentProxyUri); 1099 intent.putExtra(ViewProxyExtras.EXTRA_ORIGINAL_URI, uri); 1100 intent.putExtra(ViewProxyExtras.EXTRA_ACCOUNT, mAccount); 1101 } else { 1102 intent = new Intent(Intent.ACTION_VIEW, uri); 1103 intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName()); 1104 } 1105 1106 try { 1107 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 1108 activity.startActivity(intent); 1109 result = true; 1110 } catch (ActivityNotFoundException ex) { 1111 // If no application can handle the URL, assume that the 1112 // caller can handle it. 1113 } 1114 1115 return result; 1116 } 1117 1118 } 1119 1120 /** 1121 * Notifies the {@link ConversationViewable.ConversationCallbacks} that the conversation has 1122 * been loaded. 1123 */ 1124 public void notifyConversationLoaded(Conversation c) { 1125 // Do nothing. 1126 } 1127 1128 /** 1129 * Notifies the {@link ConversationViewable.ConversationCallbacks} that the conversation has 1130 * failed to load. 1131 */ 1132 protected void notifyConversationLoadError(Conversation c) { 1133 mActivity.onConversationLoadError(); 1134 } 1135 1136 private void showLoadingStatus() { 1137 mBackgroundView.setVisibility(View.VISIBLE); 1138 String senders = mConversation.getSenders(mContext); 1139 if (!TextUtils.isEmpty(senders) && mConversation.subject != null) { 1140 mInfoView.setVisibility(View.VISIBLE); 1141 mSendersView.setText(senders); 1142 mSubjectView.setText(createSubjectSnippet(mConversation.subject, 1143 mConversation.getSnippet())); 1144 } else { 1145 mProgressView.setVisibility(View.VISIBLE); 1146 } 1147 } 1148 1149 private void dismissLoadingStatus() { 1150 // Fade out the info view. 1151 if (mBackgroundView.getVisibility() == View.VISIBLE) { 1152 Animator animator = AnimatorInflater.loadAnimator(mContext, R.anim.fade_out); 1153 animator.setTarget(mBackgroundView); 1154 animator.addListener(new AnimatorListener() { 1155 @Override 1156 public void onAnimationStart(Animator animation) { 1157 if (mProgressView.getVisibility() != View.VISIBLE) { 1158 mProgressView.setVisibility(View.GONE); 1159 } 1160 } 1161 1162 @Override 1163 public void onAnimationEnd(Animator animation) { 1164 mBackgroundView.setVisibility(View.GONE); 1165 mInfoView.setVisibility(View.GONE); 1166 mProgressView.setVisibility(View.GONE); 1167 } 1168 1169 @Override 1170 public void onAnimationCancel(Animator animation) { 1171 // Do nothing. 1172 } 1173 1174 @Override 1175 public void onAnimationRepeat(Animator animation) { 1176 // Do nothing. 1177 } 1178 }); 1179 animator.start(); 1180 } else { 1181 mBackgroundView.setVisibility(View.GONE); 1182 mInfoView.setVisibility(View.GONE); 1183 mProgressView.setVisibility(View.GONE); 1184 } 1185 } 1186 1187 /** 1188 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 1189 * via reflection and not stripped. 1190 * 1191 */ 1192 private class MailJsBridge { 1193 1194 @SuppressWarnings("unused") 1195 public void onWebContentGeometryChange(final String[] overlayBottomStrs) { 1196 try { 1197 mHandler.post(new Runnable() { 1198 @Override 1199 public void run() { 1200 if (!mViewsCreated) { 1201 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" + 1202 " are gone, %s", ConversationViewFragment.this); 1203 return; 1204 } 1205 1206 mConversationContainer.onGeometryChange(parseInts(overlayBottomStrs)); 1207 } 1208 }); 1209 } catch (Throwable t) { 1210 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange"); 1211 } 1212 } 1213 1214 @SuppressWarnings("unused") 1215 public String getTempMessageBodies() { 1216 try { 1217 if (!mViewsCreated) { 1218 return ""; 1219 } 1220 1221 final String s = mTempBodiesHtml; 1222 mTempBodiesHtml = null; 1223 return s; 1224 } catch (Throwable t) { 1225 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies"); 1226 return ""; 1227 } 1228 } 1229 1230 private void showConversation(Conversation conv) { 1231 notifyConversationLoaded(conv); 1232 dismissLoadingStatus(); 1233 } 1234 1235 @SuppressWarnings("unused") 1236 public void onContentReady() { 1237 final Conversation conv = mConversation; 1238 try { 1239 mHandler.post(new Runnable() { 1240 @Override 1241 public void run() { 1242 LogUtils.d(LOG_TAG, "ANIMATION STARTED, ready to draw. t=%s", 1243 SystemClock.uptimeMillis()); 1244 showConversation(conv); 1245 } 1246 }); 1247 } catch (Throwable t) { 1248 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady"); 1249 // Still try to show the conversation. 1250 showConversation(conv); 1251 } 1252 } 1253 } 1254 1255 private class NewMessagesInfo { 1256 int count; 1257 String senderAddress; 1258 1259 /** 1260 * Return the display text for the new message notification overlay. It will be formatted 1261 * appropriately for a single new message vs. multiple new messages. 1262 * 1263 * @return display text 1264 */ 1265 public String getNotificationText() { 1266 final Object param; 1267 if (count > 1) { 1268 param = count; 1269 } else { 1270 final Address addr = getAddress(senderAddress); 1271 param = TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName(); 1272 } 1273 return getResources().getQuantityString(R.plurals.new_incoming_messages, count, param); 1274 } 1275 } 1276 1277 private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { 1278 1279 @Override 1280 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 1281 return new MessageLoader(mContext, mConversation, ConversationViewFragment.this); 1282 } 1283 1284 @Override 1285 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 1286 MessageCursor messageCursor = (MessageCursor) data; 1287 1288 // ignore truly duplicate results 1289 // this can happen when restoring after rotation 1290 if (mCursor == messageCursor) { 1291 return; 1292 } 1293 1294 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1295 LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump()); 1296 } 1297 1298 // TODO: handle ERROR status 1299 1300 // When the last cursor had message(s), and the new version has no messages, 1301 // we need to exit conversation view. 1302 if (messageCursor.getCount() == 0 && mCursor != null) { 1303 1304 if (mUserVisible) { 1305 // need to exit this view- conversation may have been deleted, or for 1306 // whatever reason is now invalid (e.g. discard single draft) 1307 // 1308 // N.B. this may involve a fragment transaction, which FragmentManager will 1309 // refuse to execute directly within onLoadFinished. Make sure the controller 1310 // knows. 1311 LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode"); 1312 mActivity.getListHandler().onConversationSelected(null, 1313 true /* inLoaderCallbacks */); 1314 } else { 1315 // we expect that the pager adapter will remove this conversation fragment 1316 // on its own due to a separate conversation cursor update 1317 // (we might get here if the message list update fires first. nothing to do 1318 // because we expect to be torn down soon.) 1319 LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update" 1320 + " in anticipation of conv cursor update. c=%s", mConversation.uri); 1321 } 1322 1323 return; 1324 } 1325 1326 // ignore cursors that are still loading results 1327 if (!messageCursor.isLoaded()) { 1328 return; 1329 } 1330 1331 /* 1332 * what kind of changes affect the MessageCursor? 1333 * 1. new message(s) 1334 * 2. read/unread state change 1335 * 3. deleted message, either regular or draft 1336 * 4. updated message, either from self or from others, updated in content or state 1337 * or sender 1338 * 5. star/unstar of message (technically similar to #1) 1339 * 6. other label change 1340 * 1341 * Use MessageCursor.hashCode() to sort out interesting vs. no-op cursor updates. 1342 */ 1343 1344 if (mCursor == null) { 1345 LogUtils.i(LOG_TAG, "CONV RENDER: existing cursor is null, rendering from scratch"); 1346 } else { 1347 final NewMessagesInfo info = getNewIncomingMessagesInfo(messageCursor); 1348 1349 if (info.count > 0 || messageCursor.hashCode() == mCursor.hashCode()) { 1350 1351 if (info.count > 0) { 1352 // don't immediately render new incoming messages from other senders 1353 // (to avoid a new message from losing the user's focus) 1354 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1355 + ", holding cursor for new incoming message"); 1356 showNewMessageNotification(info); 1357 } else { 1358 LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update" 1359 + ", ignoring this conversation update"); 1360 } 1361 1362 // update mCursor reference because the old one is about to be closed by 1363 // CursorLoader 1364 mCursor = messageCursor; 1365 return; 1366 } 1367 1368 // cursors are different, and not due to an incoming message. fall through and 1369 // render. 1370 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated" 1371 + ", but not due to incoming message. rendering."); 1372 } 1373 1374 renderConversation(messageCursor); 1375 1376 // TODO: if this is not user-visible, delay render until user-visible fragment is done. 1377 // This is needed in addition to the showConversation() delay to speed up rotation and 1378 // restoration. 1379 } 1380 1381 @Override 1382 public void onLoaderReset(Loader<Cursor> loader) { 1383 mCursor = null; 1384 } 1385 1386 private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) { 1387 final NewMessagesInfo info = new NewMessagesInfo(); 1388 1389 int pos = -1; 1390 while (newCursor.moveToPosition(++pos)) { 1391 final Message m = newCursor.getMessage(); 1392 if (!mViewState.contains(m)) { 1393 LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri); 1394 1395 final Address from = getAddress(m.from); 1396 // distinguish ours from theirs 1397 // new messages from the account owner should not trigger a notification 1398 if (mAccount.ownsFromAddress(from.getAddress())) { 1399 LogUtils.i(LOG_TAG, "found message from self: %s", m.uri); 1400 continue; 1401 } 1402 1403 info.count++; 1404 info.senderAddress = m.from; 1405 } 1406 } 1407 return info; 1408 } 1409 1410 } 1411 1412 /** 1413 * Inner class to to asynchronously load contact data for all senders in the conversation, 1414 * and notify observers when the data is ready. 1415 * 1416 */ 1417 private class ContactLoaderCallbacks implements ContactInfoSource, 1418 LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> { 1419 1420 private Set<String> mSenders; 1421 private ImmutableMap<String, ContactInfo> mContactInfoMap; 1422 private DataSetObservable mObservable = new DataSetObservable(); 1423 1424 public void setSenders(Set<String> emailAddresses) { 1425 mSenders = emailAddresses; 1426 } 1427 1428 @Override 1429 public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) { 1430 return new SenderInfoLoader(mContext, mSenders); 1431 } 1432 1433 @Override 1434 public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader, 1435 ImmutableMap<String, ContactInfo> data) { 1436 mContactInfoMap = data; 1437 mObservable.notifyChanged(); 1438 } 1439 1440 @Override 1441 public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) { 1442 } 1443 1444 @Override 1445 public ContactInfo getContactInfo(String email) { 1446 if (mContactInfoMap == null) { 1447 return null; 1448 } 1449 return mContactInfoMap.get(email); 1450 } 1451 1452 @Override 1453 public void registerObserver(DataSetObserver observer) { 1454 mObservable.registerObserver(observer); 1455 } 1456 1457 @Override 1458 public void unregisterObserver(DataSetObserver observer) { 1459 mObservable.unregisterObserver(observer); 1460 } 1461 1462 } 1463 1464 private class MarkReadObserver extends DataSetObserver { 1465 private final ConversationUpdater mListController; 1466 1467 private MarkReadObserver(ConversationUpdater listController) { 1468 mListController = listController; 1469 } 1470 1471 @Override 1472 public void onChanged() { 1473 if (mListController.getConversationListCursor() == null) { 1474 // nothing yet, keep watching 1475 return; 1476 } 1477 // done loading, safe to mark read now 1478 mListController.unregisterConversationListObserver(this); 1479 mMarkReadObserver = null; 1480 LogUtils.i(LOG_TAG, "running deferred conv mark read on open, id=%d", mConversation.id); 1481 markReadOnSeen(mListController); 1482 } 1483 } 1484 1485 private class SetCookieTask extends AsyncTask<Void, Void, Void> { 1486 final String mUri; 1487 final String mCookie; 1488 1489 SetCookieTask(String uri, String cookie) { 1490 mUri = uri; 1491 mCookie = cookie; 1492 } 1493 1494 @Override 1495 public Void doInBackground(Void... args) { 1496 final CookieSyncManager csm = 1497 CookieSyncManager.createInstance(mContext); 1498 CookieManager.getInstance().setCookie(mUri, mCookie); 1499 csm.sync(); 1500 return null; 1501 } 1502 } 1503} 1504