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.FragmentManager; 23 import android.app.FragmentTransaction; 24 import android.app.ListFragment; 25 import android.content.Intent; 26 import android.os.Bundle; 27 import android.support.annotation.IdRes; 28 import android.support.annotation.LayoutRes; 29 import android.support.v7.app.ActionBar; 30 import android.view.KeyEvent; 31 import android.view.View; 32 import android.widget.ListView; 33 34 import com.android.mail.ConversationListContext; 35 import com.android.mail.R; 36 import com.android.mail.providers.Account; 37 import com.android.mail.providers.Conversation; 38 import com.android.mail.providers.Folder; 39 import com.android.mail.providers.UIProvider.ConversationListIcon; 40 import com.android.mail.utils.LogUtils; 41 import com.android.mail.utils.Utils; 42 43 /** 44 * Controller for two-pane Mail activity. Two Pane is used for tablets, where screen real estate 45 * abounds. 46 */ 47 public final class TwoPaneController extends AbstractActivityController implements 48 ConversationViewFrame.DownEventListener { 49 50 private static final String SAVED_MISCELLANEOUS_VIEW = "saved-miscellaneous-view"; 51 private static final String SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID = 52 "saved-miscellaneous-view-transaction-id"; 53 54 private TwoPaneLayout mLayout; 55 @Deprecated 56 private Conversation mConversationToShow; 57 58 /** 59 * 2-pane, in wider configurations, allows peeking at a conversation view without having the 60 * conversation marked-as-read as far as read/unread state goes.<br> 61 * <br> 62 * This flag applies to {@link AbstractActivityController#mCurrentConversation} and indicates 63 * that the current conversation, if set, is in a 'peeking' state. If there is no current 64 * conversation, peeking is implied (in certain view configurations) and this value is 65 * meaningless. 66 */ 67 // TODO: save in instance state 68 private boolean mCurrentConversationJustPeeking; 69 70 /** 71 * Used to determine whether onViewModeChanged should skip a potential 72 * fragment transaction that would remove a miscellaneous view. 73 */ 74 private boolean mSavedMiscellaneousView = false; 75 76 private boolean mIsTabletLandscape; 77 78 public TwoPaneController(MailActivity activity, ViewMode viewMode) { 79 super(activity, viewMode); 80 } 81 82 public boolean isCurrentConversationJustPeeking() { 83 return mCurrentConversationJustPeeking; 84 } 85 86 private boolean isConversationOnlyMode() { 87 return getCurrentConversation() != null && !isCurrentConversationJustPeeking() 88 && !mLayout.shouldShowPreviewPanel(); 89 } 90 91 /** 92 * Display the conversation list fragment. 93 */ 94 private void initializeConversationListFragment() { 95 if (Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())) { 96 if (shouldEnterSearchConvMode()) { 97 mViewMode.enterSearchResultsConversationMode(); 98 } else { 99 mViewMode.enterSearchResultsListMode(); 100 } 101 } 102 renderConversationList(); 103 } 104 105 /** 106 * Render the conversation list in the correct pane. 107 */ 108 private void renderConversationList() { 109 if (mActivity == null) { 110 return; 111 } 112 FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction(); 113 // Use cross fading animation. 114 fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); 115 final ConversationListFragment conversationListFragment = 116 ConversationListFragment.newInstance(mConvListContext); 117 fragmentTransaction.replace(R.id.conversation_list_pane, conversationListFragment, 118 TAG_CONVERSATION_LIST); 119 fragmentTransaction.commitAllowingStateLoss(); 120 // Set default navigation here once the ConversationListFragment is created. 121 conversationListFragment.setNextFocusLeftId( 122 getClfNextFocusLeftId(getFolderListFragment().isMinimized())); 123 } 124 125 @Override 126 public boolean doesActionChangeConversationListVisibility(final int action) { 127 if (action == R.id.settings 128 || action == R.id.compose 129 || action == R.id.help_info_menu_item 130 || action == R.id.feedback_menu_item) { 131 return true; 132 } 133 134 return false; 135 } 136 137 @Override 138 protected boolean isConversationListVisible() { 139 return !mLayout.isConversationListCollapsed(); 140 } 141 142 @Override 143 public void showConversationList(ConversationListContext listContext) { 144 super.showConversationList(listContext); 145 initializeConversationListFragment(); 146 } 147 148 @Override 149 public @LayoutRes int getContentViewResource() { 150 return R.layout.two_pane_activity; 151 } 152 153 @Override 154 public boolean onCreate(Bundle savedState) { 155 mLayout = (TwoPaneLayout) mActivity.findViewById(R.id.two_pane_activity); 156 if (mLayout == null) { 157 // We need the layout for everything. Crash/Return early if it is null. 158 LogUtils.wtf(LOG_TAG, "mLayout is null!"); 159 return false; 160 } 161 mLayout.setController(this, Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())); 162 mActivity.getWindow().setBackgroundDrawable(null); 163 mIsTabletLandscape = !mActivity.getResources().getBoolean(R.bool.list_collapsible); 164 165 final FolderListFragment flf = getFolderListFragment(); 166 flf.setMiniDrawerEnabled(true); 167 flf.setMinimized(true); 168 169 if (savedState != null) { 170 mSavedMiscellaneousView = savedState.getBoolean(SAVED_MISCELLANEOUS_VIEW, false); 171 mMiscellaneousViewTransactionId = 172 savedState.getInt(SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID, -1); 173 } 174 175 // 2-pane layout is the main listener of view mode changes, and issues secondary 176 // notifications upon animation completion: 177 // (onConversationVisibilityChanged, onConversationListVisibilityChanged) 178 mViewMode.addListener(mLayout); 179 return super.onCreate(savedState); 180 } 181 182 @Override 183 public void onSaveInstanceState(Bundle outState) { 184 super.onSaveInstanceState(outState); 185 186 outState.putBoolean(SAVED_MISCELLANEOUS_VIEW, mMiscellaneousViewTransactionId >= 0); 187 outState.putInt(SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID, mMiscellaneousViewTransactionId); 188 } 189 190 @Override 191 public void onWindowFocusChanged(boolean hasFocus) { 192 if (hasFocus && !mLayout.isConversationListCollapsed()) { 193 // The conversation list is visible. 194 informCursorVisiblity(true); 195 } 196 } 197 198 @Override 199 public void switchToDefaultInboxOrChangeAccount(Account account) { 200 if (mViewMode.isSearchMode()) { 201 // We are in an activity on top of the main navigation activity. 202 // We need to return to it with a result code that indicates it should navigate to 203 // a different folder. 204 final Intent intent = new Intent(); 205 intent.putExtra(AbstractActivityController.EXTRA_ACCOUNT, account); 206 mActivity.setResult(Activity.RESULT_OK, intent); 207 mActivity.finish(); 208 return; 209 } 210 if (mViewMode.getMode() != ViewMode.CONVERSATION_LIST) { 211 mViewMode.enterConversationListMode(); 212 } 213 super.switchToDefaultInboxOrChangeAccount(account); 214 } 215 216 @Override 217 public void onFolderSelected(Folder folder) { 218 // It's possible that we are not in conversation list mode 219 if (mViewMode.isSearchMode()) { 220 // We are in an activity on top of the main navigation activity. 221 // We need to return to it with a result code that indicates it should navigate to 222 // a different folder. 223 final Intent intent = new Intent(); 224 intent.putExtra(AbstractActivityController.EXTRA_FOLDER, folder); 225 mActivity.setResult(Activity.RESULT_OK, intent); 226 mActivity.finish(); 227 return; 228 } else if (mViewMode.getMode() != ViewMode.CONVERSATION_LIST) { 229 mViewMode.enterConversationListMode(); 230 } 231 232 setHierarchyFolder(folder); 233 super.onFolderSelected(folder); 234 } 235 236 public boolean isDrawerOpen() { 237 final FolderListFragment flf = getFolderListFragment(); 238 return flf != null && !flf.isMinimized(); 239 } 240 241 @Override 242 protected void toggleDrawerState() { 243 final FolderListFragment flf = getFolderListFragment(); 244 if (flf == null) { 245 LogUtils.w(LOG_TAG, "no drawer to toggle open/closed"); 246 return; 247 } 248 flf.setMinimized(!flf.isMinimized()); 249 mLayout.requestLayout(); 250 resetActionBarIcon(); 251 252 final ConversationListFragment clf = getConversationListFragment(); 253 if (clf != null) { 254 clf.setNextFocusLeftId(getClfNextFocusLeftId(flf.isMinimized())); 255 } 256 } 257 258 @Override 259 public void onViewModeChanged(int newMode) { 260 if (!mSavedMiscellaneousView && mMiscellaneousViewTransactionId >= 0) { 261 final FragmentManager fragmentManager = mActivity.getFragmentManager(); 262 fragmentManager.popBackStackImmediate(mMiscellaneousViewTransactionId, 263 FragmentManager.POP_BACK_STACK_INCLUSIVE); 264 mMiscellaneousViewTransactionId = -1; 265 } 266 mSavedMiscellaneousView = false; 267 268 super.onViewModeChanged(newMode); 269 if (!isConversationOnlyMode()) { 270 mFloatingComposeButton.setVisibility(View.VISIBLE); 271 } 272 if (newMode != ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION) { 273 // Clear the wait fragment 274 hideWaitForInitialization(); 275 } 276 // In conversation mode, if the conversation list is not visible, then the user cannot 277 // see the selected conversations. Disable the CAB mode while leaving the selected set 278 // untouched. 279 // When the conversation list is made visible again, try to enable the CAB 280 // mode if any conversations are selected. 281 if (newMode == ViewMode.CONVERSATION || newMode == ViewMode.CONVERSATION_LIST 282 || ViewMode.isAdMode(newMode)) { 283 enableOrDisableCab(); 284 } 285 } 286 287 private @IdRes int getClfNextFocusLeftId(boolean drawerMinimized) { 288 return (drawerMinimized) ? R.id.current_account_avatar : android.R.id.list; 289 } 290 291 @Override 292 public void onConversationVisibilityChanged(boolean visible) { 293 super.onConversationVisibilityChanged(visible); 294 if (!visible) { 295 mPagerController.hide(false /* changeVisibility */); 296 } else if (mConversationToShow != null) { 297 mPagerController.show(mAccount, mFolder, mConversationToShow, 298 false /* changeVisibility */); 299 mConversationToShow = null; 300 } 301 } 302 303 @Override 304 public void onConversationListVisibilityChanged(boolean visible) { 305 super.onConversationListVisibilityChanged(visible); 306 enableOrDisableCab(); 307 } 308 309 @Override 310 public void resetActionBarIcon() { 311 final ActionBar ab = mActivity.getSupportActionBar(); 312 final boolean isChildFolder = getFolder() != null && !Utils.isEmpty(getFolder().parent); 313 if (isConversationOnlyMode() || isChildFolder) { 314 ab.setHomeAsUpIndicator(R.drawable.ic_arrow_back_wht_24dp); 315 ab.setHomeActionContentDescription(0 /* system default */); 316 } else { 317 ab.setHomeAsUpIndicator(R.drawable.ic_drawer); 318 ab.setHomeActionContentDescription( 319 isDrawerOpen() ? R.string.drawer_close : R.string.drawer_open); 320 } 321 } 322 323 /** 324 * Enable or disable the CAB mode based on the visibility of the conversation list fragment. 325 */ 326 private void enableOrDisableCab() { 327 if (mLayout.isConversationListCollapsed()) { 328 disableCabMode(); 329 } else { 330 enableCabMode(); 331 } 332 } 333 334 @Override 335 public void onSetPopulated(ConversationSelectionSet set) { 336 super.onSetPopulated(set); 337 338 boolean showSenderImage = 339 (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE); 340 if (!showSenderImage && mViewMode.isListMode()) { 341 getConversationListFragment().setChoiceNone(); 342 } 343 } 344 345 @Override 346 public void onSetEmpty() { 347 super.onSetEmpty(); 348 349 boolean showSenderImage = 350 (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE); 351 if (!showSenderImage && mViewMode.isListMode()) { 352 getConversationListFragment().revertChoiceMode(); 353 } 354 } 355 356 @Override 357 protected void showConversation(Conversation conversation, boolean markAsRead) { 358 super.showConversation(conversation, markAsRead); 359 360 // 2-pane can ignore inLoaderCallbacks because it doesn't use 361 // FragmentManager.popBackStack(). 362 363 if (mActivity == null) { 364 return; 365 } 366 if (conversation == null) { 367 handleBackPress(); 368 return; 369 } 370 // If conversation list is not visible, then the user cannot see the CAB mode, so exit it. 371 // This is needed here (in addition to during viewmode changes) because orientation changes 372 // while viewing a conversation don't change the viewmode: the mode stays 373 // ViewMode.CONVERSATION and yet the conversation list goes in and out of visibility. 374 enableOrDisableCab(); 375 376 // close the drawer, if open 377 if (isDrawerOpen()) { 378 toggleDrawerState(); 379 } 380 381 // When a mode change is required, wait for onConversationVisibilityChanged(), the signal 382 // that the mode change animation has finished, before rendering the conversation. 383 mConversationToShow = conversation; 384 mCurrentConversationJustPeeking = !markAsRead; 385 386 final int mode = mViewMode.getMode(); 387 LogUtils.i(LOG_TAG, "IN TPC.showConv, oldMode=%s conv=%s", mode, mConversationToShow); 388 if (mode == ViewMode.SEARCH_RESULTS_LIST || mode == ViewMode.SEARCH_RESULTS_CONVERSATION) { 389 mViewMode.enterSearchResultsConversationMode(); 390 } else { 391 mViewMode.enterConversationMode(); 392 } 393 // load the conversation immediately if we're already in conversation mode 394 if (!mLayout.isModeChangePending()) { 395 onConversationVisibilityChanged(true); 396 } else { 397 LogUtils.i(LOG_TAG, "TPC.showConversation will wait for TPL.animationEnd to show!"); 398 } 399 } 400 401 @Override 402 public final void onConversationSelected(Conversation conversation, boolean inLoaderCallbacks) { 403 super.onConversationSelected(conversation, inLoaderCallbacks); 404 // Shift the focus to the conversation in landscape mode 405 mPagerController.focusPager(); 406 } 407 408 @Override 409 public void onConversationFocused(Conversation conversation) { 410 if (mIsTabletLandscape) { 411 showConversation(conversation, false /* markAsRead */); 412 } 413 } 414 415 @Override 416 public void setCurrentConversation(Conversation conversation) { 417 // Order is important! We want to calculate different *before* the superclass changes 418 // mCurrentConversation, so before super.setCurrentConversation(). 419 final long oldId = mCurrentConversation != null ? mCurrentConversation.id : -1; 420 final long newId = conversation != null ? conversation.id : -1; 421 final boolean different = oldId != newId; 422 423 // This call might change mCurrentConversation. 424 super.setCurrentConversation(conversation); 425 426 final ConversationListFragment convList = getConversationListFragment(); 427 if (convList != null && conversation != null) { 428 convList.setSelected(conversation.position, different); 429 } 430 } 431 432 @Override 433 public void showWaitForInitialization() { 434 super.showWaitForInitialization(); 435 436 FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction(); 437 fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); 438 fragmentTransaction.replace(R.id.conversation_list_pane, getWaitFragment(), TAG_WAIT); 439 fragmentTransaction.commitAllowingStateLoss(); 440 } 441 442 @Override 443 protected void hideWaitForInitialization() { 444 final WaitFragment waitFragment = getWaitFragment(); 445 if (waitFragment == null) { 446 // We aren't showing a wait fragment: nothing to do 447 return; 448 } 449 // Remove the existing wait fragment from the back stack. 450 final FragmentTransaction fragmentTransaction = 451 mActivity.getFragmentManager().beginTransaction(); 452 fragmentTransaction.remove(waitFragment); 453 fragmentTransaction.commitAllowingStateLoss(); 454 super.hideWaitForInitialization(); 455 if (mViewMode.isWaitingForSync()) { 456 // We should come out of wait mode and display the account inbox. 457 loadAccountInbox(); 458 } 459 } 460 461 /** 462 * Up works as follows: 463 * 1) If the user is in a conversation and: 464 * a) the conversation list is hidden (portrait mode), shows the conv list and 465 * stays in conversation view mode. 466 * b) the conversation list is shown, goes back to conversation list mode. 467 * 2) If the user is in search results, up exits search. 468 * mode and returns the user to whatever view they were in when they began search. 469 * 3) If the user is in conversation list mode, there is no up. 470 */ 471 @Override 472 public boolean handleUpPress() { 473 if (isConversationOnlyMode()) { 474 handleBackPress(); 475 } else { 476 toggleDrawerState(); 477 } 478 479 return true; 480 } 481 482 @Override 483 public boolean handleBackPress() { 484 // Clear any visible undo bars. 485 mToastBar.hide(false, false /* actionClicked */); 486 if (isDrawerOpen()) { 487 toggleDrawerState(); 488 } else { 489 popView(false); 490 } 491 return true; 492 } 493 494 /** 495 * Pops the "view stack" to the last screen the user was viewing. 496 * 497 * @param preventClose Whether to prevent closing the app if the stack is empty. 498 */ 499 protected void popView(boolean preventClose) { 500 // If the user is in search query entry mode, or the user is viewing 501 // search results, exit 502 // the mode. 503 int mode = mViewMode.getMode(); 504 if (mode == ViewMode.SEARCH_RESULTS_LIST) { 505 mActivity.finish(); 506 } else if (mode == ViewMode.CONVERSATION || mViewMode.isAdMode()) { 507 // Go to conversation list. 508 mViewMode.enterConversationListMode(); 509 } else if (mode == ViewMode.SEARCH_RESULTS_CONVERSATION) { 510 mViewMode.enterSearchResultsListMode(); 511 } else { 512 // The Folder List fragment can be null for monkeys where we get a back before the 513 // folder list has had a chance to initialize. 514 final FolderListFragment folderList = getFolderListFragment(); 515 if (mode == ViewMode.CONVERSATION_LIST && folderList != null 516 && !Folder.isRoot(mFolder)) { 517 // If the user navigated via the left folders list into a child folder, 518 // back should take the user up to the parent folder's conversation list. 519 navigateUpFolderHierarchy(); 520 // Otherwise, if we are in the conversation list but not in the default 521 // inbox and not on expansive layouts, we want to switch back to the default 522 // inbox. This fixes b/9006969 so that on smaller tablets where we have this 523 // hybrid one and two-pane mode, we will return to the inbox. On larger tablets, 524 // we will instead exit the app. 525 } else if (!preventClose) { 526 // There is nothing else to pop off the stack. 527 mActivity.finish(); 528 } 529 } 530 } 531 532 @Override 533 public void exitSearchMode() { 534 final int mode = mViewMode.getMode(); 535 if (mode == ViewMode.SEARCH_RESULTS_LIST 536 || (mode == ViewMode.SEARCH_RESULTS_CONVERSATION 537 && Utils.showTwoPaneSearchResults(mActivity.getApplicationContext()))) { 538 mActivity.finish(); 539 } 540 } 541 542 @Override 543 public boolean shouldShowFirstConversation() { 544 return Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()) 545 && shouldEnterSearchConvMode(); 546 } 547 548 @Override 549 public void onUndoAvailable(ToastBarOperation op) { 550 final int mode = mViewMode.getMode(); 551 final ConversationListFragment convList = getConversationListFragment(); 552 553 switch (mode) { 554 case ViewMode.SEARCH_RESULTS_LIST: 555 case ViewMode.CONVERSATION_LIST: 556 case ViewMode.SEARCH_RESULTS_CONVERSATION: 557 case ViewMode.CONVERSATION: 558 if (convList != null) { 559 mToastBar.show(getUndoClickedListener(convList.getAnimatedAdapter()), 560 Utils.convertHtmlToPlainText 561 (op.getDescription(mActivity.getActivityContext())), 562 R.string.undo, 563 true, /* replaceVisibleToast */ 564 op); 565 } 566 } 567 } 568 569 @Override 570 public void onError(final Folder folder, boolean replaceVisibleToast) { 571 showErrorToast(folder, replaceVisibleToast); 572 } 573 574 @Override 575 public boolean isDrawerEnabled() { 576 // two-pane has its own drawer-like thing that expands inline from a minimized state. 577 return false; 578 } 579 580 @Override 581 public int getFolderListViewChoiceMode() { 582 // By default, we want to allow one item to be selected in the folder list 583 return ListView.CHOICE_MODE_SINGLE; 584 } 585 586 private int mMiscellaneousViewTransactionId = -1; 587 588 @Override 589 public void launchFragment(final Fragment fragment, final int selectPosition) { 590 final int containerViewId = TwoPaneLayout.MISCELLANEOUS_VIEW_ID; 591 592 final FragmentManager fragmentManager = mActivity.getFragmentManager(); 593 if (fragmentManager.findFragmentByTag(TAG_CUSTOM_FRAGMENT) == null) { 594 final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); 595 fragmentTransaction.addToBackStack(null); 596 fragmentTransaction.replace(containerViewId, fragment, TAG_CUSTOM_FRAGMENT); 597 mMiscellaneousViewTransactionId = fragmentTransaction.commitAllowingStateLoss(); 598 fragmentManager.executePendingTransactions(); 599 } 600 601 if (selectPosition >= 0) { 602 getConversationListFragment().setRawSelected(selectPosition, true); 603 } 604 } 605 606 @Override 607 public boolean onInterceptCVDownEvent() { 608 // handle a down event on CV by closing the drawer if open 609 if (isDrawerOpen()) { 610 toggleDrawerState(); 611 return true; 612 } 613 return false; 614 } 615 616 @Override 617 public boolean onInterceptKeyFromCV(int keyCode, KeyEvent keyEvent, boolean navigateAway) { 618 // Override left/right key presses in landscape mode. 619 if (navigateAway) { 620 if (keyEvent.getAction() == KeyEvent.ACTION_UP) { 621 ConversationListFragment clf = getConversationListFragment(); 622 if (clf != null) { 623 clf.getListView().requestFocus(); 624 } 625 } 626 return true; 627 } 628 return false; 629 } 630 631 @Override 632 public boolean isTwoPaneLandscape() { 633 return mIsTabletLandscape; 634 } 635 } 636