AbstractConversationViewFragment.java revision ff282d0ef252dbdaf6e9f4e2a7fd640287c01e6b
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.Context; 27import android.content.CursorLoader; 28import android.content.Loader; 29import android.content.res.Resources; 30import android.database.Cursor; 31import android.database.DataSetObservable; 32import android.database.DataSetObserver; 33import android.net.Uri; 34import android.os.Bundle; 35import android.os.Handler; 36import android.text.Spannable; 37import android.text.SpannableStringBuilder; 38import android.text.TextUtils; 39import android.text.style.ForegroundColorSpan; 40import android.util.AttributeSet; 41import android.view.Menu; 42import android.view.MenuInflater; 43import android.view.MenuItem; 44import android.view.View; 45import android.widget.TextView; 46 47import com.android.mail.ContactInfo; 48import com.android.mail.ContactInfoSource; 49import com.android.mail.FormattedDateBuilder; 50import com.android.mail.R; 51import com.android.mail.SenderInfoLoader; 52import com.android.mail.browse.MessageCursor; 53import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController; 54import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks; 55import com.android.mail.browse.MessageCursor.ConversationController; 56import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; 57import com.android.mail.providers.Account; 58import com.android.mail.providers.AccountObserver; 59import com.android.mail.providers.Address; 60import com.android.mail.providers.Conversation; 61import com.android.mail.providers.Folder; 62import com.android.mail.providers.ListParams; 63import com.android.mail.providers.UIProvider; 64import com.android.mail.providers.UIProvider.AccountCapabilities; 65import com.android.mail.providers.UIProvider.FolderCapabilities; 66import com.android.mail.utils.LogTag; 67import com.android.mail.utils.LogUtils; 68import com.android.mail.utils.Utils; 69import com.google.common.collect.ImmutableMap; 70import com.google.common.collect.Maps; 71 72import java.util.Map; 73import java.util.Set; 74 75public abstract class AbstractConversationViewFragment extends Fragment implements 76 ConversationController, ConversationAccountController, MessageHeaderViewCallbacks, 77 ConversationViewHeaderCallbacks { 78 79 private static final String ARG_ACCOUNT = "account"; 80 public static final String ARG_CONVERSATION = "conversation"; 81 private static final String ARG_FOLDER = "folder"; 82 private static final String LOG_TAG = LogTag.getLogTag(); 83 protected static final int MESSAGE_LOADER = 0; 84 protected static final int CONTACT_LOADER = 1; 85 private static int sSubjectColor = Integer.MIN_VALUE; 86 private static int sSnippetColor = Integer.MIN_VALUE; 87 private static long sMinDelay = -1; 88 protected ControllableActivity mActivity; 89 private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks(); 90 protected FormattedDateBuilder mDateBuilder; 91 private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks(); 92 private MenuItem mChangeFoldersMenuItem; 93 protected Conversation mConversation; 94 protected Folder mFolder; 95 protected String mBaseUri; 96 protected Account mAccount; 97 protected final Map<String, Address> mAddressCache = Maps.newHashMap(); 98 protected boolean mEnableContentReadySignal; 99 private MessageCursor mCursor; 100 private Context mContext; 101 public boolean mUserVisible; 102 private View mProgressView; 103 private View mBackgroundView; 104 private View mInfoView; 105 private final Handler mHandler = new Handler(); 106 private Runnable mDelayedShow = new Runnable() { 107 @Override 108 public void run() { 109 mBackgroundView.setVisibility(View.VISIBLE); 110 String senders = mConversation.getSenders(getContext()); 111 if (!TextUtils.isEmpty(senders) && mConversation.subject != null) { 112 mInfoView.setVisibility(View.VISIBLE); 113 mSendersView.setText(senders); 114 mSubjectView.setText(createSubjectSnippet(mConversation.subject, 115 mConversation.getSnippet())); 116 } else { 117 mProgressView.setVisibility(View.VISIBLE); 118 } 119 } 120 }; 121 122 private final AccountObserver mAccountObserver = new AccountObserver() { 123 @Override 124 public void onChanged(Account newAccount) { 125 mAccount = newAccount; 126 onAccountChanged(); 127 } 128 }; 129 private TextView mSendersView; 130 private TextView mSubjectView; 131 132 public static Bundle makeBasicArgs(Account account, Folder folder) { 133 Bundle args = new Bundle(); 134 args.putParcelable(ARG_ACCOUNT, account); 135 args.putParcelable(ARG_FOLDER, folder); 136 return args; 137 } 138 139 /** 140 * Constructor needs to be public to handle orientation changes and activity 141 * lifecycle events. 142 */ 143 public AbstractConversationViewFragment() { 144 super(); 145 } 146 147 /** 148 * Subclasses must override, since this depends on how many messages are 149 * shown in the conversation view. 150 */ 151 protected abstract void markUnread(); 152 153 /** 154 * Subclasses must override this, since they may want to display a single or 155 * many messages related to this conversation. 156 */ 157 protected abstract void onMessageCursorLoadFinished(Loader<Cursor> loader, Cursor data, 158 boolean wasNull, boolean messageCursorChanged); 159 160 /** 161 * Subclasses must override this, since they may want to display a single or 162 * many messages related to this conversation. 163 */ 164 @Override 165 public abstract void onConversationViewHeaderHeightChange(int newHeight); 166 167 public abstract void onUserVisibleHintChanged(); 168 169 /** 170 * Subclasses must override this. 171 */ 172 protected abstract void onAccountChanged(); 173 174 @Override 175 public void onCreate(Bundle savedState) { 176 super.onCreate(savedState); 177 178 final Bundle args = getArguments(); 179 mAccount = args.getParcelable(ARG_ACCOUNT); 180 mConversation = args.getParcelable(ARG_CONVERSATION); 181 mFolder = args.getParcelable(ARG_FOLDER); 182 // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete 183 // Below JB, try to speed up initial render by having the webview do supplemental draws to 184 // custom a software canvas. 185 // TODO(mindyp): 186 //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER 187 // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op 188 // animation that immediately runs on page load. The app uses this as a signal that the 189 // content is loaded and ready to draw, since WebView delays firing this event until the 190 // layers are composited and everything is ready to draw. 191 // This signal does not seem to be reliable, so just use the old method for now. 192 mEnableContentReadySignal = false; //Utils.isRunningJellybeanOrLater(); 193 LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this); 194 // Not really, we just want to get a crack to store a reference to the change_folder item 195 setHasOptionsMenu(true); 196 } 197 198 public void instantiateProgressIndicators(View rootView) { 199 mSendersView = (TextView) rootView.findViewById(R.id.senders_view); 200 mSubjectView = (TextView) rootView.findViewById(R.id.info_subject_view); 201 mBackgroundView = rootView.findViewById(R.id.background_view); 202 mInfoView = rootView.findViewById(R.id.info_view); 203 mProgressView = rootView.findViewById(R.id.loading_progress); 204 } 205 206 protected void dismissLoadingStatus() { 207 if (mBackgroundView.getVisibility() != View.VISIBLE) { 208 // The runnable hasn't run yet, so just remove it. 209 mHandler.removeCallbacks(mDelayedShow); 210 return; 211 } 212 // Fade out the info view. 213 if (mBackgroundView.getVisibility() == View.VISIBLE) { 214 Animator animator = AnimatorInflater.loadAnimator(getContext(), R.anim.fade_out); 215 animator.setTarget(mBackgroundView); 216 animator.addListener(new AnimatorListener() { 217 @Override 218 public void onAnimationStart(Animator animation) { 219 if (mProgressView.getVisibility() != View.VISIBLE) { 220 mProgressView.setVisibility(View.GONE); 221 } 222 } 223 224 @Override 225 public void onAnimationEnd(Animator animation) { 226 mBackgroundView.setVisibility(View.GONE); 227 mInfoView.setVisibility(View.GONE); 228 mProgressView.setVisibility(View.GONE); 229 } 230 231 @Override 232 public void onAnimationCancel(Animator animation) { 233 // Do nothing. 234 } 235 236 @Override 237 public void onAnimationRepeat(Animator animation) { 238 // Do nothing. 239 } 240 }); 241 animator.start(); 242 } else { 243 mBackgroundView.setVisibility(View.GONE); 244 mInfoView.setVisibility(View.GONE); 245 mProgressView.setVisibility(View.GONE); 246 } 247 } 248 249 @Override 250 public void onActivityCreated(Bundle savedInstanceState) { 251 super.onActivityCreated(savedInstanceState); 252 Activity activity = getActivity(); 253 if (!(activity instanceof ControllableActivity)) { 254 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 255 + "create it. Cannot proceed."); 256 } 257 if (activity.isFinishing()) { 258 // Activity is finishing, just bail. 259 return; 260 } 261 mActivity = (ControllableActivity) getActivity(); 262 mContext = activity.getApplicationContext(); 263 mDateBuilder = new FormattedDateBuilder((Context) mActivity); 264 mAccount = mAccountObserver.initialize(mActivity.getAccountController()); 265 } 266 267 @Override 268 public ConversationUpdater getListController() { 269 final ControllableActivity activity = (ControllableActivity) getActivity(); 270 return activity != null ? activity.getConversationUpdater() : null; 271 } 272 273 274 protected void showLoadingStatus() { 275 if (sMinDelay == -1) { 276 sMinDelay = getContext().getResources() 277 .getInteger(R.integer.conversationview_show_loading_delay); 278 } 279 // In case there were any other instances around, get rid of them. 280 mHandler.removeCallbacks(mDelayedShow); 281 mHandler.postDelayed(mDelayedShow, sMinDelay); 282 } 283 284 private CharSequence createSubjectSnippet(CharSequence subject, CharSequence snippet) { 285 if (TextUtils.isEmpty(subject) && TextUtils.isEmpty(snippet)) { 286 return ""; 287 } 288 if (subject == null) { 289 subject = ""; 290 } 291 if (snippet == null) { 292 snippet = ""; 293 } 294 SpannableStringBuilder subjectText = new SpannableStringBuilder(getContext().getString( 295 R.string.subject_and_snippet, subject, snippet)); 296 ensureSubjectSnippetColors(); 297 int snippetStart = 0; 298 int fontColor = sSubjectColor; 299 subjectText.setSpan(new ForegroundColorSpan(fontColor), 0, subject.length(), 300 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 301 snippetStart = subject.length() + 1; 302 fontColor = sSnippetColor; 303 subjectText.setSpan(new ForegroundColorSpan(fontColor), snippetStart, subjectText.length(), 304 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 305 return subjectText; 306 } 307 308 private void ensureSubjectSnippetColors() { 309 if (sSubjectColor == Integer.MIN_VALUE) { 310 Resources res = getContext().getResources(); 311 sSubjectColor = res.getColor(R.color.subject_text_color_read); 312 sSnippetColor = res.getColor(R.color.snippet_text_color_read); 313 } 314 } 315 316 public Context getContext() { 317 return mContext; 318 } 319 320 public Conversation getConversation() { 321 return mConversation; 322 } 323 324 @Override 325 public MessageCursor getMessageCursor() { 326 return mCursor; 327 } 328 329 public Handler getHandler() { 330 return mHandler; 331 } 332 333 public MessageLoaderCallbacks getMessageLoaderCallbacks() { 334 return mMessageLoaderCallbacks; 335 } 336 337 public ContactLoaderCallbacks getContactInfoSource() { 338 return mContactLoaderCallbacks; 339 } 340 341 @Override 342 public Account getAccount() { 343 return mAccount; 344 } 345 346 @Override 347 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 348 super.onCreateOptionsMenu(menu, inflater); 349 mChangeFoldersMenuItem = menu.findItem(R.id.change_folder); 350 } 351 352 @Override 353 public void onPrepareOptionsMenu(Menu menu) { 354 super.onPrepareOptionsMenu(menu); 355 final boolean showMarkImportant = !mConversation.isImportant(); 356 Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant 357 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 358 Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant 359 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 360 final boolean showDelete = mFolder != null && 361 mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE); 362 Utils.setMenuItemVisibility(menu, R.id.delete, showDelete); 363 // We only want to show the discard drafts menu item if we are not showing the delete menu 364 // item, and the current folder is a draft folder and the account supports discarding 365 // drafts for a conversation 366 final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() && 367 mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS); 368 Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts); 369 final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE) 370 && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE) 371 && !mFolder.isTrash(); 372 Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible); 373 Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null 374 && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 375 && !mFolder.isProviderFolder()); 376 final MenuItem removeFolder = menu.findItem(R.id.remove_folder); 377 if (removeFolder != null) { 378 removeFolder.setTitle(getString(R.string.remove_folder, mFolder.name)); 379 } 380 Utils.setMenuItemVisibility(menu, R.id.report_spam, 381 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 382 && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM) 383 && !mConversation.spam); 384 Utils.setMenuItemVisibility(menu, R.id.mark_not_spam, 385 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 386 && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM) 387 && mConversation.spam); 388 Utils.setMenuItemVisibility(menu, R.id.report_phishing, 389 mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null 390 && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING) 391 && !mConversation.phishing); 392 Utils.setMenuItemVisibility(menu, R.id.mute, 393 mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null 394 && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE) 395 && !mConversation.muted); 396 } 397 398 @Override 399 public boolean onOptionsItemSelected(MenuItem item) { 400 boolean handled = false; 401 switch (item.getItemId()) { 402 case R.id.inside_conversation_unread: 403 markUnread(); 404 handled = true; 405 break; 406 } 407 return handled; 408 } 409 410 // BEGIN conversation header callbacks 411 @Override 412 public void onFoldersClicked() { 413 if (mChangeFoldersMenuItem == null) { 414 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 415 return; 416 } 417 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 418 } 419 420 @Override 421 public String getSubjectRemainder(String subject) { 422 final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger(); 423 if (sdc == null) { 424 return subject; 425 } 426 return sdc.getUnshownSubject(subject); 427 } 428 // END conversation header callbacks 429 430 @Override 431 public void onDestroyView() { 432 super.onDestroyView(); 433 mAccountObserver.unregisterAndDestroy(); 434 } 435 436 /** 437 * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for 438 * reliability on older platforms. 439 */ 440 public void setExtraUserVisibleHint(boolean isVisibleToUser) { 441 LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this); 442 if (mUserVisible != isVisibleToUser) { 443 mUserVisible = isVisibleToUser; 444 onUserVisibleHintChanged(); 445 } 446 } 447 448 private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { 449 450 @Override 451 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 452 return new MessageLoader(mActivity.getActivityContext(), mConversation, 453 AbstractConversationViewFragment.this); 454 } 455 456 @Override 457 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 458 // ignore truly duplicate results 459 // this can happen when restoring after rotation 460 if (mCursor == data) { 461 return; 462 } else { 463 MessageCursor messageCursor = (MessageCursor) data; 464 465 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 466 LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump()); 467 } 468 469 // TODO: handle ERROR status 470 471 // When the last cursor had message(s), and the new version has 472 // no messages, we need to exit conversation view. 473 if (messageCursor.getCount() == 0 && mCursor != null) { 474 if (mUserVisible) { 475 // need to exit this view- conversation may have been 476 // deleted, or for whatever reason is now invalid (e.g. 477 // discard single draft) 478 // 479 // N.B. this may involve a fragment transaction, which 480 // FragmentManager will refuse to execute directly 481 // within onLoadFinished. Make sure the controller knows. 482 LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode"); 483 mActivity.getListHandler() 484 .onConversationSelected(null, true /* inLoaderCallbacks */); 485 } else { 486 // we expect that the pager adapter will remove this 487 // conversation fragment on its own due to a separate 488 // conversation cursor update (we might get here if the 489 // message list update fires first. nothing to do 490 // because we expect to be torn down soon.) 491 LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update" 492 + " in anticipation of conv cursor update. c=%s", mConversation.uri); 493 } 494 495 return; 496 } 497 498 // ignore cursors that are still loading results 499 if (!messageCursor.isLoaded()) { 500 return; 501 } 502 boolean wasNull = mCursor == null; 503 boolean messageCursorChanged = mCursor != null 504 && messageCursor.hashCode() != mCursor.hashCode(); 505 mCursor = (MessageCursor) data; 506 onMessageCursorLoadFinished(loader, data, wasNull, messageCursorChanged); 507 } 508 } 509 510 @Override 511 public void onLoaderReset(Loader<Cursor> loader) { 512 mCursor = null; 513 } 514 515 } 516 517 private static class MessageLoader extends CursorLoader { 518 private boolean mDeliveredFirstResults = false; 519 private final Conversation mConversation; 520 private final ConversationController mController; 521 522 public MessageLoader(Context c, Conversation conv, ConversationController controller) { 523 super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null); 524 mConversation = conv; 525 mController = controller; 526 } 527 528 @Override 529 public Cursor loadInBackground() { 530 return new MessageCursor(super.loadInBackground(), mConversation, mController); 531 } 532 533 @Override 534 public void deliverResult(Cursor result) { 535 // We want to deliver these results, and then we want to make sure 536 // that any subsequent 537 // queries do not hit the network 538 super.deliverResult(result); 539 540 if (!mDeliveredFirstResults) { 541 mDeliveredFirstResults = true; 542 Uri uri = getUri(); 543 544 // Create a ListParams that tells the provider to not hit the 545 // network 546 final ListParams listParams = new ListParams(ListParams.NO_LIMIT, 547 false /* useNetwork */); 548 549 // Build the new uri with this additional parameter 550 uri = uri 551 .buildUpon() 552 .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER, 553 listParams.serialize()).build(); 554 setUri(uri); 555 } 556 } 557 } 558 559 /** 560 * Inner class to to asynchronously load contact data for all senders in the conversation, 561 * and notify observers when the data is ready. 562 * 563 */ 564 protected class ContactLoaderCallbacks implements ContactInfoSource, 565 LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> { 566 567 private Set<String> mSenders; 568 private ImmutableMap<String, ContactInfo> mContactInfoMap; 569 private DataSetObservable mObservable = new DataSetObservable(); 570 571 public void setSenders(Set<String> emailAddresses) { 572 mSenders = emailAddresses; 573 } 574 575 @Override 576 public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) { 577 return new SenderInfoLoader(mActivity.getActivityContext(), mSenders); 578 } 579 580 @Override 581 public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader, 582 ImmutableMap<String, ContactInfo> data) { 583 mContactInfoMap = data; 584 mObservable.notifyChanged(); 585 } 586 587 @Override 588 public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) { 589 } 590 591 @Override 592 public ContactInfo getContactInfo(String email) { 593 if (mContactInfoMap == null) { 594 return null; 595 } 596 return mContactInfoMap.get(email); 597 } 598 599 @Override 600 public void registerObserver(DataSetObserver observer) { 601 mObservable.registerObserver(observer); 602 } 603 604 @Override 605 public void unregisterObserver(DataSetObserver observer) { 606 mObservable.unregisterObserver(observer); 607 } 608 609 } 610} 611