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.animation.LayoutTransition; 21import android.app.Activity; 22import android.app.Fragment; 23import android.app.LoaderManager; 24import android.content.res.Resources; 25import android.database.DataSetObserver; 26import android.os.Bundle; 27import android.os.Handler; 28import android.os.Parcelable; 29import android.support.annotation.IdRes; 30import android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener; 31import android.view.KeyEvent; 32import android.view.LayoutInflater; 33import android.view.View; 34import android.view.ViewGroup; 35import android.widget.AdapterView; 36import android.widget.AdapterView.OnItemLongClickListener; 37import android.widget.ListView; 38import android.widget.TextView; 39 40import com.android.mail.ConversationListContext; 41import com.android.mail.R; 42import com.android.mail.analytics.Analytics; 43import com.android.mail.analytics.AnalyticsTimer; 44import com.android.mail.browse.ConversationCursor; 45import com.android.mail.browse.ConversationItemView; 46import com.android.mail.browse.ConversationItemViewModel; 47import com.android.mail.browse.ConversationListFooterView; 48import com.android.mail.browse.ToggleableItem; 49import com.android.mail.providers.Account; 50import com.android.mail.providers.AccountObserver; 51import com.android.mail.providers.Conversation; 52import com.android.mail.providers.Folder; 53import com.android.mail.providers.FolderObserver; 54import com.android.mail.providers.Settings; 55import com.android.mail.providers.UIProvider; 56import com.android.mail.providers.UIProvider.AccountCapabilities; 57import com.android.mail.providers.UIProvider.ConversationListIcon; 58import com.android.mail.providers.UIProvider.FolderCapabilities; 59import com.android.mail.providers.UIProvider.Swipe; 60import com.android.mail.ui.SwipeableListView.ListItemSwipedListener; 61import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener; 62import com.android.mail.ui.SwipeableListView.SwipeListener; 63import com.android.mail.ui.ViewMode.ModeChangeListener; 64import com.android.mail.utils.LogTag; 65import com.android.mail.utils.LogUtils; 66import com.android.mail.utils.Utils; 67import com.google.common.collect.ImmutableList; 68 69import java.util.Collection; 70import java.util.List; 71 72import static android.view.View.OnKeyListener; 73 74/** 75 * The conversation list UI component. 76 */ 77public final class ConversationListFragment extends Fragment implements 78 OnItemLongClickListener, ModeChangeListener, ListItemSwipedListener, OnRefreshListener, 79 SwipeListener, OnKeyListener, AdapterView.OnItemClickListener { 80 /** Key used to pass data to {@link ConversationListFragment}. */ 81 private static final String CONVERSATION_LIST_KEY = "conversation-list"; 82 /** Key used to keep track of the scroll state of the list. */ 83 private static final String LIST_STATE_KEY = "list-state"; 84 85 private static final String LOG_TAG = LogTag.getLogTag(); 86 /** Key used to save the ListView choice mode, since ListView doesn't save it automatically! */ 87 private static final String CHOICE_MODE_KEY = "choice-mode-key"; 88 89 // True if we are on a tablet device 90 private static boolean mTabletDevice; 91 92 // Delay before displaying the loading view. 93 private static int LOADING_DELAY_MS; 94 // Minimum amount of time to keep the loading view displayed. 95 private static int MINIMUM_LOADING_DURATION; 96 97 /** 98 * Frequency of update of timestamps. Initialized in 99 * {@link #onCreate(Bundle)} and final afterwards. 100 */ 101 private static int TIMESTAMP_UPDATE_INTERVAL = 0; 102 103 private ControllableActivity mActivity; 104 105 // Control state. 106 private ConversationListCallbacks mCallbacks; 107 108 private final Handler mHandler = new Handler(); 109 110 // The internal view objects. 111 private SwipeableListView mListView; 112 113 private View mSearchHeaderView; 114 private TextView mSearchResultCountTextView; 115 116 /** 117 * Current Account being viewed 118 */ 119 private Account mAccount; 120 /** 121 * Current folder being viewed. 122 */ 123 private Folder mFolder; 124 125 /** 126 * A simple method to update the timestamps of conversations periodically. 127 */ 128 private Runnable mUpdateTimestampsRunnable = null; 129 130 private ConversationListContext mViewContext; 131 132 private AnimatedAdapter mListAdapter; 133 134 private ConversationListFooterView mFooterView; 135 private ConversationListEmptyView mEmptyView; 136 private View mLoadingView; 137 private ErrorListener mErrorListener; 138 private FolderObserver mFolderObserver; 139 private DataSetObserver mConversationCursorObserver; 140 141 private ConversationSelectionSet mSelectedSet; 142 private final AccountObserver mAccountObserver = new AccountObserver() { 143 @Override 144 public void onChanged(Account newAccount) { 145 mAccount = newAccount; 146 setSwipeAction(); 147 } 148 }; 149 private ConversationUpdater mUpdater; 150 /** Hash of the Conversation Cursor we last obtained from the controller. */ 151 private int mConversationCursorHash; 152 // The number of items in the last known ConversationCursor 153 private int mConversationCursorLastCount; 154 // State variable to keep track if we just loaded a new list, used for analytics only 155 // True if NO DATA has returned, false if we either partially or fully loaded the data 156 private boolean mInitialCursorLoading; 157 158 private @IdRes int mNextFocusLeftId; 159 // Tracks if a onKey event was initiated from the listview (received ACTION_DOWN before 160 // ACTION_UP). If not, the listview only receives ACTION_UP. 161 private boolean mKeyInitiatedFromList; 162 163 /** Duration, in milliseconds, of the CAB mode (peek icon) animation. */ 164 private static long sSelectionModeAnimationDuration = -1; 165 166 // Let's ensure that we are only showing one out of the three views at once 167 private void showListView() { 168 mListView.setVisibility(View.VISIBLE); 169 mEmptyView.setVisibility(View.INVISIBLE); 170 mLoadingView.setVisibility(View.INVISIBLE); 171 } 172 173 private void showEmptyView() { 174 mEmptyView.setupEmptyView( 175 mFolder, mViewContext.searchQuery, mListAdapter.getBidiFormatter()); 176 mListView.setVisibility(View.INVISIBLE); 177 mEmptyView.setVisibility(View.VISIBLE); 178 mLoadingView.setVisibility(View.INVISIBLE); 179 } 180 181 private void showLoadingView() { 182 mListView.setVisibility(View.INVISIBLE); 183 mEmptyView.setVisibility(View.INVISIBLE); 184 mLoadingView.setVisibility(View.VISIBLE); 185 } 186 187 private final Runnable mLoadingViewRunnable = new FragmentRunnable("LoadingRunnable", this) { 188 @Override 189 public void go() { 190 if (!isCursorReadyToShow()) { 191 mCanTakeDownLoadingView = false; 192 showLoadingView(); 193 mHandler.removeCallbacks(mHideLoadingRunnable); 194 mHandler.postDelayed(mHideLoadingRunnable, MINIMUM_LOADING_DURATION); 195 } 196 mLoadingViewPending = false; 197 } 198 }; 199 200 private final Runnable mHideLoadingRunnable = new FragmentRunnable("CancelLoading", this) { 201 @Override 202 public void go() { 203 mCanTakeDownLoadingView = true; 204 if (isCursorReadyToShow()) { 205 hideLoadingViewAndShowContents(); 206 } 207 } 208 }; 209 210 // Keep track of if we are waiting for the loading view. This variable is also used to check 211 // if the cursor corresponding to the current folder loaded (either partially or completely). 212 private boolean mLoadingViewPending; 213 private boolean mCanTakeDownLoadingView; 214 215 /** 216 * If <code>true</code>, we have restored (or attempted to restore) the list's scroll position 217 * from when we were last on this conversation list. 218 */ 219 private boolean mScrollPositionRestored = false; 220 private MailSwipeRefreshLayout mSwipeRefreshWidget; 221 222 /** 223 * Constructor needs to be public to handle orientation changes and activity 224 * lifecycle events. 225 */ 226 public ConversationListFragment() { 227 super(); 228 } 229 230 @Override 231 public void onBeginSwipe() { 232 mSwipeRefreshWidget.setEnabled(false); 233 } 234 235 @Override 236 public void onEndSwipe() { 237 mSwipeRefreshWidget.setEnabled(true); 238 } 239 240 private class ConversationCursorObserver extends DataSetObserver { 241 @Override 242 public void onChanged() { 243 onConversationListStatusUpdated(); 244 } 245 } 246 247 /** 248 * Creates a new instance of {@link ConversationListFragment}, initialized 249 * to display conversation list context. 250 */ 251 public static ConversationListFragment newInstance(ConversationListContext viewContext) { 252 final ConversationListFragment fragment = new ConversationListFragment(); 253 final Bundle args = new Bundle(1); 254 args.putBundle(CONVERSATION_LIST_KEY, viewContext.toBundle()); 255 fragment.setArguments(args); 256 return fragment; 257 } 258 259 /** 260 * Show the header if the current conversation list is showing search 261 * results. 262 */ 263 private void updateSearchResultHeader(int count) { 264 if (mActivity == null || mSearchHeaderView == null) { 265 return; 266 } 267 mSearchResultCountTextView.setText( 268 getResources().getString(R.string.search_results_loaded, count)); 269 } 270 271 @Override 272 public void onActivityCreated(Bundle savedState) { 273 super.onActivityCreated(savedState); 274 mLoadingViewPending = false; 275 mCanTakeDownLoadingView = true; 276 if (sSelectionModeAnimationDuration < 0) { 277 sSelectionModeAnimationDuration = getResources().getInteger( 278 R.integer.conv_item_view_cab_anim_duration); 279 } 280 281 // Strictly speaking, we get back an android.app.Activity from 282 // getActivity. However, the 283 // only activity creating a ConversationListContext is a MailActivity 284 // which is of type 285 // ControllableActivity, so this cast should be safe. If this cast 286 // fails, some other 287 // activity is creating ConversationListFragments. This activity must be 288 // of type 289 // ControllableActivity. 290 final Activity activity = getActivity(); 291 if (!(activity instanceof ControllableActivity)) { 292 LogUtils.e(LOG_TAG, "ConversationListFragment expects only a ControllableActivity to" 293 + "create it. Cannot proceed."); 294 } 295 mActivity = (ControllableActivity) activity; 296 // Since we now have a controllable activity, load the account from it, 297 // and register for 298 // future account changes. 299 mAccount = mAccountObserver.initialize(mActivity.getAccountController()); 300 mCallbacks = mActivity.getListHandler(); 301 mErrorListener = mActivity.getErrorListener(); 302 // Start off with the current state of the folder being viewed. 303 final LayoutInflater inflater = LayoutInflater.from(mActivity.getActivityContext()); 304 mFooterView = (ConversationListFooterView) inflater.inflate( 305 R.layout.conversation_list_footer_view, null); 306 mFooterView.setClickListener(mActivity); 307 final ConversationCursor conversationCursor = getConversationListCursor(); 308 final LoaderManager manager = getLoaderManager(); 309 310 // TODO: These special views are always created, doesn't matter whether they will 311 // be shown or not, as we add more views this will get more expensive. Given these are 312 // tips that are only shown once to the user, we should consider creating these on demand. 313 final ConversationListHelper helper = mActivity.getConversationListHelper(); 314 final List<ConversationSpecialItemView> specialItemViews = helper != null ? 315 ImmutableList.copyOf(helper.makeConversationListSpecialViews( 316 activity, mActivity, mAccount)) 317 : null; 318 if (specialItemViews != null) { 319 // Attach to the LoaderManager 320 for (final ConversationSpecialItemView view : specialItemViews) { 321 view.bindFragment(manager, savedState); 322 } 323 } 324 325 mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor, 326 mActivity.getSelectedSet(), mActivity, mListView, specialItemViews); 327 mListAdapter.addFooter(mFooterView); 328 // Show search result header only if we are in search mode 329 final boolean showSearchHeader = ConversationListContext.isSearchResult(mViewContext); 330 if (showSearchHeader) { 331 mSearchHeaderView = inflater.inflate(R.layout.search_results_view, null); 332 mSearchResultCountTextView = (TextView) 333 mSearchHeaderView.findViewById(R.id.search_result_count_view); 334 mListAdapter.addHeader(mSearchHeaderView); 335 } 336 337 mListView.setAdapter(mListAdapter); 338 mSelectedSet = mActivity.getSelectedSet(); 339 mListView.setSelectionSet(mSelectedSet); 340 mListAdapter.setFooterVisibility(false); 341 mFolderObserver = new FolderObserver(){ 342 @Override 343 public void onChanged(Folder newFolder) { 344 onFolderUpdated(newFolder); 345 } 346 }; 347 mFolderObserver.initialize(mActivity.getFolderController()); 348 mConversationCursorObserver = new ConversationCursorObserver(); 349 mUpdater = mActivity.getConversationUpdater(); 350 mUpdater.registerConversationListObserver(mConversationCursorObserver); 351 mTabletDevice = Utils.useTabletUI(mActivity.getApplicationContext().getResources()); 352 // The onViewModeChanged callback doesn't get called when the mode 353 // object is created, so 354 // force setting the mode manually this time around. 355 onViewModeChanged(mActivity.getViewMode().getMode()); 356 mActivity.getViewMode().addListener(this); 357 358 if (mActivity.isFinishing()) { 359 // Activity is finishing, just bail. 360 return; 361 } 362 mConversationCursorHash = (conversationCursor == null) ? 0 : conversationCursor.hashCode(); 363 // Belt and suspenders here; make sure we do any necessary sync of the 364 // ConversationCursor 365 if (conversationCursor != null && conversationCursor.isRefreshReady()) { 366 conversationCursor.sync(); 367 } 368 369 // On a phone we never highlight a conversation, so the default is to select none. 370 // On a tablet, we highlight a SINGLE conversation in landscape conversation view. 371 int choice = getDefaultChoiceMode(mTabletDevice); 372 if (savedState != null) { 373 // Restore the choice mode if it was set earlier, or NONE if creating a fresh view. 374 // Choice mode here represents the current conversation only. CAB mode does not rely on 375 // the platform: checked state is a local variable {@link ConversationItemView#mChecked} 376 choice = savedState.getInt(CHOICE_MODE_KEY, choice); 377 if (savedState.containsKey(LIST_STATE_KEY)) { 378 // TODO: find a better way to unset the selected item when restoring 379 mListView.clearChoices(); 380 } 381 } 382 setChoiceMode(choice); 383 384 // Show list and start loading list. 385 showList(); 386 ToastBarOperation pendingOp = mActivity.getPendingToastOperation(); 387 if (pendingOp != null) { 388 // Clear the pending operation 389 mActivity.setPendingToastOperation(null); 390 mActivity.onUndoAvailable(pendingOp); 391 } 392 } 393 394 /** 395 * Returns the default choice mode for the list based on whether the list is displayed on tablet 396 * or not. 397 * @param isTablet 398 * @return 399 */ 400 private final static int getDefaultChoiceMode(boolean isTablet) { 401 return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE; 402 } 403 404 public AnimatedAdapter getAnimatedAdapter() { 405 return mListAdapter; 406 } 407 408 @Override 409 public void onCreate(Bundle savedState) { 410 super.onCreate(savedState); 411 412 // Initialize fragment constants from resources 413 final Resources res = getResources(); 414 TIMESTAMP_UPDATE_INTERVAL = res.getInteger(R.integer.timestamp_update_interval); 415 LOADING_DELAY_MS = res.getInteger(R.integer.conversationview_show_loading_delay); 416 MINIMUM_LOADING_DURATION = res.getInteger(R.integer.conversationview_min_show_loading); 417 mUpdateTimestampsRunnable = new Runnable() { 418 @Override 419 public void run() { 420 mListView.invalidateViews(); 421 mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL); 422 } 423 }; 424 425 // Get the context from the arguments 426 final Bundle args = getArguments(); 427 mViewContext = ConversationListContext.forBundle(args.getBundle(CONVERSATION_LIST_KEY)); 428 mAccount = mViewContext.account; 429 430 setRetainInstance(false); 431 } 432 433 @Override 434 public String toString() { 435 final String s = super.toString(); 436 if (mViewContext == null) { 437 return s; 438 } 439 final StringBuilder sb = new StringBuilder(s); 440 sb.setLength(sb.length() - 1); 441 sb.append(" mListAdapter="); 442 sb.append(mListAdapter); 443 sb.append(" folder="); 444 sb.append(mViewContext.folder); 445 sb.append("}"); 446 return sb.toString(); 447 } 448 449 @Override 450 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { 451 View rootView = inflater.inflate(R.layout.conversation_list, null); 452 mEmptyView = (ConversationListEmptyView) rootView.findViewById(R.id.empty_view); 453 mLoadingView = rootView.findViewById(R.id.background_view); 454 mLoadingView.setVisibility(View.GONE); 455 mLoadingView.findViewById(R.id.loading_progress).setVisibility(View.VISIBLE); 456 mListView = (SwipeableListView) rootView.findViewById(R.id.conversation_list_view); 457 mListView.setHeaderDividersEnabled(false); 458 mListView.setOnItemLongClickListener(this); 459 mListView.enableSwipe(mAccount.supportsCapability(AccountCapabilities.UNDO)); 460 mListView.setListItemSwipedListener(this); 461 mListView.setSwipeListener(this); 462 mListView.setOnKeyListener(this); 463 mListView.setOnItemClickListener(this); 464 if (mNextFocusLeftId != 0) { 465 mListView.setNextFocusLeftId(mNextFocusLeftId); 466 } 467 468 // enable animateOnLayout (equivalent of setLayoutTransition) only for >=JB (b/14302062) 469 if (Utils.isRunningJellybeanOrLater()) { 470 ((ViewGroup) rootView.findViewById(R.id.conversation_list_parent_frame)) 471 .setLayoutTransition(new LayoutTransition()); 472 } 473 474 // By default let's show the list view 475 showListView(); 476 477 if (savedState != null && savedState.containsKey(LIST_STATE_KEY)) { 478 mListView.onRestoreInstanceState(savedState.getParcelable(LIST_STATE_KEY)); 479 } 480 mSwipeRefreshWidget = 481 (MailSwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_widget); 482 mSwipeRefreshWidget.setColorScheme(R.color.swipe_refresh_color1, 483 R.color.swipe_refresh_color2, 484 R.color.swipe_refresh_color3, R.color.swipe_refresh_color4); 485 mSwipeRefreshWidget.setOnRefreshListener(this); 486 mSwipeRefreshWidget.setScrollableChild(mListView); 487 488 return rootView; 489 } 490 491 /** 492 * Sets the choice mode of the list view 493 */ 494 private final void setChoiceMode(int choiceMode) { 495 mListView.setChoiceMode(choiceMode); 496 } 497 498 /** 499 * Tell the list to select nothing. 500 */ 501 public final void setChoiceNone() { 502 // On a phone, the default choice mode is already none, so nothing to do. 503 if (!mTabletDevice) { 504 return; 505 } 506 clearChoicesAndActivated(); 507 setChoiceMode(ListView.CHOICE_MODE_NONE); 508 } 509 510 /** 511 * Tell the list to get out of selecting none. 512 */ 513 public final void revertChoiceMode() { 514 // On a phone, the default choice mode is always none, so nothing to do. 515 if (!mTabletDevice) { 516 return; 517 } 518 setChoiceMode(getDefaultChoiceMode(mTabletDevice)); 519 } 520 521 @Override 522 public void onDestroy() { 523 super.onDestroy(); 524 } 525 526 @Override 527 public void onDestroyView() { 528 529 // Clear the list's adapter 530 mListAdapter.destroy(); 531 mListView.setAdapter(null); 532 533 mActivity.getViewMode().removeListener(this); 534 if (mFolderObserver != null) { 535 mFolderObserver.unregisterAndDestroy(); 536 mFolderObserver = null; 537 } 538 if (mConversationCursorObserver != null) { 539 mUpdater.unregisterConversationListObserver(mConversationCursorObserver); 540 mConversationCursorObserver = null; 541 } 542 mAccountObserver.unregisterAndDestroy(); 543 getAnimatedAdapter().cleanup(); 544 super.onDestroyView(); 545 } 546 547 /** 548 * There are three binary variables, which determine what we do with a 549 * message. checkbEnabled: Whether check boxes are enabled or not (forced 550 * true on tablet) cabModeOn: Whether CAB mode is currently on or not. 551 * pressType: long or short tap (There is a third possibility: phone or 552 * tablet, but they have <em>identical</em> behavior) The matrix of 553 * possibilities is: 554 * <p> 555 * Long tap: Always toggle selection of conversation. If CAB mode is not 556 * started, then start it. 557 * <pre> 558 * | Checkboxes | No Checkboxes 559 * ----------+------------+--------------- 560 * CAB mode | Select | Select 561 * List mode | Select | Select 562 * 563 * </pre> 564 * 565 * Reference: http://b/issue?id=6392199 566 * <p> 567 * {@inheritDoc} 568 */ 569 @Override 570 public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { 571 // Ignore anything that is not a conversation item. Could be a footer. 572 if (!(view instanceof ConversationItemView)) { 573 return false; 574 } 575 return ((ConversationItemView) view).toggleSelectedStateOrBeginDrag(); 576 } 577 578 /** 579 * See the comment for 580 * {@link #onItemLongClick(AdapterView, View, int, long)}. 581 * <p> 582 * Short tap behavior: 583 * 584 * <pre> 585 * | Checkboxes | No Checkboxes 586 * ----------+------------+--------------- 587 * CAB mode | Peek | Select 588 * List mode | Peek | Peek 589 * </pre> 590 * 591 * Reference: http://b/issue?id=6392199 592 * <p> 593 * {@inheritDoc} 594 */ 595 @Override 596 public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) { 597 onListItemSelected(view, position); 598 } 599 600 private void onListItemSelected(View view, int position) { 601 if (view instanceof ToggleableItem) { 602 final boolean showSenderImage = 603 (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE); 604 final boolean inCabMode = !mSelectedSet.isEmpty(); 605 if (!showSenderImage && inCabMode) { 606 ((ToggleableItem) view).toggleSelectedState(); 607 } else { 608 if (inCabMode) { 609 // this is a peek. 610 Analytics.getInstance().sendEvent("peek", null, null, mSelectedSet.size()); 611 } 612 AnalyticsTimer.getInstance().trackStart(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST); 613 viewConversation(position); 614 } 615 } else { 616 // Ignore anything that is not a conversation item. Could be a footer. 617 // If we are using a keyboard, the highlighted item is the parent; 618 // otherwise, this is a direct call from the ConverationItemView 619 return; 620 } 621 // When a new list item is clicked, commit any existing leave behind 622 // items. Wait until we have opened the desired conversation to cause 623 // any position changes. 624 commitDestructiveActions(Utils.useTabletUI(mActivity.getActivityContext().getResources())); 625 } 626 627 @Override 628 public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { 629 SwipeableListView list = (SwipeableListView) view; 630 // Don't need to handle ENTER because it's auto-handled as a "click". 631 if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { 632 if (keyEvent.getAction() == KeyEvent.ACTION_UP) { 633 if (mKeyInitiatedFromList) { 634 onListItemSelected(list.getSelectedView(), list.getSelectedItemPosition()); 635 } 636 mKeyInitiatedFromList = false; 637 } else if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { 638 mKeyInitiatedFromList = true; 639 } 640 return true; 641 } else if (keyEvent.getAction() == KeyEvent.ACTION_UP) { 642 if (keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { 643 final int position = list.getSelectedItemPosition(); 644 final Object item = getAnimatedAdapter().getItem(position); 645 if (item != null && item instanceof ConversationCursor) { 646 final Conversation conv = ((ConversationCursor) item).getConversation(); 647 mCallbacks.onConversationFocused(conv); 648 } 649 } 650 } 651 return false; 652 } 653 654 @Override 655 public void onResume() { 656 super.onResume(); 657 658 if (!isCursorReadyToShow()) { 659 // If the cursor got reset, let's reset the analytics state variable and show the list 660 // view since we are waiting for load again 661 mInitialCursorLoading = true; 662 showListView(); 663 } 664 665 final ConversationCursor conversationCursor = getConversationListCursor(); 666 if (conversationCursor != null) { 667 conversationCursor.handleNotificationActions(); 668 669 restoreLastScrolledPosition(); 670 } 671 672 mSelectedSet.addObserver(mConversationSetObserver); 673 } 674 675 @Override 676 public void onPause() { 677 super.onPause(); 678 679 mSelectedSet.removeObserver(mConversationSetObserver); 680 681 saveLastScrolledPosition(); 682 } 683 684 @Override 685 public void onSaveInstanceState(Bundle outState) { 686 super.onSaveInstanceState(outState); 687 if (mListView != null) { 688 outState.putParcelable(LIST_STATE_KEY, mListView.onSaveInstanceState()); 689 outState.putInt(CHOICE_MODE_KEY, mListView.getChoiceMode()); 690 } 691 692 if (mListAdapter != null) { 693 mListAdapter.saveSpecialItemInstanceState(outState); 694 } 695 } 696 697 @Override 698 public void onStart() { 699 super.onStart(); 700 mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL); 701 Analytics.getInstance().sendView("ConversationListFragment"); 702 } 703 704 @Override 705 public void onStop() { 706 super.onStop(); 707 mHandler.removeCallbacks(mUpdateTimestampsRunnable); 708 } 709 710 @Override 711 public void onViewModeChanged(int newMode) { 712 if (mTabletDevice) { 713 if (ViewMode.isListMode(newMode)) { 714 // There are no selected conversations when in conversation list mode. 715 clearChoicesAndActivated(); 716 } 717 } 718 if (mFooterView != null) { 719 mFooterView.onViewModeChanged(newMode); 720 } 721 722 // Set default navigation 723 if (ViewMode.isListMode(newMode)) { 724 mListView.setNextFocusRightId(R.id.conversation_list_view); 725 mListView.requestFocus(); 726 } else if (ViewMode.isConversationMode(newMode)) { 727 // This would only happen in two_pane 728 mListView.setNextFocusRightId(R.id.conversation_pager); 729 } 730 } 731 732 public boolean isAnimating() { 733 final AnimatedAdapter adapter = getAnimatedAdapter(); 734 if (adapter != null && adapter.isAnimating()) { 735 return true; 736 } 737 final boolean isScrolling = (mListView != null && mListView.isScrolling()); 738 if (isScrolling) { 739 LogUtils.i(LOG_TAG, "CLF.isAnimating=true due to scrolling"); 740 } 741 return isScrolling; 742 } 743 744 private void clearChoicesAndActivated() { 745 final int currentSelected = mListView.getCheckedItemPosition(); 746 if (currentSelected != ListView.INVALID_POSITION) { 747 mListView.setItemChecked(mListView.getCheckedItemPosition(), false); 748 } 749 } 750 751 /** 752 * Handles a request to show a new conversation list, either from a search 753 * query or for viewing a folder. This will initiate a data load, and hence 754 * must be called on the UI thread. 755 */ 756 private void showList() { 757 mInitialCursorLoading = true; 758 onFolderUpdated(mActivity.getFolderController().getFolder()); 759 onConversationListStatusUpdated(); 760 761 // try to get an order-of-magnitude sense for message count within folders 762 // (N.B. this count currently isn't working for search folders, since their counts stream 763 // in over time in pieces.) 764 final Folder f = mViewContext.folder; 765 if (f != null) { 766 final long countLog; 767 if (f.totalCount > 0) { 768 countLog = (long) Math.log10(f.totalCount); 769 } else { 770 countLog = 0; 771 } 772 Analytics.getInstance().sendEvent("view_folder", f.getTypeDescription(), 773 Long.toString(countLog), f.totalCount); 774 } 775 } 776 777 /** 778 * View the message at the given position. 779 * 780 * @param position The position of the conversation in the list (as opposed to its position 781 * in the cursor) 782 */ 783 private void viewConversation(final int position) { 784 LogUtils.d(LOG_TAG, "ConversationListFragment.viewConversation(%d)", position); 785 786 final ConversationCursor cursor = 787 (ConversationCursor) getAnimatedAdapter().getItem(position); 788 789 if (cursor == null) { 790 LogUtils.e(LOG_TAG, 791 "unable to open conv at cursor pos=%s cursor=%s getPositionOffset=%s", 792 position, cursor, getAnimatedAdapter().getPositionOffset(position)); 793 return; 794 } 795 796 final Conversation conv = cursor.getConversation(); 797 /* 798 * The cursor position may be different than the position method parameter because of 799 * special views in the list. 800 */ 801 conv.position = cursor.getPosition(); 802 setSelected(conv.position, true); 803 mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */); 804 } 805 806 /** 807 * Sets the selected conversation to the position given here. 808 * @param cursorPosition The position of the conversation in the cursor (as opposed to 809 * in the list) 810 * @param different if the currently selected conversation is different from the one provided 811 * here. This is a difference in conversations, not a difference in positions. For example, a 812 * conversation at position 2 can move to position 4 as a result of new mail. 813 */ 814 public void setSelected(final int cursorPosition, boolean different) { 815 if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) { 816 return; 817 } 818 819 final int position = 820 cursorPosition + getAnimatedAdapter().getPositionOffset(cursorPosition); 821 822 setRawSelected(position, different); 823 } 824 825 /** 826 * Sets the selected conversation to the position given here. 827 * @param position The position of the item in the list 828 * @param different if the currently selected conversation is different from the one provided 829 * here. This is a difference in conversations, not a difference in positions. For example, a 830 * conversation at position 2 can move to position 4 as a result of new mail. 831 */ 832 public void setRawSelected(final int position, final boolean different) { 833 if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) { 834 return; 835 } 836 837 if (different) { 838 mListView.smoothScrollToPosition(position); 839 } 840 mListView.setItemChecked(position, true); 841 } 842 843 /** 844 * Returns the cursor associated with the conversation list. 845 * @return 846 */ 847 private ConversationCursor getConversationListCursor() { 848 return mCallbacks != null ? mCallbacks.getConversationListCursor() : null; 849 } 850 851 /** 852 * Request a refresh of the list. No sync is carried out and none is 853 * promised. 854 */ 855 public void requestListRefresh() { 856 mListAdapter.notifyDataSetChanged(); 857 } 858 859 /** 860 * Change the UI to delete the conversations provided and then call the 861 * {@link DestructiveAction} provided here <b>after</b> the UI has been 862 * updated. 863 * @param conversations 864 * @param action 865 */ 866 public void requestDelete(int actionId, final Collection<Conversation> conversations, 867 final DestructiveAction action) { 868 for (Conversation conv : conversations) { 869 conv.localDeleteOnUpdate = true; 870 } 871 final ListItemsRemovedListener listener = new ListItemsRemovedListener() { 872 @Override 873 public void onListItemsRemoved() { 874 action.performAction(); 875 } 876 }; 877 if (mListView.getSwipeAction() == actionId) { 878 if (!mListView.destroyItems(conversations, listener)) { 879 // The listView failed to destroy the items, perform the action manually 880 LogUtils.e(LOG_TAG, "ConversationListFragment.requestDelete: " + 881 "listView failed to destroy items."); 882 action.performAction(); 883 } 884 return; 885 } 886 // Delete the local delete items (all for now) and when done, 887 // update... 888 mListAdapter.delete(conversations, listener); 889 } 890 891 public void onFolderUpdated(Folder folder) { 892 if (!isCursorReadyToShow()) { 893 // Wait a bit before showing either the empty or loading view. If the messages are 894 // actually local, it's disorienting to see this appear on every folder transition. 895 // If they aren't, then it will likely take more than 200 milliseconds to load, and 896 // then we'll see the loading view. 897 if (!mLoadingViewPending) { 898 mHandler.postDelayed(mLoadingViewRunnable, LOADING_DELAY_MS); 899 mLoadingViewPending = true; 900 } 901 } 902 903 mFolder = folder; 904 setSwipeAction(); 905 906 // Update enabled state of swipe to refresh. 907 mSwipeRefreshWidget.setEnabled(!ConversationListContext.isSearchResult(mViewContext)); 908 909 if (mFolder == null) { 910 return; 911 } 912 mListAdapter.setFolder(mFolder); 913 mFooterView.setFolder(mFolder); 914 if (!mFolder.wasSyncSuccessful()) { 915 mErrorListener.onError(mFolder, false); 916 } 917 918 // Update the sync status bar with sync results if needed 919 checkSyncStatus(); 920 921 // Blow away conversation items cache. 922 ConversationItemViewModel.onFolderUpdated(mFolder); 923 } 924 925 /** 926 * Updates the footer visibility and updates the conversation cursor 927 */ 928 public void onConversationListStatusUpdated() { 929 // Also change the cursor here. 930 onCursorUpdated(); 931 932 if (isCursorReadyToShow() && mCanTakeDownLoadingView) { 933 hideLoadingViewAndShowContents(); 934 } 935 } 936 937 private void hideLoadingViewAndShowContents() { 938 final ConversationCursor cursor = getConversationListCursor(); 939 final boolean showFooter = mFooterView.updateStatus(cursor); 940 // Update the sync status bar with sync results if needed 941 checkSyncStatus(); 942 mListAdapter.setFooterVisibility(showFooter); 943 mLoadingViewPending = false; 944 mHandler.removeCallbacks(mLoadingViewRunnable); 945 946 // Even though cursor might be empty, the list adapter might have teasers/footers. 947 // So we check the list adapter count if the cursor is fully/partially loaded. 948 if (cursor != null && ConversationCursor.isCursorReadyToShow(cursor) && 949 mListAdapter.getCount() == 0) { 950 showEmptyView(); 951 } else { 952 showListView(); 953 } 954 } 955 956 private void setSwipeAction() { 957 int swipeSetting = Settings.getSwipeSetting(mAccount.settings); 958 if (swipeSetting == Swipe.DISABLED 959 || !mAccount.supportsCapability(AccountCapabilities.UNDO) 960 || (mFolder != null && mFolder.isTrash())) { 961 mListView.enableSwipe(false); 962 } else { 963 final int action; 964 mListView.enableSwipe(true); 965 if (mFolder == null) { 966 action = R.id.remove_folder; 967 } else { 968 switch (swipeSetting) { 969 // Try to respect user's setting as best as we can and default to doing nothing 970 case Swipe.DELETE: 971 // Delete in Outbox means discard failed message and put it in draft 972 if (mFolder.isType(UIProvider.FolderType.OUTBOX)) { 973 action = R.id.discard_outbox; 974 } else { 975 action = R.id.delete; 976 } 977 break; 978 case Swipe.ARCHIVE: 979 // Special case spam since it shouldn't remove spam folder label on swipe 980 if (mAccount.supportsCapability(AccountCapabilities.ARCHIVE) 981 && !mFolder.isSpam()) { 982 if (mFolder.supportsCapability(FolderCapabilities.ARCHIVE)) { 983 action = R.id.archive; 984 break; 985 } else if (mFolder.supportsCapability 986 (FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)) { 987 action = R.id.remove_folder; 988 break; 989 } 990 } 991 992 /* 993 * If we get here, we don't support archive, on either the account or the 994 * folder, so we want to fall through to swipe doing nothing 995 */ 996 //$FALL-THROUGH$ 997 default: 998 mListView.enableSwipe(false); 999 action = 0; // Use default value so setSwipeAction essentially has no effect 1000 break; 1001 } 1002 } 1003 mListView.setSwipeAction(action); 1004 } 1005 mListView.setCurrentAccount(mAccount); 1006 mListView.setCurrentFolder(mFolder); 1007 } 1008 1009 /** 1010 * Changes the conversation cursor in the list and sets selected position if none is set. 1011 */ 1012 private void onCursorUpdated() { 1013 if (mCallbacks == null || mListAdapter == null) { 1014 return; 1015 } 1016 // Check against the previous cursor here and see if they are the same. If they are, then 1017 // do a notifyDataSetChanged. 1018 final ConversationCursor newCursor = mCallbacks.getConversationListCursor(); 1019 1020 if (newCursor == null && mListAdapter.getCursor() != null) { 1021 // We're losing our cursor, so save our scroll position 1022 saveLastScrolledPosition(); 1023 } 1024 1025 mListAdapter.swapCursor(newCursor); 1026 // When the conversation cursor is *updated*, we get back the same instance. In that 1027 // situation, CursorAdapter.swapCursor() silently returns, without forcing a 1028 // notifyDataSetChanged(). So let's force a call to notifyDataSetChanged, since an updated 1029 // cursor means that the dataset has changed. 1030 final int newCursorHash = (newCursor == null) ? 0 : newCursor.hashCode(); 1031 if (mConversationCursorHash == newCursorHash && mConversationCursorHash != 0) { 1032 mListAdapter.notifyDataSetChanged(); 1033 } 1034 mConversationCursorHash = newCursorHash; 1035 1036 updateAnalyticsData(newCursor); 1037 if (newCursor != null) { 1038 final int newCursorCount = newCursor.getCount(); 1039 updateSearchResultHeader(newCursorCount); 1040 if (newCursorCount > 0) { 1041 newCursor.markContentsSeen(); 1042 restoreLastScrolledPosition(); 1043 } 1044 } 1045 1046 // If a current conversation is available, and none is selected in the list, then ask 1047 // the list to select the current conversation. 1048 final Conversation conv = mCallbacks.getCurrentConversation(); 1049 if (conv != null) { 1050 if (mListView.getChoiceMode() != ListView.CHOICE_MODE_NONE 1051 && mListView.getCheckedItemPosition() == -1) { 1052 setSelected(conv.position, true); 1053 } 1054 } 1055 } 1056 1057 public void commitDestructiveActions(boolean animate) { 1058 if (mListView != null) { 1059 mListView.commitDestructiveActions(animate); 1060 1061 } 1062 } 1063 1064 @Override 1065 public void onListItemSwiped(Collection<Conversation> conversations) { 1066 mUpdater.showNextConversation(conversations); 1067 } 1068 1069 private void checkSyncStatus() { 1070 if (mFolder != null && mFolder.isSyncInProgress()) { 1071 LogUtils.d(LOG_TAG, "CLF.checkSyncStatus still syncing"); 1072 // Still syncing, ignore 1073 } else { 1074 // Finished syncing: 1075 LogUtils.d(LOG_TAG, "CLF.checkSyncStatus done syncing"); 1076 mSwipeRefreshWidget.setRefreshing(false); 1077 } 1078 } 1079 1080 /** 1081 * Displays the indefinite progress bar indicating a sync is in progress. This 1082 * should only be called if user manually requested a sync, and not for background syncs. 1083 */ 1084 protected void showSyncStatusBar() { 1085 mSwipeRefreshWidget.setRefreshing(true); 1086 } 1087 1088 /** 1089 * Clears all items in the list. 1090 */ 1091 public void clear() { 1092 mListView.setAdapter(null); 1093 } 1094 1095 private final ConversationSetObserver mConversationSetObserver = new ConversationSetObserver() { 1096 @Override 1097 public void onSetPopulated(final ConversationSelectionSet set) { 1098 // Disable the swipe to refresh widget. 1099 mSwipeRefreshWidget.setEnabled(false); 1100 } 1101 1102 @Override 1103 public void onSetEmpty() { 1104 mSwipeRefreshWidget.setEnabled(true); 1105 } 1106 1107 @Override 1108 public void onSetChanged(final ConversationSelectionSet set) { 1109 // Do nothing 1110 } 1111 }; 1112 1113 private void saveLastScrolledPosition() { 1114 if (mListAdapter.getCursor() == null) { 1115 // If you save your scroll position in an empty list, you're gonna have a bad time 1116 return; 1117 } 1118 1119 final Parcelable savedState = mListView.onSaveInstanceState(); 1120 1121 mActivity.getListHandler().setConversationListScrollPosition( 1122 mFolder.conversationListUri.toString(), savedState); 1123 } 1124 1125 private void restoreLastScrolledPosition() { 1126 // Scroll to our previous position, if necessary 1127 if (!mScrollPositionRestored && mFolder != null) { 1128 final String key = mFolder.conversationListUri.toString(); 1129 final Parcelable savedState = mActivity.getListHandler() 1130 .getConversationListScrollPosition(key); 1131 if (savedState != null) { 1132 mListView.onRestoreInstanceState(savedState); 1133 } 1134 mScrollPositionRestored = true; 1135 } 1136 } 1137 1138 /* (non-Javadoc) 1139 * @see android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener#onRefresh() 1140 */ 1141 @Override 1142 public void onRefresh() { 1143 Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "swipe_refresh", null, 1144 0); 1145 1146 // This will call back to showSyncStatusBar(): 1147 mActivity.getFolderController().requestFolderRefresh(); 1148 1149 // Clear list adapter state out of an abundance of caution. 1150 // There is a class of bugs where an animation that should have finished doesn't (maybe 1151 // it didn't start, or it didn't finish), and the list gets stuck pretty much forever. 1152 // Clearing the state here is in line with user expectation for 'refresh'. 1153 getAnimatedAdapter().clearAnimationState(); 1154 // possibly act on the now-cleared state 1155 mActivity.onAnimationEnd(mListAdapter); 1156 } 1157 1158 /** 1159 * Extracted function that handles Analytics state and logging updates for each new cursor 1160 * @param newCursor the new cursor pointer 1161 */ 1162 private void updateAnalyticsData(ConversationCursor newCursor) { 1163 if (newCursor != null) { 1164 // Check if the initial data returned yet 1165 if (mInitialCursorLoading) { 1166 // This marks the very first time the cursor with the data the user sees returned. 1167 // We either have a cursor in LOADING state with cursor's count > 0, OR the cursor 1168 // completed loading. 1169 // Use this point to log the appropriate timing information that depends on when 1170 // the conversation list view finishes loading 1171 if (isCursorReadyToShow()) { 1172 if (newCursor.getCount() == 0) { 1173 Analytics.getInstance().sendEvent("empty_state", "post_label_change", 1174 mFolder.getTypeDescription(), 0); 1175 } 1176 AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.COLD_START_LAUNCHER, 1177 true /* isDestructive */, "cold_start_to_list", "from_launcher", null); 1178 // Don't need null checks because the activity, controller, and folder cannot 1179 // be null in this case 1180 if (mActivity.getFolderController().getFolder().isSearch()) { 1181 AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.SEARCH_TO_LIST, 1182 true /* isDestructive */, "search_to_list", null, null); 1183 } 1184 1185 mInitialCursorLoading = false; 1186 } 1187 } else { 1188 // Log the appropriate events that happen after the initial cursor is loaded 1189 if (newCursor.getCount() == 0 && mConversationCursorLastCount > 0) { 1190 Analytics.getInstance().sendEvent("empty_state", "post_delete", 1191 mFolder.getTypeDescription(), 0); 1192 } 1193 } 1194 1195 // We save the count here because for folders that are empty, multiple successful 1196 // cursor loads will occur with size of 0. Thus we don't want to emit any false 1197 // positive post_delete events. 1198 mConversationCursorLastCount = newCursor.getCount(); 1199 } else { 1200 mConversationCursorLastCount = 0; 1201 } 1202 } 1203 1204 /** 1205 * Helper function to determine if the current cursor is ready to populate the UI 1206 * Since we extracted the functionality into a static function in ConversationCursor, 1207 * this function remains for the sole purpose of readability. 1208 * @return 1209 */ 1210 private boolean isCursorReadyToShow() { 1211 return ConversationCursor.isCursorReadyToShow(getConversationListCursor()); 1212 } 1213 1214 public ListView getListView() { 1215 return mListView; 1216 } 1217 1218 public void setNextFocusLeftId(@IdRes int id) { 1219 mNextFocusLeftId = id; 1220 if (mListView != null) { 1221 mListView.setNextFocusLeftId(mNextFocusLeftId); 1222 } 1223 } 1224} 1225