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.animation.Animator; 21 import android.animation.AnimatorListenerAdapter; 22 import android.app.Activity; 23 import android.app.Fragment; 24 import android.app.FragmentManager; 25 import android.app.FragmentTransaction; 26 import android.content.Intent; 27 import android.os.Bundle; 28 import android.support.annotation.LayoutRes; 29 import android.support.v4.widget.DrawerLayout; 30 import android.view.Gravity; 31 import android.view.KeyEvent; 32 import android.view.View; 33 import android.widget.ListView; 34 35 import com.android.mail.ConversationListContext; 36 import com.android.mail.R; 37 import com.android.mail.providers.Account; 38 import com.android.mail.providers.Conversation; 39 import com.android.mail.providers.Folder; 40 import com.android.mail.providers.UIProvider; 41 import com.android.mail.utils.FolderUri; 42 import com.android.mail.utils.Utils; 43 44 /** 45 * Controller for one-pane Mail activity. One Pane is used for phones, where screen real estate is 46 * limited. This controller also does the layout, since the layout is simpler in the one pane case. 47 */ 48 49 public final class OnePaneController extends AbstractActivityController { 50 /** Key used to store {@link #mLastConversationListTransactionId} */ 51 private static final String CONVERSATION_LIST_TRANSACTION_KEY = "conversation-list-transaction"; 52 /** Key used to store {@link #mLastConversationTransactionId}. */ 53 private static final String CONVERSATION_TRANSACTION_KEY = "conversation-transaction"; 54 /** Key used to store {@link #mConversationListVisible}. */ 55 private static final String CONVERSATION_LIST_VISIBLE_KEY = "conversation-list-visible"; 56 /** Key used to store {@link #mConversationListNeverShown}. */ 57 private static final String CONVERSATION_LIST_NEVER_SHOWN_KEY = "conversation-list-never-shown"; 58 59 private static final int INVALID_ID = -1; 60 private boolean mConversationListVisible = false; 61 private int mLastConversationListTransactionId = INVALID_ID; 62 private int mLastConversationTransactionId = INVALID_ID; 63 /** Whether a conversation list for this account has ever been shown.*/ 64 private boolean mConversationListNeverShown = true; 65 66 /** 67 * Listener for pager animation to complete and then remove the TL fragment. 68 * This is a work-around for fragment remove animation not working as intended, so we 69 * still get feedback on conversation item tap in the transition from TL to CV. 70 */ 71 private final AnimatorListenerAdapter mPagerAnimationListener = 72 new AnimatorListenerAdapter() { 73 @Override 74 public void onAnimationEnd(Animator animation) { 75 // Make sure that while we were animating, the mode did not change back 76 // If it's still in conversation view mode, remove the TL fragment from behind 77 if (mViewMode.isConversationMode()) { 78 // Once the pager is done animating in, we are ready to remove the 79 // conversation list fragment. Since we track the fragment by either what's 80 // in content_pane or by the tag, we grab it and remove without animations 81 // since it's already covered by the conversation view and its white bg. 82 final FragmentManager fm = mActivity.getFragmentManager(); 83 final FragmentTransaction ft = fm.beginTransaction(); 84 final Fragment f = fm.findFragmentById(R.id.content_pane); 85 // FragmentManager#findFragmentById can return fragments that are not 86 // added to the activity. We want to make sure that we don't attempt to 87 // remove fragments that are not added to the activity, as when the 88 // transaction is popped off, the FragmentManager will attempt to read 89 // the same fragment twice. 90 if (f != null && f.isAdded()) { 91 ft.remove(f); 92 ft.commitAllowingStateLoss(); 93 fm.executePendingTransactions(); 94 } 95 } 96 } 97 }; 98 OnePaneController(MailActivity activity, ViewMode viewMode)99 public OnePaneController(MailActivity activity, ViewMode viewMode) { 100 super(activity, viewMode); 101 } 102 103 @Override onRestoreInstanceState(Bundle inState)104 public void onRestoreInstanceState(Bundle inState) { 105 super.onRestoreInstanceState(inState); 106 if (inState == null) { 107 return; 108 } 109 mLastConversationListTransactionId = 110 inState.getInt(CONVERSATION_LIST_TRANSACTION_KEY, INVALID_ID); 111 mLastConversationTransactionId = inState.getInt(CONVERSATION_TRANSACTION_KEY, INVALID_ID); 112 mConversationListVisible = inState.getBoolean(CONVERSATION_LIST_VISIBLE_KEY); 113 mConversationListNeverShown = inState.getBoolean(CONVERSATION_LIST_NEVER_SHOWN_KEY); 114 } 115 116 @Override onSaveInstanceState(Bundle outState)117 public void onSaveInstanceState(Bundle outState) { 118 super.onSaveInstanceState(outState); 119 outState.putInt(CONVERSATION_LIST_TRANSACTION_KEY, mLastConversationListTransactionId); 120 outState.putInt(CONVERSATION_TRANSACTION_KEY, mLastConversationTransactionId); 121 outState.putBoolean(CONVERSATION_LIST_VISIBLE_KEY, mConversationListVisible); 122 outState.putBoolean(CONVERSATION_LIST_NEVER_SHOWN_KEY, mConversationListNeverShown); 123 } 124 125 @Override resetActionBarIcon()126 public void resetActionBarIcon() { 127 // Calling resetActionBarIcon should never remove the up affordance 128 // even when waiting for sync (Folder list should still show with one 129 // account. Currently this method is blank to avoid any changes. 130 } 131 132 /** 133 * Returns true if the candidate URI is the URI for the default inbox for the given account. 134 * @param candidate the URI to check 135 * @param account the account whose default Inbox the candidate might be 136 * @return true if the candidate is indeed the default inbox for the given account. 137 */ isDefaultInbox(FolderUri candidate, Account account)138 private static boolean isDefaultInbox(FolderUri candidate, Account account) { 139 return (candidate != null && account != null) 140 && candidate.equals(account.settings.defaultInbox); 141 } 142 143 /** 144 * Returns true if the user is currently in the conversation list view, viewing the default 145 * inbox. 146 * @return true if user is in conversation list mode, viewing the default inbox. 147 */ inInbox(final Account account, final ConversationListContext context)148 private static boolean inInbox(final Account account, final ConversationListContext context) { 149 // If we don't have valid state, then we are not in the inbox. 150 return !(account == null || context == null || context.folder == null 151 || account.settings == null) && !ConversationListContext.isSearchResult(context) 152 && isDefaultInbox(context.folder.folderUri, account); 153 } 154 155 /** 156 * On account change, carry out super implementation, load FolderListFragment 157 * into drawer (to avoid repetitive calls to replaceFragment). 158 */ 159 @Override changeAccount(Account account)160 public void changeAccount(Account account) { 161 super.changeAccount(account); 162 mConversationListNeverShown = true; 163 closeDrawerIfOpen(); 164 } 165 166 @Override getContentViewResource()167 public @LayoutRes int getContentViewResource() { 168 return R.layout.one_pane_activity; 169 } 170 171 @Override onCreate(Bundle savedInstanceState)172 public void onCreate(Bundle savedInstanceState) { 173 mDrawerContainer = (DrawerLayout) mActivity.findViewById(R.id.drawer_container); 174 mDrawerContainer.setDrawerTitle(Gravity.START, 175 mActivity.getActivityContext().getString(R.string.drawer_title)); 176 mDrawerContainer.setStatusBarBackground(R.color.primary_dark_color); 177 final String drawerPulloutTag = mActivity.getString(R.string.drawer_pullout_tag); 178 mDrawerPullout = mDrawerContainer.findViewWithTag(drawerPulloutTag); 179 mDrawerPullout.setBackgroundResource(R.color.list_background_color); 180 181 // CV is initially GONE on 1-pane (mode changes trigger visibility changes) 182 mActivity.findViewById(R.id.conversation_pager).setVisibility(View.GONE); 183 184 // The parent class sets the correct viewmode and starts the application off. 185 super.onCreate(savedInstanceState); 186 } 187 188 @Override findActionableToastBar(MailActivity activity)189 protected ActionableToastBar findActionableToastBar(MailActivity activity) { 190 final ActionableToastBar tb = super.findActionableToastBar(activity); 191 192 // notify the toast bar of its sibling floating action button so it can move them together 193 // as they animate 194 tb.setFloatingActionButton(activity.findViewById(R.id.compose_button)); 195 return tb; 196 } 197 198 @Override isConversationListVisible()199 protected boolean isConversationListVisible() { 200 return mConversationListVisible; 201 } 202 203 @Override onViewModeChanged(int newMode)204 public void onViewModeChanged(int newMode) { 205 super.onViewModeChanged(newMode); 206 207 // When entering conversation list mode, hide and clean up any currently visible 208 // conversation. 209 if (ViewMode.isListMode(newMode)) { 210 mPagerController.hide(true /* changeVisibility */); 211 } 212 213 if (ViewMode.isAdMode(newMode)) { 214 onConversationListVisibilityChanged(false); 215 } 216 217 // When we step away from the conversation mode, we don't have a current conversation 218 // anymore. Let's blank it out so clients calling getCurrentConversation are not misled. 219 if (!ViewMode.isConversationMode(newMode)) { 220 setCurrentConversation(null); 221 } 222 } 223 224 @Override appendToString(StringBuilder sb)225 protected void appendToString(StringBuilder sb) { 226 sb.append(" lastConvListTransId="); 227 sb.append(mLastConversationListTransactionId); 228 } 229 230 @Override showConversationList(ConversationListContext listContext)231 protected void showConversationList(ConversationListContext listContext) { 232 enableCabMode(); 233 mConversationListVisible = true; 234 if (ConversationListContext.isSearchResult(listContext)) { 235 mViewMode.enterSearchResultsListMode(); 236 } else { 237 mViewMode.enterConversationListMode(); 238 } 239 final int transition = mConversationListNeverShown 240 ? FragmentTransaction.TRANSIT_FRAGMENT_FADE 241 : FragmentTransaction.TRANSIT_FRAGMENT_OPEN; 242 final Fragment conversationListFragment = 243 ConversationListFragment.newInstance(listContext); 244 245 if (!inInbox(mAccount, listContext)) { 246 // Maintain fragment transaction history so we can get back to the 247 // fragment used to launch this list. 248 mLastConversationListTransactionId = replaceFragment(conversationListFragment, 249 transition, TAG_CONVERSATION_LIST, R.id.content_pane); 250 } else { 251 // If going to the inbox, clear the folder list transaction history. 252 mInbox = listContext.folder; 253 replaceFragment(conversationListFragment, transition, TAG_CONVERSATION_LIST, 254 R.id.content_pane); 255 256 // If we ever to to the inbox, we want to unset the transation id for any other 257 // non-inbox folder. 258 mLastConversationListTransactionId = INVALID_ID; 259 } 260 261 mActivity.getFragmentManager().executePendingTransactions(); 262 263 onConversationVisibilityChanged(false); 264 onConversationListVisibilityChanged(true); 265 mConversationListNeverShown = false; 266 } 267 268 /** 269 * Override showConversation with animation parameter so that we animate in the pager when 270 * selecting in the conversation, but don't animate on opening the app from an intent. 271 * @param conversation 272 * @param shouldAnimate true if we want to animate the conversation in, false otherwise 273 */ 274 @Override showConversation(Conversation conversation, boolean shouldAnimate)275 protected void showConversation(Conversation conversation, boolean shouldAnimate) { 276 super.showConversation(conversation, shouldAnimate); 277 278 mConversationListVisible = false; 279 if (conversation == null) { 280 transitionBackToConversationListMode(); 281 return; 282 } 283 disableCabMode(); 284 if (ConversationListContext.isSearchResult(mConvListContext)) { 285 mViewMode.enterSearchResultsConversationMode(); 286 } else { 287 mViewMode.enterConversationMode(); 288 } 289 290 mPagerController.show(mAccount, mFolder, conversation, true /* changeVisibility */, 291 shouldAnimate? mPagerAnimationListener : null); 292 onConversationVisibilityChanged(true); 293 onConversationListVisibilityChanged(false); 294 } 295 296 @Override onConversationFocused(Conversation conversation)297 public void onConversationFocused(Conversation conversation) { 298 // Do nothing 299 } 300 301 @Override showWaitForInitialization()302 protected void showWaitForInitialization() { 303 super.showWaitForInitialization(); 304 replaceFragment(getWaitFragment(), FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT, 305 R.id.content_pane); 306 } 307 308 @Override hideWaitForInitialization()309 protected void hideWaitForInitialization() { 310 transitionToInbox(); 311 super.hideWaitForInitialization(); 312 } 313 314 /** 315 * Switch to the Inbox by creating a new conversation list context that loads the inbox. 316 */ transitionToInbox()317 private void transitionToInbox() { 318 // The inbox could have changed, in which case we should load it again. 319 if (mInbox == null || !isDefaultInbox(mInbox.folderUri, mAccount)) { 320 loadAccountInbox(); 321 } else { 322 onFolderChanged(mInbox, false /* force */); 323 } 324 } 325 326 @Override doesActionChangeConversationListVisibility(final int action)327 public boolean doesActionChangeConversationListVisibility(final int action) { 328 if (action == R.id.archive 329 || action == R.id.remove_folder 330 || action == R.id.delete 331 || action == R.id.discard_drafts 332 || action == R.id.discard_outbox 333 || action == R.id.mark_important 334 || action == R.id.mark_not_important 335 || action == R.id.mute 336 || action == R.id.report_spam 337 || action == R.id.mark_not_spam 338 || action == R.id.report_phishing 339 || action == R.id.refresh 340 || action == R.id.change_folders) { 341 return false; 342 } else { 343 return true; 344 } 345 } 346 347 /** 348 * Replace the content_pane with the fragment specified here. The tag is specified so that 349 * the {@link ActivityController} can look up the fragments through the 350 * {@link android.app.FragmentManager}. 351 * @param fragment the new fragment to put 352 * @param transition the transition to show 353 * @param tag a tag for the fragment manager. 354 * @param anchor ID of view to replace fragment in 355 * @return transaction ID returned when the transition is committed. 356 */ replaceFragment(Fragment fragment, int transition, String tag, int anchor)357 private int replaceFragment(Fragment fragment, int transition, String tag, int anchor) { 358 final FragmentManager fm = mActivity.getFragmentManager(); 359 FragmentTransaction fragmentTransaction = fm.beginTransaction(); 360 fragmentTransaction.setTransition(transition); 361 fragmentTransaction.replace(anchor, fragment, tag); 362 final int id = fragmentTransaction.commitAllowingStateLoss(); 363 fm.executePendingTransactions(); 364 return id; 365 } 366 367 /** 368 * Back works as follows: 369 * 1) If the drawer is pulled out (Or mid-drag), close it - handled. 370 * 2) If the user is in the folder list view, go back 371 * to the account default inbox. 372 * 3) If the user is in a conversation list 373 * that is not the inbox AND: 374 * a) they got there by going through the folder 375 * list view, go back to the folder list view. 376 * b) they got there by using some other means (account dropdown), go back to the inbox. 377 * 4) If the user is in a conversation, go back to the conversation list they were last in. 378 * 5) If the user is in the conversation list for the default account inbox, 379 * back exits the app. 380 */ 381 @Override handleBackPress()382 public boolean handleBackPress() { 383 final int mode = mViewMode.getMode(); 384 385 if (mode == ViewMode.SEARCH_RESULTS_LIST) { 386 mActivity.finish(); 387 } else if (mViewMode.isListMode() && !inInbox(mAccount, mConvListContext)) { 388 navigateUpFolderHierarchy(); 389 } else if (mViewMode.isConversationMode() || mViewMode.isAdMode()) { 390 transitionBackToConversationListMode(); 391 } else { 392 mActivity.finish(); 393 } 394 mToastBar.hide(false, false /* actionClicked */); 395 return true; 396 } 397 398 @Override onFolderSelected(Folder folder)399 public void onFolderSelected(Folder folder) { 400 if (mViewMode.isSearchMode()) { 401 // We are in an activity on top of the main navigation activity. 402 // We need to return to it with a result code that indicates it should navigate to 403 // a different folder. 404 final Intent intent = new Intent(); 405 intent.putExtra(AbstractActivityController.EXTRA_FOLDER, folder); 406 mActivity.setResult(Activity.RESULT_OK, intent); 407 mActivity.finish(); 408 return; 409 } 410 setHierarchyFolder(folder); 411 super.onFolderSelected(folder); 412 } 413 414 /** 415 * Up works as follows: 416 * 1) If the user is in a conversation list that is not the default account inbox, 417 * a conversation, or the folder list, up follows the rules of back. 418 * 2) If the user is in search results, up exits search 419 * mode and returns the user to whatever view they were in when they began search. 420 * 3) If the user is in the inbox, there is no up. 421 */ 422 @Override handleUpPress()423 public boolean handleUpPress() { 424 final int mode = mViewMode.getMode(); 425 if (mode == ViewMode.SEARCH_RESULTS_LIST) { 426 mActivity.finish(); 427 // Not needed, the activity is going away anyway. 428 } else if (mode == ViewMode.CONVERSATION_LIST 429 || mode == ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION) { 430 final boolean isTopLevel = Folder.isRoot(mFolder); 431 432 if (isTopLevel) { 433 // Show the drawer. 434 toggleDrawerState(); 435 } else { 436 navigateUpFolderHierarchy(); 437 } 438 } else if (mode == ViewMode.CONVERSATION || mode == ViewMode.SEARCH_RESULTS_CONVERSATION 439 || mode == ViewMode.AD) { 440 // Same as go back. 441 handleBackPress(); 442 } 443 return true; 444 } 445 transitionBackToConversationListMode()446 private void transitionBackToConversationListMode() { 447 final int mode = mViewMode.getMode(); 448 enableCabMode(); 449 mConversationListVisible = true; 450 if (mode == ViewMode.SEARCH_RESULTS_CONVERSATION) { 451 mViewMode.enterSearchResultsListMode(); 452 } else { 453 mViewMode.enterConversationListMode(); 454 } 455 456 final Folder folder = mFolder != null ? mFolder : mInbox; 457 onFolderChanged(folder, true /* force */); 458 459 onConversationVisibilityChanged(false); 460 onConversationListVisibilityChanged(true); 461 } 462 463 @Override shouldShowFirstConversation()464 public boolean shouldShowFirstConversation() { 465 return false; 466 } 467 468 @Override onUndoAvailable(ToastBarOperation op)469 public void onUndoAvailable(ToastBarOperation op) { 470 if (op != null && mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO)) { 471 final int mode = mViewMode.getMode(); 472 final ConversationListFragment convList = getConversationListFragment(); 473 switch (mode) { 474 case ViewMode.SEARCH_RESULTS_CONVERSATION: 475 case ViewMode.CONVERSATION: 476 mToastBar.show(getUndoClickedListener( 477 convList != null ? convList.getAnimatedAdapter() : null), 478 Utils.convertHtmlToPlainText 479 (op.getDescription(mActivity.getActivityContext())), 480 R.string.undo, 481 true /* replaceVisibleToast */, 482 true /* autohide */, 483 op); 484 break; 485 case ViewMode.SEARCH_RESULTS_LIST: 486 case ViewMode.CONVERSATION_LIST: 487 if (convList != null) { 488 mToastBar.show( 489 getUndoClickedListener(convList.getAnimatedAdapter()), 490 Utils.convertHtmlToPlainText 491 (op.getDescription(mActivity.getActivityContext())), 492 R.string.undo, 493 true /* replaceVisibleToast */, 494 true /* autohide */, 495 op); 496 } else { 497 mActivity.setPendingToastOperation(op); 498 } 499 break; 500 } 501 } 502 } 503 504 @Override onError(final Folder folder, boolean replaceVisibleToast)505 public void onError(final Folder folder, boolean replaceVisibleToast) { 506 final int mode = mViewMode.getMode(); 507 switch (mode) { 508 case ViewMode.SEARCH_RESULTS_LIST: 509 case ViewMode.CONVERSATION_LIST: 510 showErrorToast(folder, replaceVisibleToast); 511 break; 512 default: 513 break; 514 } 515 } 516 517 @Override isDrawerEnabled()518 public boolean isDrawerEnabled() { 519 // The drawer is enabled for one pane mode 520 return true; 521 } 522 523 @Override getFolderListViewChoiceMode()524 public int getFolderListViewChoiceMode() { 525 // By default, we do not want to allow any item to be selected in the folder list 526 return ListView.CHOICE_MODE_NONE; 527 } 528 529 @Override launchFragment(final Fragment fragment, final int selectPosition)530 public void launchFragment(final Fragment fragment, final int selectPosition) { 531 replaceFragment(fragment, FragmentTransaction.TRANSIT_FRAGMENT_OPEN, 532 TAG_CUSTOM_FRAGMENT, R.id.content_pane); 533 } 534 535 @Override onInterceptKeyFromCV(int keyCode, KeyEvent keyEvent, boolean navigateAway)536 public boolean onInterceptKeyFromCV(int keyCode, KeyEvent keyEvent, boolean navigateAway) { 537 // Not applicable 538 return false; 539 } 540 541 @Override isTwoPaneLandscape()542 public boolean isTwoPaneLandscape() { 543 return false; 544 } 545 546 @Override shouldShowSearchBarByDefault(int viewMode)547 public boolean shouldShowSearchBarByDefault(int viewMode) { 548 return viewMode == ViewMode.SEARCH_RESULTS_LIST; 549 } 550 551 @Override shouldShowSearchMenuItem()552 public boolean shouldShowSearchMenuItem() { 553 return mViewMode.getMode() == ViewMode.CONVERSATION_LIST; 554 } 555 556 @Override addConversationListLayoutListener( TwoPaneLayout.ConversationListLayoutListener listener)557 public void addConversationListLayoutListener( 558 TwoPaneLayout.ConversationListLayoutListener listener) { 559 // Do nothing 560 } 561 562 } 563