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.ListFragment; 22 import android.app.LoaderManager; 23 import android.content.Context; 24 import android.content.res.Resources; 25 import android.database.DataSetObserver; 26 import android.os.Bundle; 27 import android.os.Handler; 28 import android.os.Parcelable; 29 import android.text.format.DateUtils; 30 import android.view.LayoutInflater; 31 import android.view.View; 32 import android.view.ViewGroup; 33 import android.view.ViewGroup.MarginLayoutParams; 34 import android.widget.AdapterView; 35 import android.widget.AdapterView.OnItemLongClickListener; 36 import android.widget.ListView; 37 import android.widget.TextView; 38 39 import com.android.mail.ConversationListContext; 40 import com.android.mail.R; 41 import com.android.mail.analytics.Analytics; 42 import com.android.mail.browse.ConversationCursor; 43 import com.android.mail.browse.ConversationItemView; 44 import com.android.mail.browse.ConversationItemViewModel; 45 import com.android.mail.browse.ConversationListFooterView; 46 import com.android.mail.browse.ToggleableItem; 47 import com.android.mail.providers.Account; 48 import com.android.mail.providers.AccountObserver; 49 import com.android.mail.providers.Conversation; 50 import com.android.mail.providers.Folder; 51 import com.android.mail.providers.FolderObserver; 52 import com.android.mail.providers.Settings; 53 import com.android.mail.providers.UIProvider; 54 import com.android.mail.providers.UIProvider.AccountCapabilities; 55 import com.android.mail.providers.UIProvider.ConversationListIcon; 56 import com.android.mail.providers.UIProvider.FolderCapabilities; 57 import com.android.mail.providers.UIProvider.FolderType; 58 import com.android.mail.providers.UIProvider.Swipe; 59 import com.android.mail.ui.AnimatedAdapter.ConversationListListener; 60 import com.android.mail.ui.SwipeableListView.ListItemSwipedListener; 61 import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener; 62 import com.android.mail.ui.ViewMode.ModeChangeListener; 63 import com.android.mail.utils.LogTag; 64 import com.android.mail.utils.LogUtils; 65 import com.android.mail.utils.Utils; 66 import com.google.common.collect.ImmutableList; 67 68 import java.util.Collection; 69 import java.util.List; 70 71 /** 72 * The conversation list UI component. 73 */ 74 public final class ConversationListFragment extends ListFragment implements 75 OnItemLongClickListener, ModeChangeListener, ListItemSwipedListener { 76 /** Key used to pass data to {@link ConversationListFragment}. */ 77 private static final String CONVERSATION_LIST_KEY = "conversation-list"; 78 /** Key used to keep track of the scroll state of the list. */ 79 private static final String LIST_STATE_KEY = "list-state"; 80 81 private static final String LOG_TAG = LogTag.getLogTag(); 82 /** Key used to save the ListView choice mode, since ListView doesn't save it automatically! */ 83 private static final String CHOICE_MODE_KEY = "choice-mode-key"; 84 85 // True if we are on a tablet device 86 private static boolean mTabletDevice; 87 88 /** 89 * Frequency of update of timestamps. Initialized in 90 * {@link #onCreate(Bundle)} and final afterwards. 91 */ 92 private static int TIMESTAMP_UPDATE_INTERVAL = 0; 93 94 private static long NO_NEW_MESSAGE_DURATION = 1 * DateUtils.SECOND_IN_MILLIS; 95 96 private ControllableActivity mActivity; 97 98 // Control state. 99 private ConversationListCallbacks mCallbacks; 100 101 private final Handler mHandler = new Handler(); 102 103 private ConversationListView mConversationListView; 104 105 // The internal view objects. 106 private SwipeableListView mListView; 107 108 private TextView mSearchResultCountTextView; 109 private TextView mSearchStatusTextView; 110 111 private View mSearchStatusView; 112 113 /** 114 * Current Account being viewed 115 */ 116 private Account mAccount; 117 /** 118 * Current folder being viewed. 119 */ 120 private Folder mFolder; 121 122 /** 123 * A simple method to update the timestamps of conversations periodically. 124 */ 125 private Runnable mUpdateTimestampsRunnable = null; 126 127 private ConversationListContext mViewContext; 128 129 private AnimatedAdapter mListAdapter; 130 131 private ConversationListFooterView mFooterView; 132 private View mEmptyView; 133 private ErrorListener mErrorListener; 134 private FolderObserver mFolderObserver; 135 private DataSetObserver mConversationCursorObserver; 136 137 private ConversationSelectionSet mSelectedSet; 138 private final AccountObserver mAccountObserver = new AccountObserver() { 139 @Override 140 public void onChanged(Account newAccount) { 141 mAccount = newAccount; 142 setSwipeAction(); 143 } 144 }; 145 private ConversationUpdater mUpdater; 146 /** Hash of the Conversation Cursor we last obtained from the controller. */ 147 private int mConversationCursorHash; 148 149 /** Duration, in milliseconds, of the CAB mode (peek icon) animation. */ 150 private static long sSelectionModeAnimationDuration = -1; 151 /** The time at which we last exited CAB mode. */ 152 private long mSelectionModeExitedTimestamp = -1; 153 154 /** 155 * If <code>true</code>, we have restored (or attempted to restore) the list's scroll position 156 * from when we were last on this conversation list. 157 */ 158 private boolean mScrollPositionRestored = false; 159 160 /** 161 * Constructor needs to be public to handle orientation changes and activity 162 * lifecycle events. 163 */ 164 public ConversationListFragment() { 165 super(); 166 } 167 168 private class ConversationCursorObserver extends DataSetObserver { 169 @Override 170 public void onChanged() { 171 onConversationListStatusUpdated(); 172 } 173 } 174 175 /** 176 * Creates a new instance of {@link ConversationListFragment}, initialized 177 * to display conversation list context. 178 */ 179 public static ConversationListFragment newInstance(ConversationListContext viewContext) { 180 final ConversationListFragment fragment = new ConversationListFragment(); 181 final Bundle args = new Bundle(1); 182 args.putBundle(CONVERSATION_LIST_KEY, viewContext.toBundle()); 183 fragment.setArguments(args); 184 return fragment; 185 } 186 187 /** 188 * Show the header if the current conversation list is showing search 189 * results. 190 */ 191 void configureSearchResultHeader() { 192 if (mActivity == null) { 193 return; 194 } 195 // Only show the header if the context is for a search result 196 final Resources res = getResources(); 197 final boolean showHeader = ConversationListContext.isSearchResult(mViewContext); 198 // TODO(viki): This code contains intimate understanding of the view. 199 // Much of this logic 200 // needs to reside in a separate class that handles the text view in 201 // isolation. Then, 202 // that logic can be reused in other fragments. 203 if (showHeader) { 204 mSearchStatusTextView.setText(res.getString(R.string.search_results_searching_header)); 205 // Initially reset the count 206 mSearchResultCountTextView.setText(""); 207 } 208 mSearchStatusView.setVisibility(showHeader ? View.VISIBLE : View.GONE); 209 int marginTop = showHeader ? (int) res.getDimension(R.dimen.notification_view_height) : 0; 210 MarginLayoutParams layoutParams = (MarginLayoutParams) mListView.getLayoutParams(); 211 layoutParams.topMargin = marginTop; 212 mListView.setLayoutParams(layoutParams); 213 } 214 215 /** 216 * Show the header if the current conversation list is showing search 217 * results. 218 */ 219 private void updateSearchResultHeader(int count) { 220 if (mActivity == null) { 221 return; 222 } 223 // Only show the header if the context is for a search result 224 final Resources res = getResources(); 225 final boolean showHeader = ConversationListContext.isSearchResult(mViewContext); 226 if (showHeader) { 227 mSearchStatusTextView.setText(res.getString(R.string.search_results_header)); 228 mSearchResultCountTextView 229 .setText(res.getString(R.string.search_results_loaded, count)); 230 } 231 } 232 233 /** 234 * Initializes all internal state for a rendering. 235 */ 236 private void initializeUiForFirstDisplay() { 237 // TODO(mindyp): find some way to make the notification container more 238 // re-usable. 239 // TODO(viki): refactor according to comment in 240 // configureSearchResultHandler() 241 mSearchStatusView = mActivity.findViewById(R.id.search_status_view); 242 mSearchStatusTextView = (TextView) mActivity.findViewById(R.id.search_status_text_view); 243 mSearchResultCountTextView = (TextView) mActivity 244 .findViewById(R.id.search_result_count_view); 245 } 246 247 @Override 248 public void onActivityCreated(Bundle savedState) { 249 super.onActivityCreated(savedState); 250 251 if (sSelectionModeAnimationDuration < 0) { 252 sSelectionModeAnimationDuration = getResources().getInteger( 253 R.integer.conv_item_view_cab_anim_duration); 254 } 255 256 // Strictly speaking, we get back an android.app.Activity from 257 // getActivity. However, the 258 // only activity creating a ConversationListContext is a MailActivity 259 // which is of type 260 // ControllableActivity, so this cast should be safe. If this cast 261 // fails, some other 262 // activity is creating ConversationListFragments. This activity must be 263 // of type 264 // ControllableActivity. 265 final Activity activity = getActivity(); 266 if (!(activity instanceof ControllableActivity)) { 267 LogUtils.e(LOG_TAG, "ConversationListFragment expects only a ControllableActivity to" 268 + "create it. Cannot proceed."); 269 } 270 mActivity = (ControllableActivity) activity; 271 // Since we now have a controllable activity, load the account from it, 272 // and register for 273 // future account changes. 274 mAccount = mAccountObserver.initialize(mActivity.getAccountController()); 275 mCallbacks = mActivity.getListHandler(); 276 mErrorListener = mActivity.getErrorListener(); 277 // Start off with the current state of the folder being viewed. 278 Context activityContext = mActivity.getActivityContext(); 279 mFooterView = (ConversationListFooterView) LayoutInflater.from( 280 activityContext).inflate(R.layout.conversation_list_footer_view, 281 null); 282 mFooterView.setClickListener(mActivity); 283 mConversationListView.setActivity(mActivity); 284 final ConversationCursor conversationCursor = getConversationListCursor(); 285 final LoaderManager manager = getLoaderManager(); 286 287 // TODO: These special views are always created, doesn't matter whether they will 288 // be shown or not, as we add more views this will get more expensive. Given these are 289 // tips that are only shown once to the user, we should consider creating these on demand. 290 final ConversationListHelper helper = mActivity.getConversationListHelper(); 291 final List<ConversationSpecialItemView> specialItemViews = helper != null ? 292 ImmutableList.copyOf(helper.makeConversationListSpecialViews( 293 activity, mActivity, mAccount)) 294 : null; 295 if (specialItemViews != null) { 296 // Attach to the LoaderManager 297 for (final ConversationSpecialItemView view : specialItemViews) { 298 view.bindFragment(manager, savedState); 299 } 300 } 301 302 mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor, 303 mActivity.getSelectedSet(), mActivity, mConversationListListener, mListView, 304 specialItemViews); 305 mListAdapter.addFooter(mFooterView); 306 mListView.setAdapter(mListAdapter); 307 mSelectedSet = mActivity.getSelectedSet(); 308 mListView.setSelectionSet(mSelectedSet); 309 mListAdapter.setFooterVisibility(false); 310 mFolderObserver = new FolderObserver(){ 311 @Override 312 public void onChanged(Folder newFolder) { 313 onFolderUpdated(newFolder); 314 } 315 }; 316 mFolderObserver.initialize(mActivity.getFolderController()); 317 mConversationCursorObserver = new ConversationCursorObserver(); 318 mUpdater = mActivity.getConversationUpdater(); 319 mUpdater.registerConversationListObserver(mConversationCursorObserver); 320 mTabletDevice = Utils.useTabletUI(mActivity.getApplicationContext().getResources()); 321 initializeUiForFirstDisplay(); 322 configureSearchResultHeader(); 323 // The onViewModeChanged callback doesn't get called when the mode 324 // object is created, so 325 // force setting the mode manually this time around. 326 onViewModeChanged(mActivity.getViewMode().getMode()); 327 mActivity.getViewMode().addListener(this); 328 329 if (mActivity.isFinishing()) { 330 // Activity is finishing, just bail. 331 return; 332 } 333 mConversationCursorHash = (conversationCursor == null) ? 0 : conversationCursor.hashCode(); 334 // Belt and suspenders here; make sure we do any necessary sync of the 335 // ConversationCursor 336 if (conversationCursor != null && conversationCursor.isRefreshReady()) { 337 conversationCursor.sync(); 338 } 339 340 // On a phone we never highlight a conversation, so the default is to select none. 341 // On a tablet, we highlight a SINGLE conversation in landscape conversation view. 342 int choice = getDefaultChoiceMode(mTabletDevice); 343 if (savedState != null) { 344 // Restore the choice mode if it was set earlier, or NONE if creating a fresh view. 345 // Choice mode here represents the current conversation only. CAB mode does not rely on 346 // the platform: checked state is a local variable {@link ConversationItemView#mChecked} 347 choice = savedState.getInt(CHOICE_MODE_KEY, choice); 348 if (savedState.containsKey(LIST_STATE_KEY)) { 349 // TODO: find a better way to unset the selected item when restoring 350 mListView.clearChoices(); 351 } 352 } 353 setChoiceMode(choice); 354 355 // Show list and start loading list. 356 showList(); 357 ToastBarOperation pendingOp = mActivity.getPendingToastOperation(); 358 if (pendingOp != null) { 359 // Clear the pending operation 360 mActivity.setPendingToastOperation(null); 361 mActivity.onUndoAvailable(pendingOp); 362 } 363 } 364 365 /** 366 * Returns the default choice mode for the list based on whether the list is displayed on tablet 367 * or not. 368 * @param isTablet 369 * @return 370 */ 371 private final static int getDefaultChoiceMode(boolean isTablet) { 372 return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE; 373 } 374 375 public AnimatedAdapter getAnimatedAdapter() { 376 return mListAdapter; 377 } 378 379 @Override 380 public void onCreate(Bundle savedState) { 381 super.onCreate(savedState); 382 383 // Initialize fragment constants from resources 384 final Resources res = getResources(); 385 TIMESTAMP_UPDATE_INTERVAL = res.getInteger(R.integer.timestamp_update_interval); 386 mUpdateTimestampsRunnable = new Runnable() { 387 @Override 388 public void run() { 389 mListView.invalidateViews(); 390 mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL); 391 } 392 }; 393 394 // Get the context from the arguments 395 final Bundle args = getArguments(); 396 mViewContext = ConversationListContext.forBundle(args.getBundle(CONVERSATION_LIST_KEY)); 397 mAccount = mViewContext.account; 398 399 setRetainInstance(false); 400 } 401 402 @Override 403 public String toString() { 404 final String s = super.toString(); 405 if (mViewContext == null) { 406 return s; 407 } 408 final StringBuilder sb = new StringBuilder(s); 409 sb.setLength(sb.length() - 1); 410 sb.append(" mListAdapter="); 411 sb.append(mListAdapter); 412 sb.append(" folder="); 413 sb.append(mViewContext.folder); 414 sb.append("}"); 415 return sb.toString(); 416 } 417 418 @Override 419 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { 420 View rootView = inflater.inflate(R.layout.conversation_list, null); 421 mEmptyView = rootView.findViewById(R.id.empty_view); 422 mConversationListView = 423 (ConversationListView) rootView.findViewById(R.id.conversation_list); 424 mConversationListView.setConversationContext(mViewContext); 425 mListView = (SwipeableListView) rootView.findViewById(android.R.id.list); 426 mListView.setHeaderDividersEnabled(false); 427 mListView.setOnItemLongClickListener(this); 428 mListView.enableSwipe(mAccount.supportsCapability(AccountCapabilities.UNDO)); 429 mListView.setSwipedListener(this); 430 431 if (savedState != null && savedState.containsKey(LIST_STATE_KEY)) { 432 mListView.onRestoreInstanceState(savedState.getParcelable(LIST_STATE_KEY)); 433 } 434 435 return rootView; 436 } 437 438 /** 439 * Sets the choice mode of the list view 440 * @param choiceMode ListView# 441 */ 442 private final void setChoiceMode(int choiceMode) { 443 mListView.setChoiceMode(choiceMode); 444 } 445 446 /** 447 * Tell the list to select nothing. 448 */ 449 public final void setChoiceNone() { 450 // On a phone, the default choice mode is already none, so nothing to do. 451 if (!mTabletDevice) { 452 return; 453 } 454 clearChoicesAndActivated(); 455 setChoiceMode(ListView.CHOICE_MODE_NONE); 456 } 457 458 /** 459 * Tell the list to get out of selecting none. 460 */ 461 public final void revertChoiceMode() { 462 // On a phone, the default choice mode is always none, so nothing to do. 463 if (!mTabletDevice) { 464 return; 465 } 466 setChoiceMode(getDefaultChoiceMode(mTabletDevice)); 467 } 468 469 @Override 470 public void onDestroy() { 471 super.onDestroy(); 472 } 473 474 @Override 475 public void onDestroyView() { 476 477 // Clear the list's adapter 478 mListAdapter.destroy(); 479 mListView.setAdapter(null); 480 481 mActivity.getViewMode().removeListener(this); 482 if (mFolderObserver != null) { 483 mFolderObserver.unregisterAndDestroy(); 484 mFolderObserver = null; 485 } 486 if (mConversationCursorObserver != null) { 487 mUpdater.unregisterConversationListObserver(mConversationCursorObserver); 488 mConversationCursorObserver = null; 489 } 490 mAccountObserver.unregisterAndDestroy(); 491 getAnimatedAdapter().cleanup(); 492 super.onDestroyView(); 493 } 494 495 /** 496 * There are three binary variables, which determine what we do with a 497 * message. checkbEnabled: Whether check boxes are enabled or not (forced 498 * true on tablet) cabModeOn: Whether CAB mode is currently on or not. 499 * pressType: long or short tap (There is a third possibility: phone or 500 * tablet, but they have <em>identical</em> behavior) The matrix of 501 * possibilities is: 502 * <p> 503 * Long tap: Always toggle selection of conversation. If CAB mode is not 504 * started, then start it. 505 * <pre> 506 * | Checkboxes | No Checkboxes 507 * ----------+------------+--------------- 508 * CAB mode | Select | Select 509 * List mode | Select | Select 510 * 511 * </pre> 512 * 513 * Reference: http://b/issue?id=6392199 514 * <p> 515 * {@inheritDoc} 516 */ 517 @Override 518 public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { 519 // Ignore anything that is not a conversation item. Could be a footer. 520 if (!(view instanceof ConversationItemView)) { 521 return false; 522 } 523 return ((ConversationItemView) view).toggleSelectedStateOrBeginDrag(); 524 } 525 526 /** 527 * See the comment for 528 * {@link #onItemLongClick(AdapterView, View, int, long)}. 529 * <p> 530 * Short tap behavior: 531 * 532 * <pre> 533 * | Checkboxes | No Checkboxes 534 * ----------+------------+--------------- 535 * CAB mode | Peek | Select 536 * List mode | Peek | Peek 537 * </pre> 538 * 539 * Reference: http://b/issue?id=6392199 540 * <p> 541 * {@inheritDoc} 542 */ 543 @Override 544 public void onListItemClick(ListView l, View view, int position, long id) { 545 if (view instanceof ToggleableItem) { 546 final boolean showSenderImage = 547 (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE); 548 final boolean inCabMode = !mSelectedSet.isEmpty(); 549 if (!showSenderImage && inCabMode) { 550 ((ToggleableItem) view).toggleSelectedState(); 551 } else { 552 if (inCabMode) { 553 // this is a peek. 554 Analytics.getInstance().sendEvent("peek", null, null, mSelectedSet.size()); 555 } 556 viewConversation(position); 557 } 558 } else { 559 // Ignore anything that is not a conversation item. Could be a footer. 560 // If we are using a keyboard, the highlighted item is the parent; 561 // otherwise, this is a direct call from the ConverationItemView 562 return; 563 } 564 // When a new list item is clicked, commit any existing leave behind 565 // items. Wait until we have opened the desired conversation to cause 566 // any position changes. 567 commitDestructiveActions(Utils.useTabletUI(mActivity.getActivityContext().getResources())); 568 } 569 570 @Override 571 public void onResume() { 572 super.onResume(); 573 574 final ConversationCursor conversationCursor = getConversationListCursor(); 575 if (conversationCursor != null) { 576 conversationCursor.handleNotificationActions(); 577 578 restoreLastScrolledPosition(); 579 } 580 581 mSelectedSet.addObserver(mConversationSetObserver); 582 } 583 584 @Override 585 public void onPause() { 586 super.onPause(); 587 588 mSelectedSet.removeObserver(mConversationSetObserver); 589 590 saveLastScrolledPosition(); 591 } 592 593 @Override 594 public void onSaveInstanceState(Bundle outState) { 595 super.onSaveInstanceState(outState); 596 if (mListView != null) { 597 outState.putParcelable(LIST_STATE_KEY, mListView.onSaveInstanceState()); 598 outState.putInt(CHOICE_MODE_KEY, mListView.getChoiceMode()); 599 } 600 601 if (mListAdapter != null) { 602 mListAdapter.saveSpecialItemInstanceState(outState); 603 } 604 } 605 606 @Override 607 public void onStart() { 608 super.onStart(); 609 mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL); 610 Analytics.getInstance().sendView(getClass().getName()); 611 } 612 613 @Override 614 public void onStop() { 615 super.onStop(); 616 mHandler.removeCallbacks(mUpdateTimestampsRunnable); 617 } 618 619 @Override 620 public void onViewModeChanged(int newMode) { 621 if (mTabletDevice) { 622 if (ViewMode.isListMode(newMode)) { 623 // There are no selected conversations when in conversation list mode. 624 clearChoicesAndActivated(); 625 } 626 } 627 if (mFooterView != null) { 628 mFooterView.onViewModeChanged(newMode); 629 } 630 } 631 632 public boolean isAnimating() { 633 final AnimatedAdapter adapter = getAnimatedAdapter(); 634 return (adapter != null && adapter.isAnimating()) || 635 (mListView != null && mListView.isScrolling()); 636 } 637 638 private void clearChoicesAndActivated() { 639 final int currentSelected = mListView.getCheckedItemPosition(); 640 if (currentSelected != ListView.INVALID_POSITION) { 641 mListView.setItemChecked(mListView.getCheckedItemPosition(), false); 642 } 643 } 644 645 /** 646 * Handles a request to show a new conversation list, either from a search 647 * query or for viewing a folder. This will initiate a data load, and hence 648 * must be called on the UI thread. 649 */ 650 private void showList() { 651 mListView.setEmptyView(null); 652 onFolderUpdated(mActivity.getFolderController().getFolder()); 653 onConversationListStatusUpdated(); 654 } 655 656 /** 657 * View the message at the given position. 658 * 659 * @param position The position of the conversation in the list (as opposed to its position 660 * in the cursor) 661 */ 662 private void viewConversation(final int position) { 663 LogUtils.d(LOG_TAG, "ConversationListFragment.viewConversation(%d)", position); 664 665 final ConversationCursor cursor = 666 (ConversationCursor) getAnimatedAdapter().getItem(position); 667 668 if (cursor == null) { 669 LogUtils.e(LOG_TAG, 670 "unable to open conv at cursor pos=%s cursor=%s getPositionOffset=%s", 671 position, cursor, getAnimatedAdapter().getPositionOffset(position)); 672 return; 673 } 674 675 final Conversation conv = cursor.getConversation(); 676 /* 677 * The cursor position may be different than the position method parameter because of 678 * special views in the list. 679 */ 680 conv.position = cursor.getPosition(); 681 setSelected(conv.position, true); 682 mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */); 683 } 684 685 private final ConversationListListener mConversationListListener = 686 new ConversationListListener() { 687 @Override 688 public boolean isExitingSelectionMode() { 689 return System.currentTimeMillis() < 690 (mSelectionModeExitedTimestamp + sSelectionModeAnimationDuration); 691 } 692 }; 693 694 /** 695 * Sets the selected conversation to the position given here. 696 * @param cursorPosition The position of the conversation in the cursor (as opposed to 697 * in the list) 698 * @param different if the currently selected conversation is different from the one provided 699 * here. This is a difference in conversations, not a difference in positions. For example, a 700 * conversation at position 2 can move to position 4 as a result of new mail. 701 */ 702 public void setSelected(final int cursorPosition, boolean different) { 703 if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) { 704 return; 705 } 706 707 final int position = 708 cursorPosition + getAnimatedAdapter().getPositionOffset(cursorPosition); 709 710 setRawSelected(position, different); 711 } 712 713 /** 714 * Sets the selected conversation to the position given here. 715 * @param position The position of the item in the list 716 * @param different if the currently selected conversation is different from the one provided 717 * here. This is a difference in conversations, not a difference in positions. For example, a 718 * conversation at position 2 can move to position 4 as a result of new mail. 719 */ 720 public void setRawSelected(final int position, final boolean different) { 721 if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) { 722 return; 723 } 724 725 if (different) { 726 mListView.smoothScrollToPosition(position); 727 } 728 mListView.setItemChecked(position, true); 729 } 730 731 /** 732 * Returns the cursor associated with the conversation list. 733 * @return 734 */ 735 private ConversationCursor getConversationListCursor() { 736 return mCallbacks != null ? mCallbacks.getConversationListCursor() : null; 737 } 738 739 /** 740 * Request a refresh of the list. No sync is carried out and none is 741 * promised. 742 */ 743 public void requestListRefresh() { 744 mListAdapter.notifyDataSetChanged(); 745 } 746 747 /** 748 * Change the UI to delete the conversations provided and then call the 749 * {@link DestructiveAction} provided here <b>after</b> the UI has been 750 * updated. 751 * @param conversations 752 * @param action 753 */ 754 public void requestDelete(int actionId, final Collection<Conversation> conversations, 755 final DestructiveAction action) { 756 for (Conversation conv : conversations) { 757 conv.localDeleteOnUpdate = true; 758 } 759 final ListItemsRemovedListener listener = new ListItemsRemovedListener() { 760 @Override 761 public void onListItemsRemoved() { 762 action.performAction(); 763 } 764 }; 765 final SwipeableListView listView = (SwipeableListView) getListView(); 766 if (listView.getSwipeAction() == actionId) { 767 if (!listView.destroyItems(conversations, listener)) { 768 // The listView failed to destroy the items, perform the action manually 769 LogUtils.e(LOG_TAG, "ConversationListFragment.requestDelete: " + 770 "listView failed to destroy items."); 771 action.performAction(); 772 } 773 return; 774 } 775 // Delete the local delete items (all for now) and when done, 776 // update... 777 mListAdapter.delete(conversations, listener); 778 } 779 780 public void onFolderUpdated(Folder folder) { 781 mFolder = folder; 782 setSwipeAction(); 783 if (mFolder == null) { 784 return; 785 } 786 mListAdapter.setFolder(mFolder); 787 mFooterView.setFolder(mFolder); 788 if (!mFolder.wasSyncSuccessful()) { 789 mErrorListener.onError(mFolder, false); 790 } 791 792 // Notify of changes to the Folder. 793 onFolderStatusUpdated(); 794 795 // Blow away conversation items cache. 796 ConversationItemViewModel.onFolderUpdated(mFolder); 797 } 798 799 /** 800 * Updates the footer visibility and updates the conversation cursor 801 */ 802 public void onConversationListStatusUpdated() { 803 final ConversationCursor cursor = getConversationListCursor(); 804 final boolean showFooter = mFooterView.updateStatus(cursor); 805 // Update the folder status, in case the cursor could affect it. 806 onFolderStatusUpdated(); 807 mListAdapter.setFooterVisibility(showFooter); 808 809 // Also change the cursor here. 810 onCursorUpdated(); 811 } 812 813 private void onFolderStatusUpdated() { 814 // Update the sync status bar with sync results if needed 815 checkSyncStatus(); 816 817 final ConversationCursor cursor = getConversationListCursor(); 818 Bundle extras = cursor != null ? cursor.getExtras() : Bundle.EMPTY; 819 int errorStatus = extras.containsKey(UIProvider.CursorExtraKeys.EXTRA_ERROR) ? 820 extras.getInt(UIProvider.CursorExtraKeys.EXTRA_ERROR) 821 : UIProvider.LastSyncResult.SUCCESS; 822 int cursorStatus = extras.getInt(UIProvider.CursorExtraKeys.EXTRA_STATUS); 823 // We want to update the UI with this information if either we are loaded or complete, or 824 // we have a folder with a non-0 count. 825 final int folderCount = mFolder != null ? mFolder.totalCount : 0; 826 if (errorStatus == UIProvider.LastSyncResult.SUCCESS 827 && (cursorStatus == UIProvider.CursorStatus.LOADED 828 || cursorStatus == UIProvider.CursorStatus.COMPLETE) || folderCount > 0) { 829 updateSearchResultHeader(folderCount); 830 if (folderCount == 0) { 831 mListView.setEmptyView(mEmptyView); 832 } 833 } 834 } 835 836 private void setSwipeAction() { 837 int swipeSetting = Settings.getSwipeSetting(mAccount.settings); 838 if (swipeSetting == Swipe.DISABLED 839 || !mAccount.supportsCapability(AccountCapabilities.UNDO) 840 || (mFolder != null && mFolder.isTrash())) { 841 mListView.enableSwipe(false); 842 } else { 843 final int action; 844 mListView.enableSwipe(true); 845 if (ConversationListContext.isSearchResult(mViewContext) 846 || (mFolder != null && mFolder.isType(FolderType.SPAM))) { 847 action = R.id.delete; 848 } else if (mFolder == null) { 849 action = R.id.remove_folder; 850 } else { 851 // We have enough information to respect user settings. 852 switch (swipeSetting) { 853 case Swipe.ARCHIVE: 854 if (mAccount.supportsCapability(AccountCapabilities.ARCHIVE)) { 855 if (mFolder.supportsCapability(FolderCapabilities.ARCHIVE)) { 856 action = R.id.archive; 857 break; 858 } else if (mFolder.supportsCapability 859 (FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)) { 860 action = R.id.remove_folder; 861 break; 862 } 863 } 864 865 /* 866 * If we get here, we don't support archive, on either the account or the 867 * folder, so we want to fall through into the delete case. 868 */ 869 //$FALL-THROUGH$ 870 case Swipe.DELETE: 871 default: 872 action = R.id.delete; 873 break; 874 } 875 } 876 mListView.setSwipeAction(action); 877 } 878 mListView.setCurrentAccount(mAccount); 879 mListView.setCurrentFolder(mFolder); 880 } 881 882 /** 883 * Changes the conversation cursor in the list and sets selected position if none is set. 884 */ 885 private void onCursorUpdated() { 886 if (mCallbacks == null || mListAdapter == null) { 887 return; 888 } 889 // Check against the previous cursor here and see if they are the same. If they are, then 890 // do a notifyDataSetChanged. 891 final ConversationCursor newCursor = mCallbacks.getConversationListCursor(); 892 893 if (newCursor == null && mListAdapter.getCursor() != null) { 894 // We're losing our cursor, so save our scroll position 895 saveLastScrolledPosition(); 896 } 897 898 mListAdapter.swapCursor(newCursor); 899 // When the conversation cursor is *updated*, we get back the same instance. In that 900 // situation, CursorAdapter.swapCursor() silently returns, without forcing a 901 // notifyDataSetChanged(). So let's force a call to notifyDataSetChanged, since an updated 902 // cursor means that the dataset has changed. 903 final int newCursorHash = (newCursor == null) ? 0 : newCursor.hashCode(); 904 if (mConversationCursorHash == newCursorHash && mConversationCursorHash != 0) { 905 mListAdapter.notifyDataSetChanged(); 906 } 907 mConversationCursorHash = newCursorHash; 908 909 if (newCursor != null && newCursor.getCount() > 0) { 910 newCursor.markContentsSeen(); 911 restoreLastScrolledPosition(); 912 } 913 914 // If a current conversation is available, and none is selected in the list, then ask 915 // the list to select the current conversation. 916 final Conversation conv = mCallbacks.getCurrentConversation(); 917 if (conv != null) { 918 if (mListView.getChoiceMode() != ListView.CHOICE_MODE_NONE 919 && mListView.getCheckedItemPosition() == -1) { 920 setSelected(conv.position, true); 921 } 922 } 923 } 924 925 public void commitDestructiveActions(boolean animate) { 926 if (mListView != null) { 927 mListView.commitDestructiveActions(animate); 928 929 } 930 } 931 932 @Override 933 public void onListItemSwiped(Collection<Conversation> conversations) { 934 mUpdater.showNextConversation(conversations); 935 } 936 937 private void checkSyncStatus() { 938 if (mFolder != null && mFolder.isSyncInProgress()) { 939 LogUtils.d(LOG_TAG, "CLF.checkSyncStatus still syncing"); 940 // Still syncing, ignore 941 } else { 942 // Finished syncing: 943 LogUtils.d(LOG_TAG, "CLF.checkSyncStatus done syncing"); 944 mConversationListView.onSyncFinished(); 945 } 946 } 947 948 /** 949 * Displays the indefinite progress bar indicating a sync is in progress. This 950 * should only be called if user manually requested a sync, and not for background syncs. 951 */ 952 protected void showSyncStatusBar() { 953 mConversationListView.showSyncStatusBar(); 954 } 955 956 /** 957 * Clears all items in the list. 958 */ 959 public void clear() { 960 mListView.setAdapter(null); 961 } 962 963 private final ConversationSetObserver mConversationSetObserver = new ConversationSetObserver() { 964 @Override 965 public void onSetPopulated(final ConversationSelectionSet set) { 966 // Do nothing 967 } 968 969 @Override 970 public void onSetEmpty() { 971 mSelectionModeExitedTimestamp = System.currentTimeMillis(); 972 } 973 974 @Override 975 public void onSetChanged(final ConversationSelectionSet set) { 976 // Do nothing 977 } 978 }; 979 980 private void saveLastScrolledPosition() { 981 if (mListAdapter.getCursor() == null) { 982 // If you save your scroll position in an empty list, you're gonna have a bad time 983 return; 984 } 985 986 final Parcelable savedState = mListView.onSaveInstanceState(); 987 988 mActivity.getListHandler().setConversationListScrollPosition( 989 mFolder.conversationListUri.toString(), savedState); 990 } 991 992 private void restoreLastScrolledPosition() { 993 // Scroll to our previous position, if necessary 994 if (!mScrollPositionRestored && mFolder != null) { 995 final String key = mFolder.conversationListUri.toString(); 996 final Parcelable savedState = mActivity.getListHandler() 997 .getConversationListScrollPosition(key); 998 if (savedState != null) { 999 mListView.onRestoreInstanceState(savedState); 1000 } 1001 mScrollPositionRestored = true; 1002 } 1003 } 1004 } 1005