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