/* * Copyright (C) 2010 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.email.activity; import android.app.Activity; import android.app.ListFragment; import android.app.LoaderManager; import android.content.ClipData; import android.content.ContentUris; import android.content.Context; import android.content.Loader; import android.content.res.Configuration; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Canvas; import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.Parcelable; import android.text.TextPaint; import android.util.Log; import android.view.ActionMode; import android.view.DragEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.View.DragShadowBuilder; import android.view.View.OnDragListener; import android.view.View.OnTouchListener; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import com.android.email.Controller; import com.android.email.Email; import com.android.email.MessageListContext; import com.android.email.NotificationController; import com.android.email.R; import com.android.email.RefreshManager; import com.android.email.activity.MessagesAdapter.SearchResultsCursor; import com.android.email.provider.EmailProvider; import com.android.emailcommon.Logging; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.utility.EmailAsyncTask; import com.android.emailcommon.utility.Utility; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Maps; import java.util.HashMap; import java.util.Set; /** * Message list. * * See the class javadoc for {@link MailboxListFragment} for notes on {@link #getListView()} and * {@link #isViewCreated()}. */ public class MessageListFragment extends ListFragment implements OnItemLongClickListener, MessagesAdapter.Callback, MoveMessageToDialog.Callback, OnDragListener, OnTouchListener { private static final String BUNDLE_LIST_STATE = "MessageListFragment.state.listState"; private static final String BUNDLE_KEY_SELECTED_MESSAGE_ID = "messageListFragment.state.listState.selected_message_id"; private static final int LOADER_ID_MESSAGES_LOADER = 1; /** Argument name(s) */ private static final String ARG_LIST_CONTEXT = "listContext"; // Controller access private Controller mController; private RefreshManager mRefreshManager; private final RefreshListener mRefreshListener = new RefreshListener(); // UI Support private Activity mActivity; private Callback mCallback = EmptyCallback.INSTANCE; private boolean mIsViewCreated; private View mListPanel; private View mListFooterView; private TextView mListFooterText; private View mListFooterProgress; private ViewGroup mSearchHeader; private ViewGroup mWarningContainer; private TextView mSearchHeaderText; private TextView mSearchHeaderCount; private static final int LIST_FOOTER_MODE_NONE = 0; private static final int LIST_FOOTER_MODE_MORE = 1; private int mListFooterMode; private MessagesAdapter mListAdapter; private boolean mIsFirstLoad; /** ID of the message to hightlight. */ private long mSelectedMessageId = -1; private Account mAccount; private Mailbox mMailbox; /** The original mailbox being searched, if this list is showing search results. */ private Mailbox mSearchedMailbox; private boolean mIsEasAccount; private boolean mIsRefreshable; private int mCountTotalAccounts; // Misc members private boolean mShowSendCommand; private boolean mShowMoveCommand; /** * If true, we disable the CAB even if there are selected messages. * It's used in portrait on the tablet when the message view becomes visible and the message * list gets pushed out of the screen, in which case we want to keep the selection but the CAB * should be gone. */ private boolean mDisableCab; /** true between {@link #onResume} and {@link #onPause}. */ private boolean mResumed; /** * {@link ActionMode} shown when 1 or more message is selected. */ private ActionMode mSelectionMode; private SelectionModeCallback mLastSelectionModeCallback; private Parcelable mSavedListState; private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker(); /** * Callback interface that owning activities must implement */ public interface Callback { public static final int TYPE_REGULAR = 0; public static final int TYPE_DRAFT = 1; public static final int TYPE_TRASH = 2; /** * Called when the specified mailbox does not exist. */ public void onMailboxNotFound(boolean firstLoad); /** * Called when the user wants to open a message. * Note {@code mailboxId} is of the actual mailbox of the message, which is different from * {@link MessageListFragment#getMailboxId} if it's magic mailboxes. * * @param messageId the message ID of the message * @param messageMailboxId the mailbox ID of the message. * This will never take values like {@link Mailbox#QUERY_ALL_INBOXES}. * @param listMailboxId the mailbox ID of the listbox shown on this fragment. * This can be that of a magic mailbox, e.g. {@link Mailbox#QUERY_ALL_INBOXES}. * @param type {@link #TYPE_REGULAR}, {@link #TYPE_DRAFT} or {@link #TYPE_TRASH}. */ public void onMessageOpen(long messageId, long messageMailboxId, long listMailboxId, int type); /** * Called when an operation is initiated that can potentially advance the current * message selection (e.g. a delete operation may advance the selection). * @param affectedMessages the messages the operation will apply to */ public void onAdvancingOpAccepted(Set<Long> affectedMessages); /** * Called when a drag & drop is initiated. * * @return true if drag & drop is allowed */ public boolean onDragStarted(); /** * Called when a drag & drop is ended. */ public void onDragEnded(); } private static final class EmptyCallback implements Callback { public static final Callback INSTANCE = new EmptyCallback(); @Override public void onMailboxNotFound(boolean isFirstLoad) { } @Override public void onMessageOpen( long messageId, long messageMailboxId, long listMailboxId, int type) { } @Override public void onAdvancingOpAccepted(Set<Long> affectedMessages) { } @Override public boolean onDragStarted() { return false; // We don't know -- err on the safe side. } @Override public void onDragEnded() { } } /** * Create a new instance with initialization parameters. * * This fragment should be created only with this method. (Arguments should always be set.) * * @param listContext The list context to show messages for */ public static MessageListFragment newInstance(MessageListContext listContext) { final MessageListFragment instance = new MessageListFragment(); final Bundle args = new Bundle(); args.putParcelable(ARG_LIST_CONTEXT, listContext); instance.setArguments(args); return instance; } /** * The context describing the contents to be shown in the list. * Do not use directly; instead, use the getters such as {@link #getAccountId()}. * <p><em>NOTE:</em> Although we cannot force these to be immutable using Java language * constructs, this <em>must</em> be considered immutable. */ private MessageListContext mListContext; private void initializeArgCache() { if (mListContext != null) return; mListContext = getArguments().getParcelable(ARG_LIST_CONTEXT); } /** * @return the account ID passed to {@link #newInstance}. Safe to call even before onCreate. * * NOTE it may return {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */ public long getAccountId() { initializeArgCache(); return mListContext.mAccountId; } /** * @return the mailbox ID passed to {@link #newInstance}. Safe to call even before onCreate. */ public long getMailboxId() { initializeArgCache(); return mListContext.getMailboxId(); } /** * @return true if the mailbox is a combined mailbox. Safe to call even before onCreate. */ public boolean isCombinedMailbox() { return getAccountId() == Account.ACCOUNT_ID_COMBINED_VIEW; } public MessageListContext getListContext() { initializeArgCache(); return mListContext; } /** * @return Whether or not initial data is loaded in this list. */ public boolean hasDataLoaded() { return mCountTotalAccounts > 0; } /** * @return The account object, when known. Null if not yet known. */ public Account getAccount() { return mAccount; } /** * @return The mailbox where the messages belong in, when known. Null if not yet known. */ public Mailbox getMailbox() { return mMailbox; } /** * @return Whether or not this message list is showing a user's inbox. * Note that combined inbox view is treated as an inbox view. */ public boolean isInboxList() { MessageListContext listContext = getListContext(); long accountId = listContext.mAccountId; if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { return listContext.getMailboxId() == Mailbox.QUERY_ALL_INBOXES; } if (!hasDataLoaded()) { // If the data hasn't finished loading, we don't have the full mailbox - infer from ID. long inboxId = Mailbox.findMailboxOfType(mActivity, accountId, Mailbox.TYPE_INBOX); return listContext.getMailboxId() == inboxId; } return (mMailbox != null) && (mMailbox.mType == Mailbox.TYPE_INBOX); } /** * @return The mailbox being searched, when known. Null if not yet known or if not a search * result. */ public Mailbox getSearchedMailbox() { return mSearchedMailbox; } @Override public void onAttach(Activity activity) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onAttach"); } super.onAttach(activity); } @Override public void onCreate(Bundle savedInstanceState) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onCreate"); } super.onCreate(savedInstanceState); mActivity = getActivity(); setHasOptionsMenu(true); mController = Controller.getInstance(mActivity); mRefreshManager = RefreshManager.getInstance(mActivity); mListAdapter = new MessagesAdapter(mActivity, this); mIsFirstLoad = true; } @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onCreateView"); } // Use a custom layout, which includes the original layout with "send messages" panel. View root = inflater.inflate(R.layout.message_list_fragment,null); mIsViewCreated = true; mListPanel = UiUtilities.getView(root, R.id.list_panel); return root; } public void setLayout(ThreePaneLayout layout) { if (UiUtilities.useTwoPane(mActivity)) { mListAdapter.setLayout(layout); } } private void initSearchHeader() { if (mSearchHeader == null) { ViewGroup root = (ViewGroup) getView(); mSearchHeader = (ViewGroup) LayoutInflater.from(mActivity).inflate( R.layout.message_list_search_header, root, false); mSearchHeaderText = UiUtilities.getView(mSearchHeader, R.id.search_header_text); mSearchHeaderCount = UiUtilities.getView(mSearchHeader, R.id.search_count); // Add above the actual list. root.addView(mSearchHeader, 0); } } /** * @return true if the content view is created and not destroyed yet. (i.e. between * {@link #onCreateView} and {@link #onDestroyView}. */ private boolean isViewCreated() { // Note that we don't use "getView() != null". This method is used in updateSelectionMode() // to determine if CAB shold be shown. But because it's called from onDestroyView(), at // this point the fragment still has views but we want to hide CAB, we can't use // getView() here. return mIsViewCreated; } @Override public void onActivityCreated(Bundle savedInstanceState) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onActivityCreated"); } super.onActivityCreated(savedInstanceState); final ListView lv = getListView(); lv.setOnItemLongClickListener(this); lv.setOnTouchListener(this); lv.setItemsCanFocus(false); lv.setChoiceMode(ListView.CHOICE_MODE_SINGLE); mListFooterView = getActivity().getLayoutInflater().inflate( R.layout.message_list_item_footer, lv, false); setEmptyText(getString(R.string.message_list_no_messages)); if (savedInstanceState != null) { // Fragment doesn't have this method. Call it manually. restoreInstanceState(savedInstanceState); } startLoading(); UiUtilities.installFragment(this); } @Override public void onStart() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onStart"); } super.onStart(); } @Override public void onResume() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onResume"); } super.onResume(); adjustMessageNotification(false); mRefreshManager.registerListener(mRefreshListener); mResumed = true; } @Override public void onPause() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onPause"); } mResumed = false; mSavedListState = getListView().onSaveInstanceState(); adjustMessageNotification(true); super.onPause(); } @Override public void onStop() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onStop"); } mTaskTracker.cancellAllInterrupt(); mRefreshManager.unregisterListener(mRefreshListener); super.onStop(); } @Override public void onDestroyView() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onDestroyView"); } mIsViewCreated = false; // Clear this first for updateSelectionMode(). See isViewCreated(). UiUtilities.uninstallFragment(this); updateSelectionMode(); // Reset the footer mode since we just blew away the footer view we were holding on to. // This will get re-updated when/if this fragment is restored. mListFooterMode = LIST_FOOTER_MODE_NONE; super.onDestroyView(); } @Override public void onDestroy() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onDestroy"); } finishSelectionMode(); super.onDestroy(); } @Override public void onDetach() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onDetach"); } super.onDetach(); } @Override public void onSaveInstanceState(Bundle outState) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " onSaveInstanceState"); } super.onSaveInstanceState(outState); mListAdapter.onSaveInstanceState(outState); if (isViewCreated()) { outState.putParcelable(BUNDLE_LIST_STATE, getListView().onSaveInstanceState()); } outState.putLong(BUNDLE_KEY_SELECTED_MESSAGE_ID, mSelectedMessageId); } @VisibleForTesting void restoreInstanceState(Bundle savedInstanceState) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " restoreInstanceState"); } mListAdapter.loadState(savedInstanceState); mSavedListState = savedInstanceState.getParcelable(BUNDLE_LIST_STATE); mSelectedMessageId = savedInstanceState.getLong(BUNDLE_KEY_SELECTED_MESSAGE_ID); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.message_list_fragment_option, menu); } @Override public void onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.send).setVisible(mShowSendCommand); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.send: onSendPendingMessages(); return true; } return false; } public void setCallback(Callback callback) { mCallback = (callback != null) ? callback : EmptyCallback.INSTANCE; } /** * This method must be called when the fragment is hidden/shown. */ public void onHidden(boolean hidden) { // When hidden, we need to disable CAB. if (hidden == mDisableCab) { return; } mDisableCab = hidden; updateSelectionMode(); } public void setSelectedMessage(long messageId) { if (mSelectedMessageId == messageId) { return; } mSelectedMessageId = messageId; if (mResumed) { highlightSelectedMessage(true); } } /** * @return true if the mailbox is refreshable. false otherwise, or unknown yet. */ public boolean isRefreshable() { return mIsRefreshable; } /** * @return the number of messages that are currently selected. */ private int getSelectedCount() { return mListAdapter.getSelectedSet().size(); } /** * @return true if the list is in the "selection" mode. */ public boolean isInSelectionMode() { return mSelectionMode != null; } /** * Called when a message is clicked. */ @Override public void onListItemClick(ListView parent, View view, int position, long id) { if (view != mListFooterView) { MessageListItem itemView = (MessageListItem) view; onMessageOpen(itemView.mMailboxId, id); } else { doFooterClick(); } } // This is tentative drag & drop UI private static class ShadowBuilder extends DragShadowBuilder { private static Drawable sBackground; /** Paint information for the move message text */ private static TextPaint sMessagePaint; /** Paint information for the message count */ private static TextPaint sCountPaint; /** The x location of any touch event; used to ensure the drag overlay is drawn correctly */ private static int sTouchX; /** Width of the draggable view */ private final int mDragWidth; /** Height of the draggable view */ private final int mDragHeight; private final String mMessageText; private final PointF mMessagePoint; private final String mCountText; private final PointF mCountPoint; private int mOldOrientation = Configuration.ORIENTATION_UNDEFINED; /** Margin applied to the right of count text */ private static float sCountMargin; /** Margin applied to left of the message text */ private static float sMessageMargin; /** Vertical offset of the drag view */ private static int sDragOffset; public ShadowBuilder(View view, int count) { super(view); Resources res = view.getResources(); int newOrientation = res.getConfiguration().orientation; mDragHeight = view.getHeight(); mDragWidth = view.getWidth(); // TODO: Can we define a layout for the contents of the drag area? if (sBackground == null || mOldOrientation != newOrientation) { mOldOrientation = newOrientation; sBackground = res.getDrawable(R.drawable.list_pressed_holo); sBackground.setBounds(0, 0, mDragWidth, mDragHeight); sDragOffset = (int)res.getDimension(R.dimen.message_list_drag_offset); sMessagePaint = new TextPaint(); float messageTextSize; messageTextSize = res.getDimension(R.dimen.message_list_drag_message_font_size); sMessagePaint.setTextSize(messageTextSize); sMessagePaint.setTypeface(Typeface.DEFAULT_BOLD); sMessagePaint.setAntiAlias(true); sMessageMargin = res.getDimension(R.dimen.message_list_drag_message_right_margin); sCountPaint = new TextPaint(); float countTextSize; countTextSize = res.getDimension(R.dimen.message_list_drag_count_font_size); sCountPaint.setTextSize(countTextSize); sCountPaint.setTypeface(Typeface.DEFAULT_BOLD); sCountPaint.setAntiAlias(true); sCountMargin = res.getDimension(R.dimen.message_list_drag_count_left_margin); } // Calculate layout positions Rect b = new Rect(); mMessageText = res.getQuantityString(R.plurals.move_messages, count, count); sMessagePaint.getTextBounds(mMessageText, 0, mMessageText.length(), b); mMessagePoint = new PointF(mDragWidth - b.right - sMessageMargin, (mDragHeight - b.top)/ 2); mCountText = Integer.toString(count); sCountPaint.getTextBounds(mCountText, 0, mCountText.length(), b); mCountPoint = new PointF(sCountMargin, (mDragHeight - b.top) / 2); } @Override public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { shadowSize.set(mDragWidth, mDragHeight); shadowTouchPoint.set(sTouchX, (mDragHeight / 2) + sDragOffset); } @Override public void onDrawShadow(Canvas canvas) { super.onDrawShadow(canvas); sBackground.draw(canvas); canvas.drawText(mMessageText, mMessagePoint.x, mMessagePoint.y, sMessagePaint); canvas.drawText(mCountText, mCountPoint.x, mCountPoint.y, sCountPaint); } } @Override public boolean onDrag(View view, DragEvent event) { switch(event.getAction()) { case DragEvent.ACTION_DRAG_ENDED: if (event.getResult()) { onDeselectAll(); // Clear the selection } mCallback.onDragEnded(); break; } return false; } @Override public boolean onTouch(View v, MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { // Save the touch location to draw the drag overlay at the correct location ShadowBuilder.sTouchX = (int)event.getX(); } // don't do anything, let the system process the event return false; } @Override public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { if (view != mListFooterView) { // Always toggle the item. MessageListItem listItem = (MessageListItem) view; boolean toggled = false; if (!mListAdapter.isSelected(listItem)) { toggleSelection(listItem); toggled = true; } // Additionally, check to see if we can drag the item. if (!mCallback.onDragStarted()) { return toggled; // D&D not allowed. } // We can't move from combined accounts view // We also need to check the actual mailbox to see if we can move items from it final long mailboxId = getMailboxId(); if (mAccount == null || mMailbox == null) { return false; } else if (mailboxId > 0 && !mMailbox.canHaveMessagesMoved()) { return false; } // Start drag&drop. // Create ClipData with the Uri of the message we're long clicking ClipData data = ClipData.newUri(mActivity.getContentResolver(), MessageListItem.MESSAGE_LIST_ITEMS_CLIP_LABEL, Message.CONTENT_URI.buildUpon() .appendPath(Long.toString(listItem.mMessageId)) .appendQueryParameter( EmailProvider.MESSAGE_URI_PARAMETER_MAILBOX_ID, Long.toString(mailboxId)) .build()); Set<Long> selectedMessageIds = mListAdapter.getSelectedSet(); int size = selectedMessageIds.size(); // Add additional Uri's for any other selected messages for (Long messageId: selectedMessageIds) { if (messageId.longValue() != listItem.mMessageId) { data.addItem(new ClipData.Item( ContentUris.withAppendedId(Message.CONTENT_URI, messageId))); } } // Start dragging now listItem.setOnDragListener(this); listItem.startDrag(data, new ShadowBuilder(listItem, size), null, 0); return true; } return false; } private void toggleSelection(MessageListItem itemView) { itemView.invalidate(); mListAdapter.toggleSelected(itemView); } /** * Called when a message on the list is selected * * @param messageMailboxId the actual mailbox ID of the message. Note it's different than * what is returned by {@link #getMailboxId()} for combined mailboxes. * ({@link #getMailboxId()} may return special mailbox values such as * {@link Mailbox#QUERY_ALL_INBOXES}) * @param messageId ID of the message to open. */ private void onMessageOpen(final long messageMailboxId, final long messageId) { if ((mMailbox != null) && (mMailbox.mId == messageMailboxId)) { // Normal case - the message belongs in the mailbox list we're viewing. mCallback.onMessageOpen(messageId, messageMailboxId, getMailboxId(), callbackTypeForMailboxType(mMailbox.mType)); return; } // Weird case - a virtual mailbox where the messages could come from different mailbox // types - here we have to query the DB for the type. new MessageOpenTask(messageMailboxId, messageId).cancelPreviousAndExecuteParallel(); } private int callbackTypeForMailboxType(int mailboxType) { switch (mailboxType) { case Mailbox.TYPE_DRAFTS: return Callback.TYPE_DRAFT; case Mailbox.TYPE_TRASH: return Callback.TYPE_TRASH; default: return Callback.TYPE_REGULAR; } } /** * Task to look up the mailbox type for a message, and kicks the callback. */ private class MessageOpenTask extends EmailAsyncTask<Void, Void, Integer> { private final long mMessageMailboxId; private final long mMessageId; public MessageOpenTask(long messageMailboxId, long messageId) { super(mTaskTracker); mMessageMailboxId = messageMailboxId; mMessageId = messageId; } @Override protected Integer doInBackground(Void... params) { // Restore the mailbox type. Note we can't use mMailbox.mType here, because // we don't have mMailbox for combined mailbox. // ("All Starred" can contain any kind of messages.) return callbackTypeForMailboxType( Mailbox.getMailboxType(mActivity, mMessageMailboxId)); } @Override protected void onSuccess(Integer type) { if (type == null) { return; } mCallback.onMessageOpen(mMessageId, mMessageMailboxId, getMailboxId(), type); } } private void showMoveMessagesDialog(Set<Long> selectedSet) { long[] messageIds = Utility.toPrimitiveLongArray(selectedSet); MoveMessageToDialog dialog = MoveMessageToDialog.newInstance(messageIds, this); dialog.show(getFragmentManager(), "dialog"); } @Override public void onMoveToMailboxSelected(long newMailboxId, long[] messageIds) { final Context context = getActivity(); if (context == null) { // Detached from activity. This callback was really delayed or a monkey was involved. return; } mCallback.onAdvancingOpAccepted(Utility.toLongSet(messageIds)); ActivityHelper.moveMessages(context, newMailboxId, messageIds); // Move is async, so we can't refresh now. Instead, just clear the selection. onDeselectAll(); } /** * Refresh the list. NOOP for special mailboxes (e.g. combined inbox). * * Note: Manual refresh is enabled even for push accounts. */ public void onRefresh(boolean userRequest) { if (mIsRefreshable) { mRefreshManager.refreshMessageList(getAccountId(), getMailboxId(), userRequest); } } private void onDeselectAll() { mListAdapter.clearSelection(); if (isInSelectionMode()) { finishSelectionMode(); } } /** * Load more messages. NOOP for special mailboxes (e.g. combined inbox). */ private void onLoadMoreMessages() { if (mIsRefreshable) { mRefreshManager.loadMoreMessages(getAccountId(), getMailboxId()); } } public void onSendPendingMessages() { RefreshManager rm = RefreshManager.getInstance(mActivity); if (getMailboxId() == Mailbox.QUERY_ALL_OUTBOX) { rm.sendPendingMessagesForAllAccounts(); } else if (mMailbox != null) { // Magic boxes don't have a specific account id. rm.sendPendingMessages(mMailbox.mAccountKey); } } /** * Toggles a set read/unread states. Note, the default behavior is "mark unread", so the * sense of the helper methods is "true=unread"; this may be called from the UI thread * * @param selectedSet The current list of selected items */ private void toggleRead(Set<Long> selectedSet) { toggleMultiple(selectedSet, new MultiToggleHelper() { @Override public boolean getField(Cursor c) { return c.getInt(MessagesAdapter.COLUMN_READ) == 0; } @Override public void setField(long messageId, boolean newValue) { mController.setMessageReadSync(messageId, !newValue); } }); } /** * Toggles a set of favorites (stars); this may be called from the UI thread * * @param selectedSet The current list of selected items */ private void toggleFavorite(Set<Long> selectedSet) { toggleMultiple(selectedSet, new MultiToggleHelper() { @Override public boolean getField(Cursor c) { return c.getInt(MessagesAdapter.COLUMN_FAVORITE) != 0; } @Override public void setField(long messageId, boolean newValue) { mController.setMessageFavoriteSync(messageId, newValue); } }); } private void deleteMessages(Set<Long> selectedSet) { final long[] messageIds = Utility.toPrimitiveLongArray(selectedSet); mController.deleteMessages(messageIds); Toast.makeText(mActivity, mActivity.getResources().getQuantityString( R.plurals.message_deleted_toast, messageIds.length), Toast.LENGTH_SHORT).show(); selectedSet.clear(); // Message deletion is async... Can't refresh the list immediately. } private interface MultiToggleHelper { /** * Return true if the field of interest is "set". If one or more are false, then our * bulk action will be to "set". If all are set, our bulk action will be to "clear". * @param c the cursor, positioned to the item of interest * @return true if the field at this row is "set" */ public boolean getField(Cursor c); /** * Set or clear the field of interest; setField is called asynchronously via EmailAsyncTask * @param messageId the message id of the current message * @param newValue the new value to be set at this row */ public void setField(long messageId, boolean newValue); } /** * Toggle multiple fields in a message, using the following logic: If one or more fields * are "clear", then "set" them. If all fields are "set", then "clear" them all. Provider * calls are applied asynchronously in setField * * @param selectedSet the set of messages that are selected * @param helper functions to implement the specific getter & setter */ private void toggleMultiple(final Set<Long> selectedSet, final MultiToggleHelper helper) { final Cursor c = mListAdapter.getCursor(); if (c == null || c.isClosed()) { return; } final HashMap<Long, Boolean> setValues = Maps.newHashMap(); boolean allWereSet = true; c.moveToPosition(-1); while (c.moveToNext()) { long id = c.getInt(MessagesAdapter.COLUMN_ID); if (selectedSet.contains(id)) { boolean value = helper.getField(c); setValues.put(id, value); allWereSet = allWereSet && value; } } if (!setValues.isEmpty()) { final boolean newValue = !allWereSet; c.moveToPosition(-1); // TODO: we should probably put up a dialog or some other progress indicator for this. EmailAsyncTask.runAsyncParallel(new Runnable() { @Override public void run() { for (long id : setValues.keySet()) { if (setValues.get(id) != newValue) { helper.setField(id, newValue); } } }}); } } /** * Test selected messages for showing appropriate labels * @param selectedSet * @param columnId * @param defaultflag * @return true when the specified flagged message is selected */ private boolean testMultiple(Set<Long> selectedSet, int columnId, boolean defaultflag) { final Cursor c = mListAdapter.getCursor(); if (c == null || c.isClosed()) { return false; } c.moveToPosition(-1); while (c.moveToNext()) { long id = c.getInt(MessagesAdapter.COLUMN_ID); if (selectedSet.contains(Long.valueOf(id))) { if (c.getInt(columnId) == (defaultflag ? 1 : 0)) { return true; } } } return false; } /** * @return true if one or more non-starred messages are selected. */ public boolean doesSelectionContainNonStarredMessage() { return testMultiple(mListAdapter.getSelectedSet(), MessagesAdapter.COLUMN_FAVORITE, false); } /** * @return true if one or more read messages are selected. */ public boolean doesSelectionContainReadMessage() { return testMultiple(mListAdapter.getSelectedSet(), MessagesAdapter.COLUMN_READ, true); } /** * Implements a timed refresh of "stale" mailboxes. This should only happen when * multiple conditions are true, including: * Only refreshable mailboxes. * Only when the mailbox is "stale" (currently set to 5 minutes since last refresh) * Note we do this even if it's a push account; even on Exchange only inbox can be pushed. */ private void autoRefreshStaleMailbox() { if (!mIsRefreshable) { // Not refreshable (special box such as drafts, or magic boxes) return; } if (!mRefreshManager.isMailboxStale(getMailboxId())) { return; } onRefresh(false); } /** Implements {@link MessagesAdapter.Callback} */ @Override public void onAdapterFavoriteChanged(MessageListItem itemView, boolean newFavorite) { mController.setMessageFavorite(itemView.mMessageId, newFavorite); } /** Implements {@link MessagesAdapter.Callback} */ @Override public void onAdapterSelectedChanged(MessageListItem itemView, boolean newSelected, int mSelectedCount) { updateSelectionMode(); } private void updateSearchHeader(Cursor cursor) { MessageListContext listContext = getListContext(); if (!listContext.isSearch() || cursor == null) { UiUtilities.setVisibilitySafe(mSearchHeader, View.GONE); return; } SearchResultsCursor searchCursor = (SearchResultsCursor) cursor; initSearchHeader(); mSearchHeader.setVisibility(View.VISIBLE); String header = String.format( mActivity.getString(R.string.search_header_text_fmt), listContext.getSearchParams().mFilter); mSearchHeaderText.setText(header); int resultCount = searchCursor.getResultsCount(); // Don't show a negative value here; this means that the server request failed // TODO Use some other text for this case (e.g. "search failed")? if (resultCount < 0) { resultCount = 0; } mSearchHeaderCount.setText(UiUtilities.getMessageCountForUi( mActivity, resultCount, false /* replaceZeroWithBlank */)); } private int determineFooterMode() { int result = LIST_FOOTER_MODE_NONE; if ((mMailbox == null) || (mMailbox.mType == Mailbox.TYPE_OUTBOX) || (mMailbox.mType == Mailbox.TYPE_DRAFTS)) { return result; // No footer } if (mMailbox.mType == Mailbox.TYPE_SEARCH) { // Determine how many results have been loaded. Cursor c = mListAdapter.getCursor(); if (c == null || c.isClosed()) { // Unknown yet - don't do anything. return result; } int total = ((SearchResultsCursor) c).getResultsCount(); int loaded = c.getCount(); if (loaded < total) { result = LIST_FOOTER_MODE_MORE; } } else if (!mIsEasAccount) { // IMAP, POP has "load more" for regular mailboxes. result = LIST_FOOTER_MODE_MORE; } return result; } private void updateFooterView() { // Only called from onLoadFinished -- always has views. int mode = determineFooterMode(); if (mListFooterMode == mode) { return; } mListFooterMode = mode; ListView lv = getListView(); if (mListFooterMode != LIST_FOOTER_MODE_NONE) { lv.addFooterView(mListFooterView); if (getListAdapter() != null) { // Already have an adapter - reset it to force the mode. But save the scroll // position so that we don't get kicked to the top. Parcelable listState = lv.onSaveInstanceState(); setListAdapter(mListAdapter); lv.onRestoreInstanceState(listState); } mListFooterProgress = mListFooterView.findViewById(R.id.progress); mListFooterText = (TextView) mListFooterView.findViewById(R.id.main_text); } else { lv.removeFooterView(mListFooterView); } updateListFooter(); } /** * Set the list footer text based on mode and the current "network active" status */ private void updateListFooter() { if (mListFooterMode != LIST_FOOTER_MODE_NONE) { int footerTextId = 0; switch (mListFooterMode) { case LIST_FOOTER_MODE_MORE: boolean active = mRefreshManager.isMessageListRefreshing(getMailboxId()); footerTextId = active ? R.string.status_loading_messages : R.string.message_list_load_more_messages_action; mListFooterProgress.setVisibility(active ? View.VISIBLE : View.GONE); break; } mListFooterText.setText(footerTextId); } } /** * Handle a click in the list footer, which changes meaning depending on what we're looking at. */ private void doFooterClick() { switch (mListFooterMode) { case LIST_FOOTER_MODE_NONE: // should never happen break; case LIST_FOOTER_MODE_MORE: onLoadMoreMessages(); break; } } private void showSendCommand(boolean show) { if (show != mShowSendCommand) { mShowSendCommand = show; mActivity.invalidateOptionsMenu(); } } private void updateMailboxSpecificActions() { final boolean isOutbox = (getMailboxId() == Mailbox.QUERY_ALL_OUTBOX) || ((mMailbox != null) && (mMailbox.mType == Mailbox.TYPE_OUTBOX)); showSendCommand(isOutbox && (mListAdapter != null) && (mListAdapter.getCount() > 0)); // A null account/mailbox means we're in a combined view. We show the move icon there, // even though it may be the case that we can't move messages from one of the mailboxes. // There's no good way to tell that right now, though. mShowMoveCommand = (mAccount == null || mAccount.supportsMoveMessages(getActivity())) && (mMailbox == null || mMailbox.canHaveMessagesMoved()); // Enable mailbox specific actions on the UIController level if needed. mActivity.invalidateOptionsMenu(); } /** * Adjusts message notification depending upon the state of the fragment and the currently * viewed mailbox. If the fragment is resumed, notifications for the current mailbox may * be suspended. Otherwise, notifications may be re-activated. Not all mailbox types are * supported for notifications. These include (but are not limited to) special mailboxes * such as {@link Mailbox#QUERY_ALL_DRAFTS}, {@link Mailbox#QUERY_ALL_FAVORITES}, etc... * * @param updateLastSeenKey If {@code true}, the last seen message key for the currently * viewed mailbox will be updated. */ private void adjustMessageNotification(boolean updateLastSeenKey) { final long accountId = getAccountId(); final long mailboxId = getMailboxId(); if (mailboxId == Mailbox.QUERY_ALL_INBOXES || mailboxId > 0) { if (updateLastSeenKey) { Utility.updateLastSeenMessageKey(mActivity, accountId); } NotificationController notifier = NotificationController.getInstance(mActivity); notifier.suspendMessageNotification(mResumed, accountId); } } private void startLoading() { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, this + " startLoading"); } // Clear the list. (ListFragment will show the "Loading" animation) showSendCommand(false); updateSearchHeader(null); // Start loading... final LoaderManager lm = getLoaderManager(); lm.initLoader(LOADER_ID_MESSAGES_LOADER, null, LOADER_CALLBACKS); } /** Timeout to show a warning, since some IMAP searches could take a long time. */ private final int SEARCH_WARNING_DELAY_MS = 10000; private void onSearchLoadTimeout() { // Search is taking too long. Show an error message. ViewGroup root = (ViewGroup) getView(); Activity host = getActivity(); if (root != null && host != null) { mListPanel.setVisibility(View.GONE); mWarningContainer = (ViewGroup) LayoutInflater.from(host).inflate( R.layout.message_list_warning, root, false); TextView title = UiUtilities.getView(mWarningContainer, R.id.message_title); TextView message = UiUtilities.getView(mWarningContainer, R.id.message_warning); title.setText(R.string.search_slow_warning_title); message.setText(R.string.search_slow_warning_message); root.addView(mWarningContainer); } } /** * Loader callbacks for message list. */ private final LoaderManager.LoaderCallbacks<Cursor> LOADER_CALLBACKS = new LoaderManager.LoaderCallbacks<Cursor>() { @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { final MessageListContext listContext = getListContext(); if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, MessageListFragment.this + " onCreateLoader(messages) listContext=" + listContext); } if (mListContext.isSearch()) { final MessageListContext searchInfo = mListContext; // Search results are not primed with local data, and so will usually be slow. // In some cases, they could take a long time to return, so we need to be robust. setListShownNoAnimation(false); Utility.getMainThreadHandler().postDelayed(new Runnable() { @Override public void run() { if (mListContext != searchInfo) { // Different list is being shown now. return; } if (!mIsFirstLoad) { // Something already returned. No need to do anything. return; } onSearchLoadTimeout(); } }, SEARCH_WARNING_DELAY_MS); } mIsFirstLoad = true; return MessagesAdapter.createLoader(getActivity(), listContext); } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor c) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, MessageListFragment.this + " onLoadFinished(messages) mailboxId=" + getMailboxId()); } MessagesAdapter.MessagesCursor cursor = (MessagesAdapter.MessagesCursor) c; // Update the list mListAdapter.swapCursor(cursor); if (!cursor.mIsFound) { mCallback.onMailboxNotFound(mIsFirstLoad); return; } // Get the "extras" part. mAccount = cursor.mAccount; mMailbox = cursor.mMailbox; mIsEasAccount = cursor.mIsEasAccount; mIsRefreshable = cursor.mIsRefreshable; mCountTotalAccounts = cursor.mCountTotalAccounts; // If this is a search result, open the first message. if (UiUtilities.useTwoPane(getActivity()) && mIsFirstLoad && mListContext.isSearch() && cursor.getCount() > 0) { cursor.moveToFirst(); onMessageOpen(getMailboxId(), cursor.getLong(MessagesAdapter.COLUMN_ID)); } // Suspend message notifications as long as we're resumed adjustMessageNotification(false); // If this is a search mailbox, set the query; otherwise, clear it if (mIsFirstLoad) { if (mMailbox != null && mMailbox.mType == Mailbox.TYPE_SEARCH) { mListAdapter.setQuery(getListContext().getSearchParams().mFilter); mSearchedMailbox = ((SearchResultsCursor) c).getSearchedMailbox(); } else { mListAdapter.setQuery(null); mSearchedMailbox = null; } updateMailboxSpecificActions(); // Show chips if combined view. mListAdapter.setShowColorChips(isCombinedMailbox() && mCountTotalAccounts > 1); } // Various post processing... updateSearchHeader(cursor); autoRefreshStaleMailbox(); updateFooterView(); updateSelectionMode(); // We want to make visible the selection only for the first load. // Re-load caused by content changed events shouldn't scroll the list. highlightSelectedMessage(mIsFirstLoad); if (mIsFirstLoad) { UiUtilities.setVisibilitySafe(mWarningContainer, View.GONE); mListPanel.setVisibility(View.VISIBLE); // Setting the adapter will automatically transition from "Loading" to showing // the list, which could show "No messages". Avoid showing that on the first sync, // if we know we're still potentially loading more. if (!isEmptyAndLoading(cursor)) { setListAdapter(mListAdapter); } } else if ((getListAdapter() == null) && !isEmptyAndLoading(cursor)) { setListAdapter(mListAdapter); } // Restore the state -- this step has to be the last, because Some of the // "post processing" seems to reset the scroll position. if (mSavedListState != null) { getListView().onRestoreInstanceState(mSavedListState); mSavedListState = null; } mIsFirstLoad = false; } /** * Determines whether or not the list is empty, but we're still potentially loading data. * This represents an ambiguous state where we may not want to show "No messages", since * it may still just be loading. */ private boolean isEmptyAndLoading(Cursor cursor) { return (cursor.getCount() == 0) && mRefreshManager.isMessageListRefreshing(mMailbox.mId); } @Override public void onLoaderReset(Loader<Cursor> loader) { if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { Log.d(Logging.LOG_TAG, MessageListFragment.this + " onLoaderReset(messages)"); } mListAdapter.swapCursor(null); mAccount = null; mMailbox = null; mSearchedMailbox = null; mCountTotalAccounts = 0; } }; /** * Show/hide the "selection" action mode, according to the number of selected messages and * the visibility of the fragment. * Also update the content (title and menus) if necessary. */ public void updateSelectionMode() { final int numSelected = getSelectedCount(); if ((numSelected == 0) || mDisableCab || !isViewCreated()) { finishSelectionMode(); return; } if (isInSelectionMode()) { updateSelectionModeView(); } else { mLastSelectionModeCallback = new SelectionModeCallback(); getActivity().startActionMode(mLastSelectionModeCallback); } } /** * Finish the "selection" action mode. * * Note this method finishes the contextual mode, but does *not* clear the selection. * If you want to do so use {@link #onDeselectAll()} instead. */ private void finishSelectionMode() { if (isInSelectionMode()) { mLastSelectionModeCallback.mClosedByUser = false; mSelectionMode.finish(); } } /** Update the "selection" action mode bar */ private void updateSelectionModeView() { mSelectionMode.invalidate(); } private class SelectionModeCallback implements ActionMode.Callback { private MenuItem mMarkRead; private MenuItem mMarkUnread; private MenuItem mAddStar; private MenuItem mRemoveStar; private MenuItem mMove; /* package */ boolean mClosedByUser = true; @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { mSelectionMode = mode; MenuInflater inflater = getActivity().getMenuInflater(); inflater.inflate(R.menu.message_list_fragment_cab_options, menu); mMarkRead = menu.findItem(R.id.mark_read); mMarkUnread = menu.findItem(R.id.mark_unread); mAddStar = menu.findItem(R.id.add_star); mRemoveStar = menu.findItem(R.id.remove_star); mMove = menu.findItem(R.id.move); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { int num = getSelectedCount(); // Set title -- "# selected" mSelectionMode.setTitle(getActivity().getResources().getQuantityString( R.plurals.message_view_selected_message_count, num, num)); // Show appropriate menu items. boolean nonStarExists = doesSelectionContainNonStarredMessage(); boolean readExists = doesSelectionContainReadMessage(); mMarkRead.setVisible(!readExists); mMarkUnread.setVisible(readExists); mAddStar.setVisible(nonStarExists); mRemoveStar.setVisible(!nonStarExists); mMove.setVisible(mShowMoveCommand); return true; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { Set<Long> selectedConversations = mListAdapter.getSelectedSet(); if (selectedConversations.isEmpty()) return true; switch (item.getItemId()) { case R.id.mark_read: // Note - marking as read does not trigger auto-advance. toggleRead(selectedConversations); break; case R.id.mark_unread: mCallback.onAdvancingOpAccepted(selectedConversations); toggleRead(selectedConversations); break; case R.id.add_star: case R.id.remove_star: // TODO: removing a star can be a destructive command and cause auto-advance // if the current mailbox shown is favorites. toggleFavorite(selectedConversations); break; case R.id.delete: mCallback.onAdvancingOpAccepted(selectedConversations); deleteMessages(selectedConversations); break; case R.id.move: showMoveMessagesDialog(selectedConversations); break; } return true; } @Override public void onDestroyActionMode(ActionMode mode) { // Clear this before onDeselectAll() to prevent onDeselectAll() from trying to close the // contextual mode again. mSelectionMode = null; if (mClosedByUser) { // Clear selection, only when the contextual mode is explicitly closed by the user. // // We close the contextual mode when the fragment becomes temporary invisible // (i.e. mIsVisible == false) too, in which case we want to keep the selection. onDeselectAll(); } } } private class RefreshListener implements RefreshManager.Listener { @Override public void onMessagingError(long accountId, long mailboxId, String message) { } @Override public void onRefreshStatusChanged(long accountId, long mailboxId) { updateListFooter(); } } /** * Highlight the selected message. */ private void highlightSelectedMessage(boolean ensureSelectionVisible) { if (!isViewCreated()) { return; } final ListView lv = getListView(); if (mSelectedMessageId == -1) { // No message selected lv.clearChoices(); return; } final int count = lv.getCount(); for (int i = 0; i < count; i++) { if (lv.getItemIdAtPosition(i) != mSelectedMessageId) { continue; } lv.setItemChecked(i, true); if (ensureSelectionVisible) { Utility.listViewSmoothScrollToPosition(getActivity(), lv, i); } break; } } }