AbstractConversationViewFragment.java revision 562c5ba7235948cf1d20a9afa40e67cd62f43cf7
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.app.Activity; 21import android.app.Fragment; 22import android.app.LoaderManager; 23import android.content.Context; 24import android.content.Loader; 25import android.database.Cursor; 26import android.net.Uri; 27import android.os.Bundle; 28import android.os.Handler; 29import android.view.Menu; 30import android.view.MenuInflater; 31import android.view.MenuItem; 32 33import com.android.mail.R; 34import com.android.mail.analytics.Analytics; 35import com.android.mail.browse.ConversationAccountController; 36import com.android.mail.browse.ConversationMessage; 37import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks; 38import com.android.mail.browse.MessageCursor; 39import com.android.mail.browse.MessageCursor.ConversationController; 40import com.android.mail.content.ObjectCursor; 41import com.android.mail.content.ObjectCursorLoader; 42import com.android.mail.preferences.AccountPreferences; 43import com.android.mail.providers.Account; 44import com.android.mail.providers.AccountObserver; 45import com.android.mail.providers.Address; 46import com.android.mail.providers.Conversation; 47import com.android.mail.providers.ListParams; 48import com.android.mail.providers.UIProvider; 49import com.android.mail.providers.UIProvider.CursorStatus; 50import com.android.mail.utils.LogTag; 51import com.android.mail.utils.LogUtils; 52import com.android.mail.utils.Utils; 53 54import java.util.Arrays; 55import java.util.Collections; 56import java.util.HashMap; 57import java.util.Map; 58 59 60public abstract class AbstractConversationViewFragment extends Fragment implements 61 ConversationController, ConversationAccountController, 62 ConversationViewHeaderCallbacks { 63 64 protected static final String ARG_ACCOUNT = "account"; 65 public static final String ARG_CONVERSATION = "conversation"; 66 private static final String LOG_TAG = LogTag.getLogTag(); 67 protected static final int MESSAGE_LOADER = 0; 68 protected static final int CONTACT_LOADER = 1; 69 protected ControllableActivity mActivity; 70 private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks(); 71 private ContactLoaderCallbacks mContactLoaderCallbacks; 72 private MenuItem mChangeFoldersMenuItem; 73 protected Conversation mConversation; 74 protected String mBaseUri; 75 protected Account mAccount; 76 77 /** 78 * Must be instantiated in a derived class's onCreate. 79 */ 80 protected AbstractConversationWebViewClient mWebViewClient; 81 82 /** 83 * Cache of email address strings to parsed Address objects. 84 * <p> 85 * Remember to synchronize on the map when reading or writing to this cache, because some 86 * instances use it off the UI thread (e.g. from WebView). 87 */ 88 protected final Map<String, Address> mAddressCache = Collections.synchronizedMap( 89 new HashMap<String, Address>()); 90 private MessageCursor mCursor; 91 private Context mContext; 92 /** 93 * A backwards-compatible version of {{@link #getUserVisibleHint()}. Like the framework flag, 94 * this flag is saved and restored. 95 */ 96 private boolean mUserVisible; 97 98 private final Handler mHandler = new Handler(); 99 /** True if we want to avoid marking the conversation as viewed and read. */ 100 private boolean mSuppressMarkingViewed; 101 /** 102 * Parcelable state of the conversation view. Can safely be used without null checking any time 103 * after {@link #onCreate(Bundle)}. 104 */ 105 protected ConversationViewState mViewState; 106 107 private boolean mIsDetached; 108 109 private boolean mHasConversationBeenTransformed; 110 private boolean mHasConversationTransformBeenReverted; 111 112 private final AccountObserver mAccountObserver = new AccountObserver() { 113 @Override 114 public void onChanged(Account newAccount) { 115 final Account oldAccount = mAccount; 116 mAccount = newAccount; 117 mWebViewClient.setAccount(mAccount); 118 onAccountChanged(newAccount, oldAccount); 119 } 120 }; 121 122 private static final String BUNDLE_VIEW_STATE = 123 AbstractConversationViewFragment.class.getName() + "viewstate"; 124 /** 125 * We save the user visible flag so the various transitions that occur during rotation do not 126 * cause unnecessary visibility change. 127 */ 128 private static final String BUNDLE_USER_VISIBLE = 129 AbstractConversationViewFragment.class.getName() + "uservisible"; 130 131 private static final String BUNDLE_DETACHED = 132 AbstractConversationViewFragment.class.getName() + "detached"; 133 134 private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED = 135 AbstractConversationViewFragment.class.getName() + "conversationtransformed"; 136 private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED = 137 AbstractConversationViewFragment.class.getName() + "conversationreverted"; 138 139 public static Bundle makeBasicArgs(Account account) { 140 Bundle args = new Bundle(); 141 args.putParcelable(ARG_ACCOUNT, account); 142 return args; 143 } 144 145 /** 146 * Constructor needs to be public to handle orientation changes and activity 147 * lifecycle events. 148 */ 149 public AbstractConversationViewFragment() { 150 super(); 151 } 152 153 /** 154 * Subclasses must override, since this depends on how many messages are 155 * shown in the conversation view. 156 */ 157 protected void markUnread() { 158 // Do not automatically mark this conversation viewed and read. 159 mSuppressMarkingViewed = true; 160 } 161 162 /** 163 * Subclasses must override this, since they may want to display a single or 164 * many messages related to this conversation. 165 */ 166 protected abstract void onMessageCursorLoadFinished( 167 Loader<ObjectCursor<ConversationMessage>> loader, 168 MessageCursor newCursor, MessageCursor oldCursor); 169 170 /** 171 * Subclasses must override this, since they may want to display a single or 172 * many messages related to this conversation. 173 */ 174 @Override 175 public abstract void onConversationViewHeaderHeightChange(int newHeight); 176 177 public abstract void onUserVisibleHintChanged(); 178 179 /** 180 * Subclasses must override this. 181 */ 182 protected abstract void onAccountChanged(Account newAccount, Account oldAccount); 183 184 @Override 185 public void onCreate(Bundle savedState) { 186 super.onCreate(savedState); 187 188 parseArguments(); 189 setBaseUri(); 190 191 LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this); 192 // Not really, we just want to get a crack to store a reference to the change_folder item 193 setHasOptionsMenu(true); 194 195 if (savedState != null) { 196 mViewState = savedState.getParcelable(BUNDLE_VIEW_STATE); 197 mUserVisible = savedState.getBoolean(BUNDLE_USER_VISIBLE); 198 mIsDetached = savedState.getBoolean(BUNDLE_DETACHED, false); 199 mHasConversationBeenTransformed = 200 savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED, false); 201 mHasConversationTransformBeenReverted = 202 savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED, false); 203 } else { 204 mViewState = getNewViewState(); 205 mHasConversationBeenTransformed = false; 206 mHasConversationTransformBeenReverted = false; 207 } 208 } 209 210 /** 211 * Can be overridden in case a subclass needs to get additional arguments. 212 */ 213 protected void parseArguments() { 214 final Bundle args = getArguments(); 215 mAccount = args.getParcelable(ARG_ACCOUNT); 216 mConversation = args.getParcelable(ARG_CONVERSATION); 217 } 218 219 /** 220 * Can be overridden in case a subclass needs a different uri format 221 * (such as one that does not rely on account and/or conversation. 222 */ 223 protected void setBaseUri() { 224 mBaseUri = buildBaseUri(mAccount, mConversation); 225 } 226 227 public static String buildBaseUri(Account account, Conversation conversation) { 228 // Since the uri specified in the conversation base uri may not be unique, we specify a 229 // base uri that us guaranteed to be unique for this conversation. 230 return "x-thread://" + account.getEmailAddress().hashCode() + "/" + conversation.id; 231 } 232 233 @Override 234 public String toString() { 235 // log extra info at DEBUG level or finer 236 final String s = super.toString(); 237 if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) { 238 return s; 239 } 240 return "(" + s + " conv=" + mConversation + ")"; 241 } 242 243 @Override 244 public void onActivityCreated(Bundle savedInstanceState) { 245 super.onActivityCreated(savedInstanceState); 246 final Activity activity = getActivity(); 247 if (!(activity instanceof ControllableActivity)) { 248 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 249 + "create it. Cannot proceed."); 250 } 251 if (activity == null || activity.isFinishing()) { 252 // Activity is finishing, just bail. 253 return; 254 } 255 mActivity = (ControllableActivity) activity; 256 mContext = activity.getApplicationContext(); 257 mWebViewClient.setActivity(activity); 258 mAccount = mAccountObserver.initialize(mActivity.getAccountController()); 259 mWebViewClient.setAccount(mAccount); 260 } 261 262 @Override 263 public ConversationUpdater getListController() { 264 final ControllableActivity activity = (ControllableActivity) getActivity(); 265 return activity != null ? activity.getConversationUpdater() : null; 266 } 267 268 public Context getContext() { 269 return mContext; 270 } 271 272 @Override 273 public Conversation getConversation() { 274 return mConversation; 275 } 276 277 @Override 278 public MessageCursor getMessageCursor() { 279 return mCursor; 280 } 281 282 public Handler getHandler() { 283 return mHandler; 284 } 285 286 public MessageLoaderCallbacks getMessageLoaderCallbacks() { 287 return mMessageLoaderCallbacks; 288 } 289 290 public ContactLoaderCallbacks getContactInfoSource() { 291 if (mContactLoaderCallbacks == null) { 292 mContactLoaderCallbacks = new ContactLoaderCallbacks(mActivity.getActivityContext()); 293 } 294 return mContactLoaderCallbacks; 295 } 296 297 @Override 298 public Account getAccount() { 299 return mAccount; 300 } 301 302 @Override 303 public AccountPreferences getAccountPreferences() { 304 return AccountPreferences.get(getContext(), mAccount.name); 305 } 306 307 @Override 308 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 309 super.onCreateOptionsMenu(menu, inflater); 310 mChangeFoldersMenuItem = menu.findItem(R.id.change_folders); 311 } 312 313 @Override 314 public boolean onOptionsItemSelected(MenuItem item) { 315 if (!isUserVisible()) { 316 // Unclear how this is happening. Current theory is that this fragment was scheduled 317 // to be removed, but the remove transaction failed. When the Activity is later 318 // restored, the FragmentManager restores this fragment, but Fragment.mMenuVisible is 319 // stuck at its initial value (true), which makes this zombie fragment eligible for 320 // menu item clicks. 321 // 322 // Work around this by relying on the (properly restored) extra user visible hint. 323 LogUtils.e(LOG_TAG, 324 "ACVF ignoring onOptionsItemSelected b/c userVisibleHint is false. f=%s", this); 325 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 326 LogUtils.e(LOG_TAG, Utils.dumpFragment(this)); // the dump has '%' chars in it... 327 } 328 return false; 329 } 330 331 boolean handled = false; 332 final int itemId = item.getItemId(); 333 if (itemId == R.id.inside_conversation_unread) { 334 markUnread(); 335 handled = true; 336 } else if (itemId == R.id.show_original) { 337 showUntransformedConversation(); 338 handled = true; 339 } else if (itemId == R.id.print_all) { 340 printConversation(); 341 handled = true; 342 } 343 return handled; 344 } 345 346 @Override 347 public void onPrepareOptionsMenu(Menu menu) { 348 // Only show option if we support message transforms and message has been transformed. 349 Utils.setMenuItemVisibility(menu, R.id.show_original, supportsMessageTransforms() && 350 mHasConversationBeenTransformed && !mHasConversationTransformBeenReverted); 351 Utils.setMenuItemVisibility(menu, R.id.print_all, Utils.isRunningKitkatOrLater()); 352 } 353 354 abstract boolean supportsMessageTransforms(); 355 356 // BEGIN conversation header callbacks 357 @Override 358 public void onFoldersClicked() { 359 if (mChangeFoldersMenuItem == null) { 360 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 361 return; 362 } 363 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 364 } 365 // END conversation header callbacks 366 367 @Override 368 public void onStart() { 369 super.onStart(); 370 371 Analytics.getInstance().sendView(getClass().getName()); 372 } 373 374 @Override 375 public void onSaveInstanceState(Bundle outState) { 376 if (mViewState != null) { 377 outState.putParcelable(BUNDLE_VIEW_STATE, mViewState); 378 } 379 outState.putBoolean(BUNDLE_USER_VISIBLE, mUserVisible); 380 outState.putBoolean(BUNDLE_DETACHED, mIsDetached); 381 outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED, 382 mHasConversationBeenTransformed); 383 outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED, 384 mHasConversationTransformBeenReverted); 385 } 386 387 @Override 388 public void onDestroyView() { 389 super.onDestroyView(); 390 mAccountObserver.unregisterAndDestroy(); 391 } 392 393 /** 394 * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for 395 * reliability on older platforms. 396 */ 397 public void setExtraUserVisibleHint(boolean isVisibleToUser) { 398 LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this); 399 if (mUserVisible != isVisibleToUser) { 400 mUserVisible = isVisibleToUser; 401 MessageCursor cursor = getMessageCursor(); 402 if (mUserVisible && (cursor != null && cursor.isLoaded() && cursor.getCount() == 0)) { 403 // Pop back to conversation list and show error. 404 onError(); 405 return; 406 } 407 onUserVisibleHintChanged(); 408 } 409 } 410 411 public boolean isUserVisible() { 412 return mUserVisible; 413 } 414 415 protected void timerMark(String msg) { 416 if (isUserVisible()) { 417 Utils.sConvLoadTimer.mark(msg); 418 } 419 } 420 421 private class MessageLoaderCallbacks 422 implements LoaderManager.LoaderCallbacks<ObjectCursor<ConversationMessage>> { 423 424 @Override 425 public Loader<ObjectCursor<ConversationMessage>> onCreateLoader(int id, Bundle args) { 426 return new MessageLoader(mActivity.getActivityContext(), mConversation.messageListUri); 427 } 428 429 @Override 430 public void onLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader, 431 ObjectCursor<ConversationMessage> data) { 432 // ignore truly duplicate results 433 // this can happen when restoring after rotation 434 if (mCursor == data) { 435 return; 436 } else { 437 final MessageCursor messageCursor = (MessageCursor) data; 438 439 // bind the cursor to this fragment so it can access to the current list controller 440 messageCursor.setController(AbstractConversationViewFragment.this); 441 442 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 443 LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump()); 444 } 445 446 // We have no messages: exit conversation view. 447 if (messageCursor.getCount() == 0 448 && (!CursorStatus.isWaitingForResults(messageCursor.getStatus()) 449 || mIsDetached)) { 450 if (mUserVisible) { 451 onError(); 452 } else { 453 // we expect that the pager adapter will remove this 454 // conversation fragment on its own due to a separate 455 // conversation cursor update (we might get here if the 456 // message list update fires first. nothing to do 457 // because we expect to be torn down soon.) 458 LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update" 459 + " in anticipation of conv cursor update. c=%s", 460 mConversation.uri); 461 } 462 // existing mCursor will imminently be closed, must stop referencing it 463 // since we expect to be kicked out soon, it doesn't matter what mCursor 464 // becomes 465 mCursor = null; 466 return; 467 } 468 469 // ignore cursors that are still loading results 470 if (!messageCursor.isLoaded()) { 471 // existing mCursor will imminently be closed, must stop referencing it 472 // in this case, the new cursor is also no good, and since don't expect to get 473 // here except in initial load situations, it's safest to just ensure the 474 // reference is null 475 mCursor = null; 476 return; 477 } 478 final MessageCursor oldCursor = mCursor; 479 mCursor = messageCursor; 480 onMessageCursorLoadFinished(loader, mCursor, oldCursor); 481 } 482 } 483 484 @Override 485 public void onLoaderReset(Loader<ObjectCursor<ConversationMessage>> loader) { 486 mCursor = null; 487 } 488 489 } 490 491 private void onError() { 492 // need to exit this view- conversation may have been 493 // deleted, or for whatever reason is now invalid (e.g. 494 // discard single draft) 495 // 496 // N.B. this may involve a fragment transaction, which 497 // FragmentManager will refuse to execute directly 498 // within onLoadFinished. Make sure the controller knows. 499 LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode"); 500 // TODO(mindyp): handle ERROR status by showing an error 501 // message to the user that there are no messages in 502 // this conversation 503 popOut(); 504 } 505 506 private void popOut() { 507 mHandler.post(new FragmentRunnable("popOut", this) { 508 @Override 509 public void go() { 510 if (mActivity != null) { 511 mActivity.getListHandler() 512 .onConversationSelected(null, true /* inLoaderCallbacks */); 513 } 514 } 515 }); 516 } 517 518 protected void onConversationSeen() { 519 LogUtils.d(LOG_TAG, "AbstractConversationViewFragment#onConversationSeen()"); 520 521 // Ignore unsafe calls made after a fragment is detached from an activity 522 final ControllableActivity activity = (ControllableActivity) getActivity(); 523 if (activity == null) { 524 LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id); 525 return; 526 } 527 528 mViewState.setInfoForConversation(mConversation); 529 530 LogUtils.d(LOG_TAG, "onConversationSeen() - mSuppressMarkingViewed = %b", 531 mSuppressMarkingViewed); 532 // In most circumstances we want to mark the conversation as viewed and read, since the 533 // user has read it. However, if the user has already marked the conversation unread, we 534 // do not want a later mark-read operation to undo this. So we check this variable which 535 // is set in #markUnread() which suppresses automatic mark-read. 536 if (!mSuppressMarkingViewed) { 537 // mark viewed/read if not previously marked viewed by this conversation view, 538 // or if unread messages still exist in the message list cursor 539 // we don't want to keep marking viewed on rotation or restore 540 // but we do want future re-renders to mark read (e.g. "New message from X" case) 541 final MessageCursor cursor = getMessageCursor(); 542 LogUtils.d(LOG_TAG, "onConversationSeen() - mConversation.isViewed() = %b, " 543 + "cursor null = %b, cursor.isConversationRead() = %b", 544 mConversation.isViewed(), cursor == null, 545 cursor != null && cursor.isConversationRead()); 546 if (!mConversation.isViewed() || (cursor != null && !cursor.isConversationRead())) { 547 // Mark the conversation viewed and read. 548 activity.getConversationUpdater() 549 .markConversationsRead(Arrays.asList(mConversation), true, true); 550 551 // and update the Message objects in the cursor so the next time a cursor update 552 // happens with these messages marked read, we know to ignore it 553 if (cursor != null && !cursor.isClosed()) { 554 cursor.markMessagesRead(); 555 } 556 } 557 } 558 activity.getListHandler().onConversationSeen(); 559 } 560 561 protected ConversationViewState getNewViewState() { 562 return new ConversationViewState(); 563 } 564 565 private static class MessageLoader extends ObjectCursorLoader<ConversationMessage> { 566 private boolean mDeliveredFirstResults = false; 567 568 public MessageLoader(Context c, Uri messageListUri) { 569 super(c, messageListUri, UIProvider.MESSAGE_PROJECTION, ConversationMessage.FACTORY); 570 } 571 572 @Override 573 public void deliverResult(ObjectCursor<ConversationMessage> result) { 574 // We want to deliver these results, and then we want to make sure 575 // that any subsequent 576 // queries do not hit the network 577 super.deliverResult(result); 578 579 if (!mDeliveredFirstResults) { 580 mDeliveredFirstResults = true; 581 Uri uri = getUri(); 582 583 // Create a ListParams that tells the provider to not hit the 584 // network 585 final ListParams listParams = new ListParams(ListParams.NO_LIMIT, 586 false /* useNetwork */); 587 588 // Build the new uri with this additional parameter 589 uri = uri 590 .buildUpon() 591 .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER, 592 listParams.serialize()).build(); 593 setUri(uri); 594 } 595 } 596 597 @Override 598 protected ObjectCursor<ConversationMessage> getObjectCursor(Cursor inner) { 599 return new MessageCursor(inner); 600 } 601 } 602 603 public abstract void onConversationUpdated(Conversation conversation); 604 605 public void onDetachedModeEntered() { 606 // If we have no messages, then we have nothing to display, so leave this view. 607 // Otherwise, just set the detached flag. 608 final Cursor messageCursor = getMessageCursor(); 609 610 if (messageCursor == null || messageCursor.getCount() == 0) { 611 popOut(); 612 } else { 613 mIsDetached = true; 614 } 615 } 616 617 /** 618 * Called when the JavaScript reports that it transformed a message. 619 * Sets a flag to true and invalidates the options menu so it will 620 * include the "Revert auto-sizing" menu option. 621 */ 622 public void onConversationTransformed() { 623 mHasConversationBeenTransformed = true; 624 mHandler.post(new FragmentRunnable("invalidateOptionsMenu", this) { 625 @Override 626 public void go() { 627 mActivity.invalidateOptionsMenu(); 628 } 629 }); 630 } 631 632 /** 633 * Called when the "Revert auto-sizing" option is selected. Default 634 * implementation simply sets a value on whether transforms should be 635 * applied. Derived classes should override this class and force a 636 * re-render so that the conversation renders without 637 */ 638 public void showUntransformedConversation() { 639 // must set the value to true so we don't show the options menu item again 640 mHasConversationTransformBeenReverted = true; 641 } 642 643 /** 644 * Returns {@code true} if the conversation should be transformed. {@code false}, otherwise. 645 * @return {@code true} if the conversation should be transformed. {@code false}, otherwise. 646 */ 647 public boolean shouldApplyTransforms() { 648 return (mAccount.enableMessageTransforms > 0) && 649 !mHasConversationTransformBeenReverted; 650 } 651 652 protected abstract void printConversation(); 653} 654