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