blob: 0844cb22546ba5b69f7e737523324629d8dccf08 [file] [log] [blame]
/*
* 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 androidx.annotation.IdRes;
import androidx.swiperefreshlayout.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 <code>true</code>, 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<ConversationSpecialItemView> 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 <em>identical</em> behavior) The matrix of
* possibilities is:
* <p>
* Long tap: Always toggle selection of conversation. If CAB mode is not
* started, then start it.
* <pre>
* | Checkboxes | No Checkboxes
* ----------+------------+---------------
* CAB mode | Select | Select
* List mode | Select | Select
*
* </pre>
*
* Reference: http://b/issue?id=6392199
* <p>
* {@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)}.
* <p>
* Short tap behavior:
*
* <pre>
* | Checkboxes | No Checkboxes
* ----------+------------+---------------
* CAB mode | Peek | Select
* List mode | Peek | Peek
* </pre>
*
* Reference: http://b/issue?id=6392199
* <p>
* {@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 <b>after</b> the UI has been
* updated.
* @param conversations
* @param action
*/
public void requestDelete(int actionId, final Collection<Conversation> 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<Conversation> 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 androidx.swiperefreshlayout.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);
}
}
}
}