/* * Copyright (C) 2012 Google Inc. * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.mail.ui; import android.animation.LayoutTransition; import android.app.Activity; import android.app.Fragment; import android.app.LoaderManager; import android.content.Intent; import android.content.res.Resources; import android.database.DataSetObserver; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Parcelable; import android.support.annotation.IdRes; import android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.ListView; import android.widget.TextView; import com.android.mail.ConversationListContext; import com.android.mail.R; import com.android.mail.analytics.Analytics; import com.android.mail.analytics.AnalyticsTimer; import com.android.mail.browse.ConversationCursor; import com.android.mail.browse.ConversationItemView; import com.android.mail.browse.ConversationItemViewModel; import com.android.mail.browse.ConversationListFooterView; import com.android.mail.browse.ToggleableItem; import com.android.mail.providers.Account; import com.android.mail.providers.AccountObserver; import com.android.mail.providers.Conversation; import com.android.mail.providers.Folder; import com.android.mail.providers.FolderObserver; import com.android.mail.providers.Settings; import com.android.mail.providers.UIProvider; import com.android.mail.providers.UIProvider.AccountCapabilities; import com.android.mail.providers.UIProvider.ConversationListIcon; import com.android.mail.providers.UIProvider.FolderCapabilities; import com.android.mail.providers.UIProvider.Swipe; import com.android.mail.ui.SwipeableListView.ListItemSwipedListener; import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener; import com.android.mail.ui.SwipeableListView.SwipeListener; import com.android.mail.ui.ViewMode.ModeChangeListener; import com.android.mail.utils.KeyboardUtils; import com.android.mail.utils.LogTag; import com.android.mail.utils.LogUtils; import com.android.mail.utils.Utils; import com.android.mail.utils.ViewUtils; import com.google.common.collect.ImmutableList; import java.util.Collection; import java.util.List; import static android.view.View.OnKeyListener; /** * The conversation list UI component. */ public final class ConversationListFragment extends Fragment implements OnItemLongClickListener, ModeChangeListener, ListItemSwipedListener, OnRefreshListener, SwipeListener, OnKeyListener, AdapterView.OnItemClickListener, View.OnClickListener, AbsListView.OnScrollListener { /** Key used to pass data to {@link ConversationListFragment}. */ private static final String CONVERSATION_LIST_KEY = "conversation-list"; /** Key used to keep track of the scroll state of the list. */ private static final String LIST_STATE_KEY = "list-state"; private static final String LOG_TAG = LogTag.getLogTag(); /** Key used to save the ListView choice mode, since ListView doesn't save it automatically! */ private static final String CHOICE_MODE_KEY = "choice-mode-key"; // True if we are on a tablet device private static boolean mTabletDevice; // Delay before displaying the loading view. private static int LOADING_DELAY_MS; // Minimum amount of time to keep the loading view displayed. private static int MINIMUM_LOADING_DURATION; /** * Frequency of update of timestamps. Initialized in * {@link #onCreate(Bundle)} and final afterwards. */ private static int TIMESTAMP_UPDATE_INTERVAL = 0; private ControllableActivity mActivity; // Control state. private ConversationListCallbacks mCallbacks; private final Handler mHandler = new Handler(); // The internal view objects. private SwipeableListView mListView; private View mSearchHeaderView; private TextView mSearchResultCountTextView; /** * Current Account being viewed */ private Account mAccount; /** * Current folder being viewed. */ private Folder mFolder; /** * A simple method to update the timestamps of conversations periodically. */ private Runnable mUpdateTimestampsRunnable = null; private ConversationListContext mViewContext; private AnimatedAdapter mListAdapter; private ConversationListFooterView mFooterView; private ConversationListEmptyView mEmptyView; private View mSecurityHoldView; private TextView mSecurityHoldText; private View mSecurityHoldButton; private View mLoadingView; private ErrorListener mErrorListener; private FolderObserver mFolderObserver; private DataSetObserver mConversationCursorObserver; private ConversationCheckedSet mCheckedSet; private final AccountObserver mAccountObserver = new AccountObserver() { @Override public void onChanged(Account newAccount) { mAccount = newAccount; setSwipeAction(); } }; private ConversationUpdater mUpdater; /** Hash of the Conversation Cursor we last obtained from the controller. */ private int mConversationCursorHash; // The number of items in the last known ConversationCursor private int mConversationCursorLastCount; // State variable to keep track if we just loaded a new list, used for analytics only // True if NO DATA has returned, false if we either partially or fully loaded the data private boolean mInitialCursorLoading; private @IdRes int mNextFocusStartId; // Tracks if a onKey event was initiated from the listview (received ACTION_DOWN before // ACTION_UP). If not, the listview only receives ACTION_UP. private boolean mKeyInitiatedFromList; // Default color id for what background should be while idle private int mDefaultListBackgroundColor; /** Duration, in milliseconds, of the CAB mode (peek icon) animation. */ private static long sSelectionModeAnimationDuration = -1; // Let's ensure that we are only showing one out of the three views at once private void showListView() { setupEmptyIcon(false); mListView.setVisibility(View.VISIBLE); mEmptyView.setVisibility(View.INVISIBLE); mLoadingView.setVisibility(View.INVISIBLE); mSecurityHoldView.setVisibility(View.INVISIBLE); } private void showSecurityHoldView() { setupEmptyIcon(false); mListView.setVisibility(View.INVISIBLE); mEmptyView.setVisibility(View.INVISIBLE); mLoadingView.setVisibility(View.INVISIBLE); setupSecurityHoldView(); mSecurityHoldView.setVisibility(View.VISIBLE); } private void showEmptyView() { // If the callbacks didn't set up the empty icon, then we should show it in the empty view. final boolean shouldShowIcon = !setupEmptyIcon(true); mEmptyView.setupEmptyText(mFolder, mViewContext.searchQuery, mListAdapter.getBidiFormatter(), shouldShowIcon); mListView.setVisibility(View.INVISIBLE); mEmptyView.setVisibility(View.VISIBLE); mLoadingView.setVisibility(View.INVISIBLE); mSecurityHoldView.setVisibility(View.INVISIBLE); } private void showLoadingView() { setupEmptyIcon(false); mListView.setVisibility(View.INVISIBLE); mEmptyView.setVisibility(View.INVISIBLE); mLoadingView.setVisibility(View.VISIBLE); mSecurityHoldView.setVisibility(View.INVISIBLE); } private boolean setupEmptyIcon(boolean isEmpty) { return mCallbacks != null && mCallbacks.setupEmptyIconView(mFolder, isEmpty); } private void setupSecurityHoldView() { mSecurityHoldText.setText(getString(R.string.security_hold_required_text, mAccount.getDisplayName())); } private final Runnable mLoadingViewRunnable = new FragmentRunnable("LoadingRunnable", this) { @Override public void go() { if (!isCursorReadyToShow()) { mCanTakeDownLoadingView = false; showLoadingView(); mHandler.removeCallbacks(mHideLoadingRunnable); mHandler.postDelayed(mHideLoadingRunnable, MINIMUM_LOADING_DURATION); } mLoadingViewPending = false; } }; private final Runnable mHideLoadingRunnable = new FragmentRunnable("CancelLoading", this) { @Override public void go() { mCanTakeDownLoadingView = true; if (isCursorReadyToShow()) { hideLoadingViewAndShowContents(); } } }; // Keep track of if we are waiting for the loading view. This variable is also used to check // if the cursor corresponding to the current folder loaded (either partially or completely). private boolean mLoadingViewPending; private boolean mCanTakeDownLoadingView; /** * If true, we have restored (or attempted to restore) the list's scroll position * from when we were last on this conversation list. */ private boolean mScrollPositionRestored = false; private MailSwipeRefreshLayout mSwipeRefreshWidget; /** * Constructor needs to be public to handle orientation changes and activity * lifecycle events. */ public ConversationListFragment() { super(); } @Override public void onBeginSwipe() { mSwipeRefreshWidget.setEnabled(false); } @Override public void onEndSwipe() { mSwipeRefreshWidget.setEnabled(true); } private class ConversationCursorObserver extends DataSetObserver { @Override public void onChanged() { onConversationListStatusUpdated(); } } /** * Creates a new instance of {@link ConversationListFragment}, initialized * to display conversation list context. */ public static ConversationListFragment newInstance(ConversationListContext viewContext) { final ConversationListFragment fragment = new ConversationListFragment(); final Bundle args = new Bundle(1); args.putBundle(CONVERSATION_LIST_KEY, viewContext.toBundle()); fragment.setArguments(args); return fragment; } /** * Show the header if the current conversation list is showing search * results. */ private void updateSearchResultHeader(int count) { if (mActivity == null || mSearchHeaderView == null) { return; } mSearchResultCountTextView.setText( getResources().getString(R.string.search_results_loaded, count)); } @Override public void onActivityCreated(Bundle savedState) { super.onActivityCreated(savedState); mLoadingViewPending = false; mCanTakeDownLoadingView = true; if (sSelectionModeAnimationDuration < 0) { sSelectionModeAnimationDuration = getResources().getInteger( R.integer.conv_item_view_cab_anim_duration); } // Strictly speaking, we get back an android.app.Activity from // getActivity. However, the // only activity creating a ConversationListContext is a MailActivity // which is of type // ControllableActivity, so this cast should be safe. If this cast // fails, some other // activity is creating ConversationListFragments. This activity must be // of type // ControllableActivity. final Activity activity = getActivity(); if (!(activity instanceof ControllableActivity)) { LogUtils.e(LOG_TAG, "ConversationListFragment expects only a ControllableActivity to" + "create it. Cannot proceed."); } mActivity = (ControllableActivity) activity; // Since we now have a controllable activity, load the account from it, // and register for // future account changes. mAccount = mAccountObserver.initialize(mActivity.getAccountController()); mCallbacks = mActivity.getListHandler(); mErrorListener = mActivity.getErrorListener(); // Start off with the current state of the folder being viewed. final LayoutInflater inflater = LayoutInflater.from(mActivity.getActivityContext()); mFooterView = (ConversationListFooterView) inflater.inflate( R.layout.conversation_list_footer_view, null); mFooterView.setClickListener(mActivity); final ConversationCursor conversationCursor = getConversationListCursor(); final LoaderManager manager = getLoaderManager(); // TODO: These special views are always created, doesn't matter whether they will // be shown or not, as we add more views this will get more expensive. Given these are // tips that are only shown once to the user, we should consider creating these on demand. final ConversationListHelper helper = mActivity.getConversationListHelper(); final List specialItemViews = helper != null ? ImmutableList.copyOf(helper.makeConversationListSpecialViews( activity, mActivity, mAccount)) : null; if (specialItemViews != null) { // Attach to the LoaderManager for (final ConversationSpecialItemView view : specialItemViews) { view.bindFragment(manager, savedState); } } mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor, mActivity.getCheckedSet(), mActivity, mListView, specialItemViews); mListAdapter.addFooter(mFooterView); // Show search result header only if we are in search mode final boolean showSearchHeader = ConversationListContext.isSearchResult(mViewContext); if (showSearchHeader) { mSearchHeaderView = inflater.inflate(R.layout.search_results_view, null); mSearchResultCountTextView = (TextView) mSearchHeaderView.findViewById(R.id.search_result_count_view); mListAdapter.addHeader(mSearchHeaderView); } mListView.setAdapter(mListAdapter); mCheckedSet = mActivity.getCheckedSet(); mListView.setCheckedSet(mCheckedSet); mListAdapter.setFooterVisibility(false); mFolderObserver = new FolderObserver(){ @Override public void onChanged(Folder newFolder) { onFolderUpdated(newFolder); } }; mFolderObserver.initialize(mActivity.getFolderController()); mConversationCursorObserver = new ConversationCursorObserver(); mUpdater = mActivity.getConversationUpdater(); mUpdater.registerConversationListObserver(mConversationCursorObserver); mTabletDevice = Utils.useTabletUI(mActivity.getApplicationContext().getResources()); // Shadow mods to TL require background changes and scroll listening to avoid overdraw mDefaultListBackgroundColor = getResources().getColor(R.color.conversation_list_background_color); getView().setBackgroundColor(mDefaultListBackgroundColor); mListView.setOnScrollListener(this); // The onViewModeChanged callback doesn't get called when the mode // object is created, so // force setting the mode manually this time around. onViewModeChanged(mActivity.getViewMode().getMode()); mActivity.getViewMode().addListener(this); if (mActivity.getListHandler().shouldPreventListSwipesEntirely()) { mListView.preventSwipesEntirely(); } else { mListView.stopPreventingSwipes(); } if (mActivity.isFinishing()) { // Activity is finishing, just bail. return; } mConversationCursorHash = (conversationCursor == null) ? 0 : conversationCursor.hashCode(); // Belt and suspenders here; make sure we do any necessary sync of the // ConversationCursor if (conversationCursor != null && conversationCursor.isRefreshReady()) { conversationCursor.sync(); } // On a phone we never highlight a conversation, so the default is to select none. // On a tablet, we highlight a SINGLE conversation in landscape conversation view. int choice = getDefaultChoiceMode(mTabletDevice); if (savedState != null) { // Restore the choice mode if it was set earlier, or NONE if creating a fresh view. // Choice mode here represents the current conversation only. CAB mode does not rely on // the platform: checked state is a local variable {@link ConversationItemView#mChecked} choice = savedState.getInt(CHOICE_MODE_KEY, choice); if (savedState.containsKey(LIST_STATE_KEY)) { // TODO: find a better way to unset the selected item when restoring mListView.clearChoices(); } } setChoiceMode(choice); // Show list and start loading list. showList(); ToastBarOperation pendingOp = mActivity.getPendingToastOperation(); if (pendingOp != null) { // Clear the pending operation mActivity.setPendingToastOperation(null); mActivity.onUndoAvailable(pendingOp); } } /** * Returns the default choice mode for the list based on whether the list is displayed on tablet * or not. * @param isTablet * @return */ private final static int getDefaultChoiceMode(boolean isTablet) { return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE; } public AnimatedAdapter getAnimatedAdapter() { return mListAdapter; } @Override public void onCreate(Bundle savedState) { super.onCreate(savedState); // Initialize fragment constants from resources final Resources res = getResources(); TIMESTAMP_UPDATE_INTERVAL = res.getInteger(R.integer.timestamp_update_interval); LOADING_DELAY_MS = res.getInteger(R.integer.conversationview_show_loading_delay); MINIMUM_LOADING_DURATION = res.getInteger(R.integer.conversationview_min_show_loading); mUpdateTimestampsRunnable = new Runnable() { @Override public void run() { mListView.invalidateViews(); mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL); } }; // Get the context from the arguments final Bundle args = getArguments(); mViewContext = ConversationListContext.forBundle(args.getBundle(CONVERSATION_LIST_KEY)); mAccount = mViewContext.account; setRetainInstance(false); } @Override public String toString() { final String s = super.toString(); if (mViewContext == null) { return s; } final StringBuilder sb = new StringBuilder(s); sb.setLength(sb.length() - 1); sb.append(" mListAdapter="); sb.append(mListAdapter); sb.append(" folder="); sb.append(mViewContext.folder); if (mListView != null) { sb.append(" selectedPos="); sb.append(mListView.getSelectedConversationPosDebug()); sb.append(" listSelectedPos="); sb.append(mListView.getSelectedItemPosition()); sb.append(" isListInTouchMode="); sb.append(mListView.isInTouchMode()); } sb.append("}"); return sb.toString(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { View rootView = inflater.inflate(R.layout.conversation_list, null); mEmptyView = (ConversationListEmptyView) rootView.findViewById(R.id.empty_view); mSecurityHoldView = rootView.findViewById(R.id.security_hold_view); mSecurityHoldText = (TextView) rootView.findViewById(R.id.security_hold_text); mSecurityHoldButton = rootView.findViewById(R.id.security_hold_button); mSecurityHoldButton.setOnClickListener(this); mLoadingView = rootView.findViewById(R.id.conversation_list_loading_view); mListView = (SwipeableListView) rootView.findViewById(R.id.conversation_list_view); mListView.setHeaderDividersEnabled(false); mListView.setOnItemLongClickListener(this); mListView.enableSwipe(mAccount.supportsCapability(AccountCapabilities.UNDO)); mListView.setListItemSwipedListener(this); mListView.setSwipeListener(this); mListView.setOnKeyListener(this); mListView.setOnItemClickListener(this); // For tablets, the default left focus is the mini-drawer if (mTabletDevice && mNextFocusStartId == 0) { mNextFocusStartId = R.id.mini_drawer; } setNextFocusStartOnList(); // enable animateOnLayout (equivalent of setLayoutTransition) only for >=JB (b/14302062) if (Utils.isRunningJellybeanOrLater()) { ((ViewGroup) rootView.findViewById(R.id.conversation_list_parent_frame)) .setLayoutTransition(new LayoutTransition()); } // By default let's show the list view showListView(); if (savedState != null && savedState.containsKey(LIST_STATE_KEY)) { mListView.onRestoreInstanceState(savedState.getParcelable(LIST_STATE_KEY)); } mSwipeRefreshWidget = (MailSwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_widget); mSwipeRefreshWidget.setColorScheme(R.color.swipe_refresh_color1, R.color.swipe_refresh_color2, R.color.swipe_refresh_color3, R.color.swipe_refresh_color4); mSwipeRefreshWidget.setOnRefreshListener(this); mSwipeRefreshWidget.setScrollableChild(mListView); return rootView; } /** * Sets the choice mode of the list view */ private final void setChoiceMode(int choiceMode) { mListView.setChoiceMode(choiceMode); } /** * Tell the list to select nothing. */ public final void setChoiceNone() { // On a phone, the default choice mode is already none, so nothing to do. if (!mTabletDevice) { return; } clearChoicesAndActivated(); setChoiceMode(ListView.CHOICE_MODE_NONE); } /** * Tell the list to get out of selecting none. */ public final void revertChoiceMode() { // On a phone, the default choice mode is always none, so nothing to do. if (!mTabletDevice) { return; } setChoiceMode(getDefaultChoiceMode(mTabletDevice)); } @Override public void onDestroy() { super.onDestroy(); } @Override public void onDestroyView() { // Clear the list's adapter mListAdapter.destroy(); mListView.setAdapter(null); mActivity.getViewMode().removeListener(this); if (mFolderObserver != null) { mFolderObserver.unregisterAndDestroy(); mFolderObserver = null; } if (mConversationCursorObserver != null) { mUpdater.unregisterConversationListObserver(mConversationCursorObserver); mConversationCursorObserver = null; } mAccountObserver.unregisterAndDestroy(); getAnimatedAdapter().cleanup(); super.onDestroyView(); } /** * There are three binary variables, which determine what we do with a * message. checkbEnabled: Whether check boxes are enabled or not (forced * true on tablet) cabModeOn: Whether CAB mode is currently on or not. * pressType: long or short tap (There is a third possibility: phone or * tablet, but they have identical behavior) The matrix of * possibilities is: *

* Long tap: Always toggle selection of conversation. If CAB mode is not * started, then start it. *

     *              | Checkboxes | No Checkboxes
     *    ----------+------------+---------------
     *    CAB mode  |   Select   |     Select
     *    List mode |   Select   |     Select
     *
     * 
* * Reference: http://b/issue?id=6392199 *

* {@inheritDoc} */ @Override public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { // Ignore anything that is not a conversation item. Could be a footer. if (!(view instanceof ConversationItemView)) { return false; } return ((ConversationItemView) view).toggleCheckedState("long_press"); } /** * See the comment for * {@link #onItemLongClick(AdapterView, View, int, long)}. *

* Short tap behavior: * *

     *              | Checkboxes | No Checkboxes
     *    ----------+------------+---------------
     *    CAB mode  |    Peek    |     Select
     *    List mode |    Peek    |      Peek
     * 
* * Reference: http://b/issue?id=6392199 *

* {@inheritDoc} */ @Override public void onItemClick(AdapterView adapterView, View view, int position, long id) { onListItemSelected(view, position); } private void onListItemSelected(View view, int position) { if (view instanceof ToggleableItem) { final boolean showSenderImage = (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE); final boolean inCabMode = !mCheckedSet.isEmpty(); if (!showSenderImage && inCabMode) { ((ToggleableItem) view).toggleCheckedState(); } else { if (inCabMode) { // this is a peek. Analytics.getInstance().sendEvent("peek", null, null, mCheckedSet.size()); } AnalyticsTimer.getInstance().trackStart(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST); viewConversation(position); } } else { // Ignore anything that is not a conversation item. Could be a footer. // If we are using a keyboard, the highlighted item is the parent; // otherwise, this is a direct call from the ConverationItemView return; } // When a new list item is clicked, commit any existing leave behind // items. Wait until we have opened the desired conversation to cause // any position changes. commitDestructiveActions(Utils.useTabletUI(mActivity.getActivityContext().getResources())); } @Override public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { if (view instanceof SwipeableListView) { SwipeableListView list = (SwipeableListView) view; // Don't need to handle ENTER because it's auto-handled as a "click". if (KeyboardUtils.isKeycodeDirectionEnd(keyCode, ViewUtils.isViewRtl(list))) { if (keyEvent.getAction() == KeyEvent.ACTION_UP) { if (mKeyInitiatedFromList) { int currentPos = list.getSelectedItemPosition(); if (currentPos < 0) { // Find the activated item if the focused item is non-existent. // This can happen when the user transitions from touch mode. currentPos = list.getCheckedItemPosition(); } if (currentPos >= 0) { // We don't use onListItemSelected because right arrow should always // view the conversation even in CAB/no_sender_image mode. viewConversation(currentPos); commitDestructiveActions(Utils.useTabletUI( mActivity.getActivityContext().getResources())); } } mKeyInitiatedFromList = false; } else if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { mKeyInitiatedFromList = true; } return true; } else if ((keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) && keyEvent.getAction() == KeyEvent.ACTION_UP) { final int position = list.getSelectedItemPosition(); if (position >= 0) { final Object item = getAnimatedAdapter().getItem(position); if (item != null && item instanceof ConversationCursor) { final Conversation conv = ((ConversationCursor) item).getConversation(); mCallbacks.onConversationFocused(conv); } } } } return false; } @Override public void onResume() { super.onResume(); if (!isCursorReadyToShow()) { // If the cursor got reset, let's reset the analytics state variable and show the list // view since we are waiting for load again mInitialCursorLoading = true; showListView(); } final ConversationCursor conversationCursor = getConversationListCursor(); if (conversationCursor != null) { conversationCursor.handleNotificationActions(); restoreLastScrolledPosition(); } mCheckedSet.addObserver(mConversationSetObserver); } @Override public void onPause() { super.onPause(); mCheckedSet.removeObserver(mConversationSetObserver); saveLastScrolledPosition(); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mListView != null) { outState.putParcelable(LIST_STATE_KEY, mListView.onSaveInstanceState()); outState.putInt(CHOICE_MODE_KEY, mListView.getChoiceMode()); } if (mListAdapter != null) { mListAdapter.saveSpecialItemInstanceState(outState); } } @Override public void onStart() { super.onStart(); mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL); Analytics.getInstance().sendView("ConversationListFragment"); } @Override public void onStop() { super.onStop(); mHandler.removeCallbacks(mUpdateTimestampsRunnable); } @Override public void onViewModeChanged(int newMode) { if (mTabletDevice) { if (ViewMode.isListMode(newMode)) { // There are no checked conversations when in conversation list mode. clearChoicesAndActivated(); } } } public boolean isAnimating() { final AnimatedAdapter adapter = getAnimatedAdapter(); if (adapter != null && adapter.isAnimating()) { return true; } final boolean isScrolling = (mListView != null && mListView.isScrolling()); if (isScrolling) { LogUtils.i(LOG_TAG, "CLF.isAnimating=true due to scrolling"); } return isScrolling; } protected void clearChoicesAndActivated() { final int currentChecked = mListView.getCheckedItemPosition(); if (currentChecked != ListView.INVALID_POSITION) { mListView.setItemChecked(currentChecked, false); } } /** * Handles a request to show a new conversation list, either from a search * query or for viewing a folder. This will initiate a data load, and hence * must be called on the UI thread. */ private void showList() { mInitialCursorLoading = true; onFolderUpdated(mActivity.getFolderController().getFolder()); onConversationListStatusUpdated(); // try to get an order-of-magnitude sense for message count within folders // (N.B. this count currently isn't working for search folders, since their counts stream // in over time in pieces.) final Folder f = mViewContext.folder; if (f != null) { final long countLog; if (f.totalCount > 0) { countLog = (long) Math.log10(f.totalCount); } else { countLog = 0; } Analytics.getInstance().sendEvent("view_folder", f.getTypeDescription(), Long.toString(countLog), f.totalCount); } } /** * View the message at the given position. * * @param position The position of the conversation in the list (as opposed to its position * in the cursor) */ private void viewConversation(final int position) { LogUtils.d(LOG_TAG, "ConversationListFragment.viewConversation(%d)", position); final Object item = getAnimatedAdapter().getItem(position); if (item != null && item instanceof ConversationCursor) { final ConversationCursor cursor = (ConversationCursor) item; final Conversation conv = cursor.getConversation(); /* * The cursor position may be different than the position method parameter because of * special views in the list. */ conv.position = cursor.getPosition(); setActivated(conv, true); mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */); } else { LogUtils.e(LOG_TAG, "unable to open conv at cursor pos=%s item=%s getPositionOffset=%s", position, item, getAnimatedAdapter().getPositionOffset(position)); } } /** * Sets the checked conversation to the position given here. * @param conversation the activated conversation. * @param different if the currently checked conversation is different from the one provided * here. This is a difference in conversations, not a difference in positions. For example, a * conversation at position 2 can move to position 4 as a result of new mail. */ public void setActivated(final Conversation conversation, boolean different) { if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE || conversation == null) { return; } final int cursorPosition = conversation.position; final int position = cursorPosition + mListAdapter.getPositionOffset(cursorPosition); setRawActivated(position, different); setRawSelected(conversation, position); } /** * Set the selected conversation (used by the framework to indicate current focus in the list). * @param conversation the selected conversation. */ public void setSelected(final Conversation conversation) { if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE || conversation == null) { return; } final int cursorPosition = conversation.position; final int position = cursorPosition + mListAdapter.getPositionOffset(cursorPosition); setRawSelected(conversation, position); } /** * Set the selected conversation (used by the framework to indicate current focus in the list). * @param position The position of the item in the list */ private void setRawSelected(Conversation conversation, final int position) { final View selectedView = mListView.getChildAt( position - mListView.getFirstVisiblePosition()); // Don't do anything if the view is already selected. if (!(selectedView != null && selectedView.isSelected())) { final int firstVisible = mListView.getFirstVisiblePosition(); final int lastVisible = mListView.getLastVisiblePosition(); // Check if the view is off the screen if (selectedView == null || position < firstVisible || position > lastVisible) { mListView.setSelection(position); } else { // If the view is on screen, we call setSelectionFromTop with a top offset. This // prevents the list from stupidly scrolling the item to the top because // setSelection calls setSelectionFromTop with y = 0. mListView.setSelectionFromTop(position, selectedView.getTop()); } mListView.setSelectedConversation(conversation); } } /** * Sets the activated conversation to the position given here. * @param position The position of the item in the list * @param different if the currently activated conversation is different from the one provided * here. This is a difference in conversations, not a difference in positions. For example, a * conversation at position 2 can move to position 4 as a result of new mail. */ public void setRawActivated(final int position, final boolean different) { if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) { return; } if (different) { mListView.smoothScrollToPosition(position); } // Internally setItemChecked will set the activated bit if the item does not implement // the Checkable interface. We use checked state to indicated CAB selection mode. mListView.setItemChecked(position, true); } /** * Returns the cursor associated with the conversation list. * @return */ private ConversationCursor getConversationListCursor() { return mCallbacks != null ? mCallbacks.getConversationListCursor() : null; } /** * Request a refresh of the list. No sync is carried out and none is * promised. */ public void requestListRefresh() { mListAdapter.notifyDataSetChanged(); } /** * Change the UI to delete the conversations provided and then call the * {@link DestructiveAction} provided here after the UI has been * updated. * @param conversations * @param action */ public void requestDelete(int actionId, final Collection conversations, final DestructiveAction action) { for (Conversation conv : conversations) { conv.localDeleteOnUpdate = true; } final ListItemsRemovedListener listener = new ListItemsRemovedListener() { @Override public void onListItemsRemoved() { action.performAction(); } }; if (mListView.getSwipeAction() == actionId) { if (!mListView.destroyItems(conversations, listener)) { // The listView failed to destroy the items, perform the action manually LogUtils.e(LOG_TAG, "ConversationListFragment.requestDelete: " + "listView failed to destroy items."); action.performAction(); } return; } // Delete the local delete items (all for now) and when done, // update... mListAdapter.delete(conversations, listener); } public void onFolderUpdated(Folder folder) { if (!isCursorReadyToShow()) { // Wait a bit before showing either the empty or loading view. If the messages are // actually local, it's disorienting to see this appear on every folder transition. // If they aren't, then it will likely take more than 200 milliseconds to load, and // then we'll see the loading view. if (!mLoadingViewPending) { mHandler.postDelayed(mLoadingViewRunnable, LOADING_DELAY_MS); mLoadingViewPending = true; } } mFolder = folder; setSwipeAction(); // Update enabled state of swipe to refresh. mSwipeRefreshWidget.setEnabled(!ConversationListContext.isSearchResult(mViewContext)); if (mFolder == null) { return; } mListAdapter.setFolder(mFolder); mFooterView.setFolder(mFolder); if (!mFolder.wasSyncSuccessful()) { mErrorListener.onError(mFolder, false); } // Update the sync status bar with sync results if needed checkSyncStatus(); // Blow away conversation items cache. ConversationItemViewModel.onFolderUpdated(mFolder); } /** * Updates the footer visibility and updates the conversation cursor */ public void onConversationListStatusUpdated() { // Also change the cursor here. onCursorUpdated(); if (isCursorReadyToShow() && mCanTakeDownLoadingView) { hideLoadingViewAndShowContents(); } } private void hideLoadingViewAndShowContents() { final ConversationCursor cursor = getConversationListCursor(); final boolean showFooter = mFooterView.updateStatus(cursor); // Update the sync status bar with sync results if needed checkSyncStatus(); mListAdapter.setFooterVisibility(showFooter); mLoadingViewPending = false; mHandler.removeCallbacks(mLoadingViewRunnable); // Even though cursor might be empty, the list adapter might have teasers/footers. // So we check the list adapter count if the cursor is fully/partially loaded. if (mAccount.securityHold != 0) { showSecurityHoldView(); } else if (mListAdapter.getCount() == 0) { showEmptyView(); } else { showListView(); } } private void setSwipeAction() { int swipeSetting = Settings.getSwipeSetting(mAccount.settings); if (swipeSetting == Swipe.DISABLED || !mAccount.supportsCapability(AccountCapabilities.UNDO) || (mFolder != null && mFolder.isTrash())) { mListView.enableSwipe(false); } else { final int action; mListView.enableSwipe(true); if (mFolder == null) { action = R.id.remove_folder; } else { switch (swipeSetting) { // Try to respect user's setting as best as we can and default to doing nothing case Swipe.DELETE: // Delete in Outbox means discard failed message and put it in draft if (mFolder.isType(UIProvider.FolderType.OUTBOX)) { action = R.id.discard_outbox; } else { action = R.id.delete; } break; case Swipe.ARCHIVE: // Special case spam since it shouldn't remove spam folder label on swipe if (mAccount.supportsCapability(AccountCapabilities.ARCHIVE) && !mFolder.isSpam()) { if (mFolder.supportsCapability(FolderCapabilities.ARCHIVE)) { action = R.id.archive; break; } else if (mFolder.supportsCapability (FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)) { action = R.id.remove_folder; break; } } /* * If we get here, we don't support archive, on either the account or the * folder, so we want to fall through to swipe doing nothing */ //$FALL-THROUGH$ default: mListView.enableSwipe(false); action = 0; // Use default value so setSwipeAction essentially has no effect break; } } mListView.setSwipeAction(action); } mListView.setCurrentAccount(mAccount); mListView.setCurrentFolder(mFolder); } /** * Changes the conversation cursor in the list and sets checked position if none is set. */ private void onCursorUpdated() { if (mCallbacks == null || mListAdapter == null) { return; } // Check against the previous cursor here and see if they are the same. If they are, then // do a notifyDataSetChanged. final ConversationCursor newCursor = mCallbacks.getConversationListCursor(); if (newCursor == null && mListAdapter.getCursor() != null) { // We're losing our cursor, so save our scroll position saveLastScrolledPosition(); } mListAdapter.swapCursor(newCursor); // When the conversation cursor is *updated*, we get back the same instance. In that // situation, CursorAdapter.swapCursor() silently returns, without forcing a // notifyDataSetChanged(). So let's force a call to notifyDataSetChanged, since an updated // cursor means that the dataset has changed. final int newCursorHash = (newCursor == null) ? 0 : newCursor.hashCode(); if (mConversationCursorHash == newCursorHash && mConversationCursorHash != 0) { mListAdapter.notifyDataSetChanged(); } mConversationCursorHash = newCursorHash; updateAnalyticsData(newCursor); if (newCursor != null) { final int newCursorCount = newCursor.getCount(); updateSearchResultHeader(newCursorCount); if (newCursorCount > 0) { newCursor.markContentsSeen(); restoreLastScrolledPosition(); } } // If a current conversation is available, and none is activated in the list, then ask // the list to select the current conversation. final Conversation conv = mCallbacks.getCurrentConversation(); final boolean currentConvIsPeeking = mCallbacks.isCurrentConversationJustPeeking(); if (conv != null && !currentConvIsPeeking) { if (mListView.getChoiceMode() != ListView.CHOICE_MODE_NONE && mListView.getCheckedItemPosition() == -1) { setActivated(conv, true); } } } public void commitDestructiveActions(boolean animate) { if (mListView != null) { mListView.commitDestructiveActions(animate); } } @Override public void onListItemSwiped(Collection conversations) { mUpdater.showNextConversation(conversations); } private void checkSyncStatus() { if (mFolder != null && mFolder.isSyncInProgress()) { LogUtils.d(LOG_TAG, "CLF.checkSyncStatus still syncing"); // Still syncing, ignore } else { // Finished syncing: LogUtils.d(LOG_TAG, "CLF.checkSyncStatus done syncing"); mSwipeRefreshWidget.setRefreshing(false); } } /** * Displays the indefinite progress bar indicating a sync is in progress. This * should only be called if user manually requested a sync, and not for background syncs. */ protected void showSyncStatusBar() { mSwipeRefreshWidget.setRefreshing(true); } /** * Clears all items in the list. */ public void clear() { mListView.setAdapter(null); } private final ConversationSetObserver mConversationSetObserver = new ConversationSetObserver() { @Override public void onSetPopulated(final ConversationCheckedSet set) { // Disable the swipe to refresh widget. mSwipeRefreshWidget.setEnabled(false); } @Override public void onSetEmpty() { mSwipeRefreshWidget.setEnabled(true); } @Override public void onSetChanged(final ConversationCheckedSet set) { // Do nothing } }; private void saveLastScrolledPosition() { if (mFolder == null || mFolder.conversationListUri == null || mListAdapter.getCursor() == null) { // If you save your scroll position in an empty list, you're gonna have a bad time return; } final Parcelable savedState = mListView.onSaveInstanceState(); mActivity.getListHandler().setConversationListScrollPosition( mFolder.conversationListUri.toString(), savedState); } private void restoreLastScrolledPosition() { // Scroll to our previous position, if necessary if (!mScrollPositionRestored && mFolder != null) { final String key = mFolder.conversationListUri.toString(); final Parcelable savedState = mActivity.getListHandler() .getConversationListScrollPosition(key); if (savedState != null) { mListView.onRestoreInstanceState(savedState); } mScrollPositionRestored = true; } } /* (non-Javadoc) * @see android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener#onRefresh() */ @Override public void onRefresh() { Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "swipe_refresh", null, 0); // This will call back to showSyncStatusBar(): mActivity.getFolderController().requestFolderRefresh(); // Clear list adapter state out of an abundance of caution. // There is a class of bugs where an animation that should have finished doesn't (maybe // it didn't start, or it didn't finish), and the list gets stuck pretty much forever. // Clearing the state here is in line with user expectation for 'refresh'. getAnimatedAdapter().clearAnimationState(); // possibly act on the now-cleared state mActivity.onAnimationEnd(mListAdapter); } /** * Extracted function that handles Analytics state and logging updates for each new cursor * @param newCursor the new cursor pointer */ private void updateAnalyticsData(ConversationCursor newCursor) { if (newCursor != null) { // Check if the initial data returned yet if (mInitialCursorLoading) { // This marks the very first time the cursor with the data the user sees returned. // We either have a cursor in LOADING state with cursor's count > 0, OR the cursor // completed loading. // Use this point to log the appropriate timing information that depends on when // the conversation list view finishes loading if (isCursorReadyToShow()) { if (newCursor.getCount() == 0) { Analytics.getInstance().sendEvent("empty_state", "post_label_change", mFolder.getTypeDescription(), 0); } AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.COLD_START_LAUNCHER, true /* isDestructive */, "cold_start_to_list", "from_launcher", null); // Don't need null checks because the activity, controller, and folder cannot // be null in this case if (mActivity.getFolderController().getFolder().isSearch()) { AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.SEARCH_TO_LIST, true /* isDestructive */, "search_to_list", null, null); } mInitialCursorLoading = false; } } else { // Log the appropriate events that happen after the initial cursor is loaded if (newCursor.getCount() == 0 && mConversationCursorLastCount > 0) { Analytics.getInstance().sendEvent("empty_state", "post_delete", mFolder.getTypeDescription(), 0); } } // We save the count here because for folders that are empty, multiple successful // cursor loads will occur with size of 0. Thus we don't want to emit any false // positive post_delete events. mConversationCursorLastCount = newCursor.getCount(); } else { mConversationCursorLastCount = 0; } } /** * Helper function to determine if the current cursor is ready to populate the UI * Since we extracted the functionality into a static function in ConversationCursor, * this function remains for the sole purpose of readability. * @return */ private boolean isCursorReadyToShow() { return ConversationCursor.isCursorReadyToShow(getConversationListCursor()); } public SwipeableListView getListView() { return mListView; } public void setNextFocusStartId(@IdRes int id) { mNextFocusStartId = id; setNextFocusStartOnList(); } private void setNextFocusStartOnList() { if (mListView != null && mNextFocusStartId != 0) { // Since we manually handle right navigation from the list, let's just always set both // the default left and right navigation to the left id so that whenever the framework // handles one of these directions, it will go to the left side regardless of RTL. mListView.setNextFocusLeftId(mNextFocusStartId); mListView.setNextFocusRightId(mNextFocusStartId); } } public void onClick(View view) { if (view == mSecurityHoldButton) { final String accountSecurityUri = mAccount.accountSecurityUri; Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(accountSecurityUri)); startActivity(intent); } } @Override public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { mListView.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount); } /** * Used with SwipeableListView to change conv_list backgrounds to work around shadow elevation * issues causing and overdraw problems due to static backgrounds. * * @param view * @param scrollState */ @Override public void onScrollStateChanged(final AbsListView view, final int scrollState) { mListView.onScrollStateChanged(view, scrollState); final View rootView = getView(); // It seems that the list view is reading the scroll state, but the onCreateView has not // yet finished and the root view is null, so check that if (rootView != null) { // If not scrolling, assign default background - white for tablet, transparent for phone if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { rootView.setBackgroundColor(mDefaultListBackgroundColor); // Otherwise, list is scrolling, so remove background (corresponds to 0 input) } else { rootView.setBackgroundResource(0); } } } }