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