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