MessageListFragment.java revision e2201949bab24427aff4f5d85e36e96f6a2a8af6
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.activity;
18
19import com.android.email.Controller;
20import com.android.email.Email;
21import com.android.email.R;
22import com.android.email.RefreshManager;
23import com.android.email.Utility;
24import com.android.email.Utility.ListStateSaver;
25import com.android.email.data.MailboxAccountLoader;
26import com.android.email.provider.EmailContent;
27import com.android.email.provider.EmailProvider;
28import com.android.email.provider.EmailContent.Account;
29import com.android.email.provider.EmailContent.Mailbox;
30import com.android.email.provider.EmailContent.Message;
31import com.android.email.service.MailService;
32
33import android.app.Activity;
34import android.app.ListFragment;
35import android.app.LoaderManager;
36import android.content.ClipData;
37import android.content.ContentUris;
38import android.content.Context;
39import android.content.Loader;
40import android.content.res.Resources;
41import android.database.Cursor;
42import android.graphics.Canvas;
43import android.graphics.Point;
44import android.graphics.Typeface;
45import android.graphics.drawable.Drawable;
46import android.os.AsyncTask;
47import android.os.Bundle;
48import android.os.Parcel;
49import android.os.Parcelable;
50import android.text.TextPaint;
51import android.util.Log;
52import android.view.ActionMode;
53import android.view.DragEvent;
54import android.view.LayoutInflater;
55import android.view.Menu;
56import android.view.MenuInflater;
57import android.view.MenuItem;
58import android.view.View;
59import android.view.ViewGroup;
60import android.view.View.DragThumbnailBuilder;
61import android.view.View.OnDragListener;
62import android.widget.AdapterView;
63import android.widget.ListView;
64import android.widget.TextView;
65import android.widget.Toast;
66import android.widget.AdapterView.OnItemClickListener;
67import android.widget.AdapterView.OnItemLongClickListener;
68
69import java.security.InvalidParameterException;
70import java.util.HashSet;
71import java.util.Set;
72
73// TODO Better handling of restoring list position/adapter check status
74/**
75 * Message list.
76 *
77 * <p>This fragment uses two different loaders to load data.
78 * <ul>
79 *   <li>One to load {@link Account} and {@link Mailbox}, with {@link MailboxAccountLoader}.
80 *   <li>The other to actually load messages.
81 * </ul>
82 * We run them sequentially.  i.e. First starts {@link MailboxAccountLoader}, and when it finishes
83 * starts the other.
84 *
85 * TODO Finalize batch move UI.  Probably the "move" button should be disabled or hidden when
86 * the selection contains non-movable messages.  But then how does the user know why they can't be
87 * moved?
88 */
89public class MessageListFragment extends ListFragment
90        implements OnItemClickListener, OnItemLongClickListener, MessagesAdapter.Callback,
91        MoveMessageToDialog.Callback, OnDragListener {
92    private static final String BUNDLE_LIST_STATE = "MessageListFragment.state.listState";
93    private static final String BUNDLE_KEY_SELECTED_MESSAGE_ID
94            = "messageListFragment.state.listState.selected_message_id";
95
96    private static final int LOADER_ID_MAILBOX_LOADER = 1;
97    private static final int LOADER_ID_MESSAGES_LOADER = 2;
98
99    // UI Support
100    private Activity mActivity;
101    private Callback mCallback = EmptyCallback.INSTANCE;
102
103    private ListView mListView;
104    private View mListFooterView;
105    private TextView mListFooterText;
106    private View mListFooterProgress;
107    private View mListPanel;
108    private View mNoMessagesPanel;
109
110    private static final int LIST_FOOTER_MODE_NONE = 0;
111    private static final int LIST_FOOTER_MODE_MORE = 1;
112    private int mListFooterMode;
113
114    private MessagesAdapter mListAdapter;
115
116    private long mMailboxId = -1;
117    private long mLastLoadedMailboxId = -1;
118    private long mSelectedMessageId = -1;
119
120    private Account mAccount;
121    private Mailbox mMailbox;
122    private boolean mIsEasAccount;
123    private boolean mIsRefreshable;
124    private int mCountTotalAccounts;
125
126    // Controller access
127    private Controller mController;
128    private RefreshManager mRefreshManager;
129    private RefreshListener mRefreshListener = new RefreshListener();
130
131    // Misc members
132    private boolean mDoAutoRefresh;
133
134    private boolean mOpenRequested;
135
136    /** true between {@link #onResume} and {@link #onPause}. */
137    private boolean mResumed;
138
139    /**
140     * {@link ActionMode} shown when 1 or more message is selected.
141     */
142    private ActionMode mSelectionMode;
143
144    /** Whether "Send all messages" should be shown. */
145    private boolean mShowSendCommand;
146
147    private Utility.ListStateSaver mSavedListState;
148
149    private MessageOpenTask mMessageOpenTask;
150
151    /**
152     * Callback interface that owning activities must implement
153     */
154    public interface Callback {
155        public static final int TYPE_REGULAR = 0;
156        public static final int TYPE_DRAFT = 1;
157        public static final int TYPE_TRASH = 2;
158
159        /**
160         * Called when the specified mailbox does not exist.
161         */
162        public void onMailboxNotFound();
163
164        /**
165         * Called when the user wants to open a message.
166         * Note {@code mailboxId} is of the actual mailbox of the message, which is different from
167         * {@link MessageListFragment#getMailboxId} if it's magic mailboxes.
168         *
169         * @param messageId the message ID of the message
170         * @param messageMailboxId the mailbox ID of the message.
171         *     This will never take values like {@link Mailbox#QUERY_ALL_INBOXES}.
172         * @param listMailboxId the mailbox ID of the listbox shown on this fragment.
173         *     This can be that of a magic mailbox, e.g.  {@link Mailbox#QUERY_ALL_INBOXES}.
174         * @param type {@link #TYPE_REGULAR}, {@link #TYPE_DRAFT} or {@link #TYPE_TRASH}.
175         */
176        public void onMessageOpen(long messageId, long messageMailboxId, long listMailboxId,
177                int type);
178
179        /**
180         * Called when entering/leaving selection mode.
181         * @param enter true if entering, false if leaving
182         */
183        public void onEnterSelectionMode(boolean enter);
184    }
185
186    private static final class EmptyCallback implements Callback {
187        public static final Callback INSTANCE = new EmptyCallback();
188
189        @Override
190        public void onMailboxNotFound() {
191        }
192        @Override
193        public void onMessageOpen(
194                long messageId, long messageMailboxId, long listMailboxId, int type) {
195        }
196        @Override
197        public void onEnterSelectionMode(boolean enter) {
198        }
199    }
200
201    @Override
202    public void onCreate(Bundle savedInstanceState) {
203        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
204            Log.d(Email.LOG_TAG, "MessageListFragment onCreate");
205        }
206        super.onCreate(savedInstanceState);
207        mActivity = getActivity();
208        setHasOptionsMenu(true);
209        mController = Controller.getInstance(mActivity);
210        mRefreshManager = RefreshManager.getInstance(mActivity);
211        mRefreshManager.registerListener(mRefreshListener);
212    }
213
214    @Override
215    public View onCreateView(
216            LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
217        // Use a custom layout, which includes the original layout with "send messages" panel.
218        View root = inflater.inflate(R.layout.message_list_fragment,null);
219        mListPanel = root.findViewById(R.id.list_panel);
220        mNoMessagesPanel = root.findViewById(R.id.no_messages_panel);
221        return root;
222    }
223
224    @Override
225    public void onActivityCreated(Bundle savedInstanceState) {
226        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
227            Log.d(Email.LOG_TAG, "MessageListFragment onActivityCreated");
228        }
229        super.onActivityCreated(savedInstanceState);
230
231        mListView = getListView();
232        mListView.setOnItemClickListener(this);
233        mListView.setOnItemLongClickListener(this);
234        mListView.setItemsCanFocus(false);
235        mListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
236
237        mListAdapter = new MessagesAdapter(mActivity, this);
238
239        mListFooterView = getActivity().getLayoutInflater().inflate(
240                R.layout.message_list_item_footer, mListView, false);
241
242        if (savedInstanceState != null) {
243            // Fragment doesn't have this method.  Call it manually.
244            loadState(savedInstanceState);
245        }
246    }
247
248    @Override
249    public void onStart() {
250        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
251            Log.d(Email.LOG_TAG, "MessageListFragment onStart");
252        }
253        super.onStart();
254    }
255
256    @Override
257    public void onResume() {
258        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
259            Log.d(Email.LOG_TAG, "MessageListFragment onResume");
260        }
261        super.onResume();
262        mResumed = true;
263
264        // If we're recovering from the stopped state, we don't have to reload.
265        // (when mOpenRequested = false)
266        if (mMailboxId != -1 && mOpenRequested) {
267            startLoading();
268        }
269    }
270
271    @Override
272    public void onPause() {
273        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
274            Log.d(Email.LOG_TAG, "MessageListFragment onPause");
275        }
276        mResumed = false;
277        super.onStop();
278        mSavedListState = new Utility.ListStateSaver(getListView());
279    }
280
281    @Override
282    public void onStop() {
283        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
284            Log.d(Email.LOG_TAG, "MessageListFragment onStop");
285        }
286        super.onStop();
287    }
288
289    @Override
290    public void onDestroy() {
291        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
292            Log.d(Email.LOG_TAG, "MessageListFragment onDestroy");
293        }
294        Utility.cancelTaskInterrupt(mMessageOpenTask);
295        mMessageOpenTask = null;
296        mRefreshManager.unregisterListener(mRefreshListener);
297        super.onDestroy();
298    }
299
300    @Override
301    public void onSaveInstanceState(Bundle outState) {
302        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
303            Log.d(Email.LOG_TAG, "MessageListFragment onSaveInstanceState");
304        }
305        super.onSaveInstanceState(outState);
306        mListAdapter.onSaveInstanceState(outState);
307        outState.putParcelable(BUNDLE_LIST_STATE, new Utility.ListStateSaver(getListView()));
308        outState.putLong(BUNDLE_KEY_SELECTED_MESSAGE_ID, mSelectedMessageId);
309    }
310
311    // Unit tests use it
312    /* package */void loadState(Bundle savedInstanceState) {
313        mListAdapter.loadState(savedInstanceState);
314        mSavedListState = savedInstanceState.getParcelable(BUNDLE_LIST_STATE);
315        mSelectedMessageId = savedInstanceState.getLong(BUNDLE_KEY_SELECTED_MESSAGE_ID);
316    }
317
318    @Override
319    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
320        inflater.inflate(R.menu.message_list_fragment_option, menu);
321    }
322
323    @Override
324    public void onPrepareOptionsMenu(Menu menu) {
325        menu.findItem(R.id.send).setVisible(mShowSendCommand);
326    }
327
328    @Override
329    public boolean onOptionsItemSelected(MenuItem item) {
330        switch (item.getItemId()) {
331            case R.id.send:
332                onSendPendingMessages();
333                return true;
334
335        }
336        return false;
337    }
338
339    public void setCallback(Callback callback) {
340        mCallback = (callback != null) ? callback : EmptyCallback.INSTANCE;
341    }
342
343    /**
344     * Clear all the content, stop the loaders, etc -- should be called when the fragment is hidden.
345     */
346    public void clearContent() {
347        mMailboxId = -1;
348        stopLoaders();
349        onDeselectAll();
350        if (mListAdapter != null) {
351            mListAdapter.changeCursor(null);
352        }
353        setListShownNoAnimation(false);
354    }
355
356    /**
357     * Called by an Activity to open an mailbox.
358     *
359     * @param mailboxId the ID of a mailbox, or one of "special" mailbox IDs like
360     *     {@link Mailbox#QUERY_ALL_INBOXES}.  -1 is not allowed.
361     */
362    public void openMailbox(long mailboxId) {
363        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
364            Log.d(Email.LOG_TAG, "MessageListFragment openMailbox");
365        }
366        if (mailboxId == -1) {
367            throw new InvalidParameterException();
368        }
369        if (mMailboxId == mailboxId) {
370            return;
371        }
372
373        mOpenRequested = true;
374        mMailboxId = mailboxId;
375
376        onDeselectAll();
377        if (mResumed) {
378            startLoading();
379        }
380    }
381
382    public void setSelectedMessage(long messageId) {
383        mSelectedMessageId = messageId;
384        if (mResumed) {
385            highlightSelectedMessage(true);
386        }
387    }
388
389    /* package */MessagesAdapter getAdapterForTest() {
390        return mListAdapter;
391    }
392
393    /**
394     * @return the account id or -1 if it's unknown yet.  It's also -1 if it's a magic mailbox.
395     */
396    public long getAccountId() {
397        return (mMailbox == null) ? -1 : mMailbox.mAccountKey;
398    }
399
400    /**
401     * @return the mailbox id, which is the value set to {@link #openMailbox}.
402     * (Meaning it will never return -1, but may return special values,
403     * eg {@link Mailbox#QUERY_ALL_INBOXES}).
404     */
405    public long getMailboxId() {
406        return mMailboxId;
407    }
408
409    /**
410     * @return true if the mailbox is a "special" box.  (e.g. combined inbox, all starred, etc.)
411     */
412    public boolean isMagicMailbox() {
413        return mMailboxId < 0;
414    }
415
416    /**
417     * @return the number of messages that are currently selecteed.
418     */
419    public int getSelectedCount() {
420        return mListAdapter.getSelectedSet().size();
421    }
422
423    /**
424     * @return true if the list is in the "selection" mode.
425     */
426    public boolean isInSelectionMode() {
427        return mSelectionMode != null;
428    }
429
430    /**
431     * Called when a message is clicked.
432     */
433    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
434        if (view != mListFooterView) {
435            MessageListItem itemView = (MessageListItem) view;
436            onMessageOpen(itemView.mMailboxId, id);
437        } else {
438            doFooterClick();
439        }
440    }
441
442    // This is tentative drag & drop UI
443    // STOPSHIP this entire class needs to be rewritten based on the actual UI design
444    private static class ThumbnailBuilder extends DragThumbnailBuilder {
445        private static Drawable sBackground;
446        private static TextPaint sPaint;
447
448        // TODO Get actual dimention from UI
449        private static final int mWidth = 250;
450        private final int mHeight;
451        private String mDragDesc;
452        private float mDragDescX;
453        private float mDragDescY;
454
455        public ThumbnailBuilder(View view, int count) {
456            super(view);
457            Resources resources = view.getResources();
458            // TODO Get actual dimension from UI
459            mHeight = view.getHeight();
460            mDragDesc = resources.getQuantityString(R.plurals.move_messages, count, count);
461            mDragDescX = 60;
462            // Use height of this font??
463            mDragDescY = view.getHeight() / 2;
464            if (sBackground == null) {
465                sBackground = resources.getDrawable(R.drawable.drag_background_holo);
466                sBackground.setBounds(0, 0, mWidth, view.getHeight());
467                sPaint = new TextPaint();
468                sPaint.setTypeface(Typeface.DEFAULT_BOLD);
469                sPaint.setTextSize(18);
470            }
471        }
472
473        @Override
474        public void onProvideThumbnailMetrics(Point thumbnailSize, Point thumbnailTouchPoint) {
475            thumbnailSize.set(mWidth, mHeight);
476            thumbnailTouchPoint.set(20, mHeight / 2);
477        }
478
479        @Override
480        public void onDrawThumbnail(Canvas canvas) {
481            super.onDrawThumbnail(canvas);
482            sBackground.draw(canvas);
483            canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint);
484        }
485    }
486
487    public boolean onDrag(View view, DragEvent event) {
488        switch(event.getAction()) {
489            case DragEvent.ACTION_DRAG_ENDED:
490                if (event.getResult()) {
491                    finishSelectionMode();
492                }
493                break;
494        }
495        return false;
496    }
497
498    public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
499        if (view != mListFooterView) {
500            // We can't move from combined accounts view
501            // We also need to check the actual mailbox to see if we can move items from it
502            if (mAccount == null || mMailbox == null) {
503                return false;
504            } else if (mMailboxId > 0 && !Mailbox.canMoveFrom(mActivity, mMailboxId)) {
505                return false;
506            }
507            MessageListItem listItem = (MessageListItem)view;
508            if (!mListAdapter.isSelected(listItem)) {
509                toggleSelection(listItem);
510            }
511            // Create ClipData with the Uri of the message we're long clicking
512            ClipData data = ClipData.newUri(mActivity.getContentResolver(),
513                    MessageListItem.MESSAGE_LIST_ITEMS_CLIP_LABEL, null,
514                    Message.CONTENT_URI.buildUpon()
515                    .appendPath(Long.toString(listItem.mMessageId))
516                    .appendQueryParameter(
517                            EmailProvider.MESSAGE_URI_PARAMETER_MAILBOX_ID,
518                            Long.toString(mMailboxId))
519                            .build());
520            Set<Long> selectedMessageIds = mListAdapter.getSelectedSet();
521            int size = selectedMessageIds.size();
522            // Add additional Uri's for any other selected messages
523            for (Long messageId: selectedMessageIds) {
524                if (messageId.longValue() != listItem.mMessageId) {
525                    data.addItem(new ClipData.Item(
526                            ContentUris.withAppendedId(Message.CONTENT_URI, messageId)));
527                }
528            }
529            // Start dragging now
530            listItem.setOnDragListener(this);
531            listItem.startDrag(data, new ThumbnailBuilder(listItem, size), false);
532            return true;
533        }
534        return false;
535    }
536
537    private void toggleSelection(MessageListItem itemView) {
538        mListAdapter.toggleSelected(itemView);
539    }
540
541    /**
542     * Called when a message on the list is selected
543     *
544     * @param messageMailboxId the actual mailbox ID of the message.  Note it's different from
545     * {@link #mMailboxId} in combined mailboxes.  ({@link #mMailboxId} can take values such as
546     * {@link Mailbox#QUERY_ALL_INBOXES})
547     * @param messageId ID of the msesage to open.
548     */
549    private void onMessageOpen(final long messageMailboxId, final long messageId) {
550        Utility.cancelTaskInterrupt(mMessageOpenTask);
551        mMessageOpenTask = new MessageOpenTask(messageMailboxId, messageId);
552        mMessageOpenTask.execute();
553    }
554
555    /**
556     * Task to look up the mailbox type for a message, and kicks the callback.
557     */
558    private class MessageOpenTask extends AsyncTask<Void, Void, Integer> {
559        private final long mMessageMailboxId;
560        private final long mMessageId;
561
562        public MessageOpenTask(long messageMailboxId, long messageId) {
563            mMessageMailboxId = messageMailboxId;
564            mMessageId = messageId;
565        }
566
567        @Override
568        protected Integer doInBackground(Void... params) {
569            // Restore the mailbox type.  Note we can't use mMailbox.mType here, because
570            // we don't have mMailbox for combined mailbox.
571            // ("All Starred" can contain any kind of messages.)
572            switch (Mailbox.getMailboxType(mActivity, mMessageMailboxId)) {
573                case EmailContent.Mailbox.TYPE_DRAFTS:
574                    return Callback.TYPE_DRAFT;
575                case EmailContent.Mailbox.TYPE_TRASH:
576                    return Callback.TYPE_TRASH;
577                default:
578                    return Callback.TYPE_REGULAR;
579            }
580        }
581
582        @Override
583        protected void onPostExecute(Integer type) {
584            if (isCancelled() || type == null) {
585                return;
586            }
587            mCallback.onMessageOpen(mMessageId, mMessageMailboxId, getMailboxId(), type);
588        }
589    }
590
591    public void onMultiToggleRead() {
592        onMultiToggleRead(mListAdapter.getSelectedSet());
593    }
594
595    public void onMultiToggleFavorite() {
596        onMultiToggleFavorite(mListAdapter.getSelectedSet());
597    }
598
599    public void onMultiDelete() {
600        onMultiDelete(mListAdapter.getSelectedSet());
601    }
602
603    public void onMultiMove() {
604        long[] messageIds = Utility.toPrimitiveLongArray(mListAdapter.getSelectedSet());
605        MoveMessageToDialog dialog = MoveMessageToDialog.newInstance(getActivity(), messageIds,
606                this);
607        dialog.show(getFragmentManager(), "dialog");
608    }
609
610    @Override
611    public void onMoveToMailboxSelected(long newMailboxId, long[] messageIds) {
612        ActivityHelper.moveMessages(getActivity(), newMailboxId, messageIds);
613
614        // Move is async, so we can't refresh now.  Instead, just clear the selection.
615        onDeselectAll();
616    }
617
618    /**
619     * Refresh the list.  NOOP for special mailboxes (e.g. combined inbox).
620     *
621     * Note: Manual refresh is enabled even for push accounts.
622     */
623    public void onRefresh() {
624        if (!mIsRefreshable) {
625            return;
626        }
627        long accountId = getAccountId();
628        if (accountId != -1) {
629            mRefreshManager.refreshMessageList(accountId, mMailboxId);
630        }
631    }
632
633    public void onDeselectAll() {
634        if ((mListAdapter == null) || (mListAdapter.getSelectedSet().size() == 0)) {
635            return;
636        }
637        mListAdapter.getSelectedSet().clear();
638        getListView().invalidateViews();
639        finishSelectionMode();
640    }
641
642    /**
643     * Load more messages.  NOOP for special mailboxes (e.g. combined inbox).
644     */
645    private void onLoadMoreMessages() {
646        long accountId = getAccountId();
647        if (accountId != -1) {
648            mRefreshManager.loadMoreMessages(accountId, mMailboxId);
649        }
650    }
651
652    /**
653     * @return if it's an outbox or "all outboxes".
654     *
655     * TODO make it private.  It's only used by MessageList, but the callsite is obsolete.
656     */
657    public boolean isOutbox() {
658        return (getMailboxId() == Mailbox.QUERY_ALL_OUTBOX)
659            || ((mMailbox != null) && (mMailbox.mType == Mailbox.TYPE_OUTBOX));
660    }
661
662    public void onSendPendingMessages() {
663        RefreshManager rm = RefreshManager.getInstance(mActivity);
664        if (getMailboxId() == Mailbox.QUERY_ALL_OUTBOX) {
665            rm.sendPendingMessagesForAllAccounts();
666        } else if (mMailbox != null) { // Magic boxes don't have a specific account id.
667            rm.sendPendingMessages(mMailbox.mAccountKey);
668        }
669    }
670
671    private void onSetMessageRead(long messageId, boolean newRead) {
672        mController.setMessageRead(messageId, newRead);
673    }
674
675    private void onSetMessageFavorite(long messageId, boolean newFavorite) {
676        mController.setMessageFavorite(messageId, newFavorite);
677    }
678
679    /**
680     * Toggles a set read/unread states.  Note, the default behavior is "mark unread", so the
681     * sense of the helper methods is "true=unread".
682     *
683     * @param selectedSet The current list of selected items
684     */
685    private void onMultiToggleRead(Set<Long> selectedSet) {
686        toggleMultiple(selectedSet, new MultiToggleHelper() {
687
688            public boolean getField(long messageId, Cursor c) {
689                return c.getInt(MessagesAdapter.COLUMN_READ) == 0;
690            }
691
692            public boolean setField(long messageId, Cursor c, boolean newValue) {
693                boolean oldValue = getField(messageId, c);
694                if (oldValue != newValue) {
695                    onSetMessageRead(messageId, !newValue);
696                    return true;
697                }
698                return false;
699            }
700        });
701    }
702
703    /**
704     * Toggles a set of favorites (stars)
705     *
706     * @param selectedSet The current list of selected items
707     */
708    private void onMultiToggleFavorite(Set<Long> selectedSet) {
709        toggleMultiple(selectedSet, new MultiToggleHelper() {
710
711            public boolean getField(long messageId, Cursor c) {
712                return c.getInt(MessagesAdapter.COLUMN_FAVORITE) != 0;
713            }
714
715            public boolean setField(long messageId, Cursor c, boolean newValue) {
716                boolean oldValue = getField(messageId, c);
717                if (oldValue != newValue) {
718                    onSetMessageFavorite(messageId, newValue);
719                    return true;
720                }
721                return false;
722            }
723        });
724    }
725
726    private void onMultiDelete(Set<Long> selectedSet) {
727        // Clone the set, because deleting is going to thrash things
728        HashSet<Long> cloneSet = new HashSet<Long>(selectedSet);
729        for (Long id : cloneSet) {
730            mController.deleteMessage(id, -1);
731        }
732        Toast.makeText(mActivity, mActivity.getResources().getQuantityString(
733                R.plurals.message_deleted_toast, cloneSet.size()), Toast.LENGTH_SHORT).show();
734        selectedSet.clear();
735        // Message deletion is async... Can't refresh the list immediately.
736    }
737
738    private interface MultiToggleHelper {
739        /**
740         * Return true if the field of interest is "set".  If one or more are false, then our
741         * bulk action will be to "set".  If all are set, our bulk action will be to "clear".
742         * @param messageId the message id of the current message
743         * @param c the cursor, positioned to the item of interest
744         * @return true if the field at this row is "set"
745         */
746        public boolean getField(long messageId, Cursor c);
747
748        /**
749         * Set or clear the field of interest.  Return true if a change was made.
750         * @param messageId the message id of the current message
751         * @param c the cursor, positioned to the item of interest
752         * @param newValue the new value to be set at this row
753         * @return true if a change was actually made
754         */
755        public boolean setField(long messageId, Cursor c, boolean newValue);
756    }
757
758    /**
759     * Toggle multiple fields in a message, using the following logic:  If one or more fields
760     * are "clear", then "set" them.  If all fields are "set", then "clear" them all.
761     *
762     * @param selectedSet the set of messages that are selected
763     * @param helper functions to implement the specific getter & setter
764     * @return the number of messages that were updated
765     */
766    private int toggleMultiple(Set<Long> selectedSet, MultiToggleHelper helper) {
767        Cursor c = mListAdapter.getCursor();
768        boolean anyWereFound = false;
769        boolean allWereSet = true;
770
771        c.moveToPosition(-1);
772        while (c.moveToNext()) {
773            long id = c.getInt(MessagesAdapter.COLUMN_ID);
774            if (selectedSet.contains(Long.valueOf(id))) {
775                anyWereFound = true;
776                if (!helper.getField(id, c)) {
777                    allWereSet = false;
778                    break;
779                }
780            }
781        }
782
783        int numChanged = 0;
784
785        if (anyWereFound) {
786            boolean newValue = !allWereSet;
787            c.moveToPosition(-1);
788            while (c.moveToNext()) {
789                long id = c.getInt(MessagesAdapter.COLUMN_ID);
790                if (selectedSet.contains(Long.valueOf(id))) {
791                    if (helper.setField(id, c, newValue)) {
792                        ++numChanged;
793                    }
794                }
795            }
796        }
797
798        refreshList();
799
800        return numChanged;
801    }
802
803    /**
804     * Test selected messages for showing appropriate labels
805     * @param selectedSet
806     * @param column_id
807     * @param defaultflag
808     * @return true when the specified flagged message is selected
809     */
810    private boolean testMultiple(Set<Long> selectedSet, int column_id, boolean defaultflag) {
811        Cursor c = mListAdapter.getCursor();
812        if (c == null || c.isClosed()) {
813            return false;
814        }
815        c.moveToPosition(-1);
816        while (c.moveToNext()) {
817            long id = c.getInt(MessagesAdapter.COLUMN_ID);
818            if (selectedSet.contains(Long.valueOf(id))) {
819                if (c.getInt(column_id) == (defaultflag ? 1 : 0)) {
820                    return true;
821                }
822            }
823        }
824        return false;
825    }
826
827    /**
828     * @return true if one or more non-starred messages are selected.
829     */
830    public boolean doesSelectionContainNonStarredMessage() {
831        return testMultiple(mListAdapter.getSelectedSet(), MessagesAdapter.COLUMN_FAVORITE,
832                false);
833    }
834
835    /**
836     * @return true if one or more read messages are selected.
837     */
838    public boolean doesSelectionContainReadMessage() {
839        return testMultiple(mListAdapter.getSelectedSet(), MessagesAdapter.COLUMN_READ, true);
840    }
841
842    /**
843     * Called by activity to indicate that the user explicitly opened the
844     * mailbox and it needs auto-refresh when it's first shown. TODO:
845     * {@link MessageList} needs to call this as well.
846     *
847     * TODO It's a bit ugly. We can remove this if this fragment "remembers" the current mailbox ID
848     * through configuration changes.
849     */
850    public void doAutoRefresh() {
851        mDoAutoRefresh = true;
852    }
853
854    /**
855     * Implements a timed refresh of "stale" mailboxes.  This should only happen when
856     * multiple conditions are true, including:
857     *   Only refreshable mailboxes.
858     *   Only when the user explicitly opens the mailbox (not onResume, for example)
859     *   Only when the mailbox is "stale" (currently set to 5 minutes since last refresh)
860     * Note we do this even if it's a push account; even on Exchange only inbox can be pushed.
861     */
862    private void autoRefreshStaleMailbox() {
863        if (!mDoAutoRefresh // Not explicitly open
864                || !mIsRefreshable // Not refreshable (special box such as drafts, or magic boxes)
865                ) {
866            return;
867        }
868        mDoAutoRefresh = false;
869        if (!mRefreshManager.isMailboxStale(mMailboxId)) {
870            return;
871        }
872        onRefresh();
873    }
874
875    /** Implements {@link MessagesAdapter.Callback} */
876    @Override
877    public void onAdapterFavoriteChanged(MessageListItem itemView, boolean newFavorite) {
878        onSetMessageFavorite(itemView.mMessageId, newFavorite);
879    }
880
881    /** Implements {@link MessagesAdapter.Callback} */
882    @Override
883    public void onAdapterSelectedChanged(
884            MessageListItem itemView, boolean newSelected, int mSelectedCount) {
885        updateSelectionMode();
886    }
887
888    private void determineFooterMode() {
889        mListFooterMode = LIST_FOOTER_MODE_NONE;
890        if ((mMailbox == null) || (mMailbox.mType == Mailbox.TYPE_OUTBOX)
891                || (mMailbox.mType == Mailbox.TYPE_DRAFTS)) {
892            return; // No footer
893        }
894        if (!mIsEasAccount) {
895            // IMAP, POP has "load more"
896            mListFooterMode = LIST_FOOTER_MODE_MORE;
897        }
898    }
899
900    private void addFooterView() {
901        ListView lv = getListView();
902        if (mListFooterView != null) {
903            lv.removeFooterView(mListFooterView);
904        }
905        determineFooterMode();
906        if (mListFooterMode != LIST_FOOTER_MODE_NONE) {
907
908            lv.addFooterView(mListFooterView);
909            lv.setAdapter(mListAdapter);
910
911            mListFooterProgress = mListFooterView.findViewById(R.id.progress);
912            mListFooterText = (TextView) mListFooterView.findViewById(R.id.main_text);
913
914            updateListFooter();
915        }
916    }
917
918    /**
919     * Set the list footer text based on mode and the current "network active" status
920     */
921    private void updateListFooter() {
922        if (mListFooterMode != LIST_FOOTER_MODE_NONE) {
923            int footerTextId = 0;
924            switch (mListFooterMode) {
925                case LIST_FOOTER_MODE_MORE:
926                    boolean active = mRefreshManager.isMessageListRefreshing(mMailboxId);
927                    footerTextId = active ? R.string.status_loading_messages
928                            : R.string.message_list_load_more_messages_action;
929                    mListFooterProgress.setVisibility(active ? View.VISIBLE : View.GONE);
930                    break;
931            }
932            mListFooterText.setText(footerTextId);
933        }
934    }
935
936    /**
937     * Handle a click in the list footer, which changes meaning depending on what we're looking at.
938     */
939    private void doFooterClick() {
940        switch (mListFooterMode) {
941            case LIST_FOOTER_MODE_NONE: // should never happen
942                break;
943            case LIST_FOOTER_MODE_MORE:
944                onLoadMoreMessages();
945                break;
946        }
947    }
948
949    private void showSendCommand(boolean show) {
950        mShowSendCommand = show;
951        mActivity.invalidateOptionsMenu();
952    }
953
954    private void showSendCommandIfNecessary() {
955        showSendCommand(isOutbox() && (mListAdapter != null) && (mListAdapter.getCount() > 0));
956    }
957
958    private void showNoMessageText(boolean visible) {
959        mNoMessagesPanel.setVisibility(visible ? View.VISIBLE : View.GONE);
960        mListPanel.setVisibility(visible ? View.GONE : View.VISIBLE);
961    }
962
963    private void showNoMessageTextIfNecessary() {
964        boolean noItem = (mListFooterMode == LIST_FOOTER_MODE_NONE)
965                && (mListView.getCount() == 0);
966        showNoMessageText(noItem);
967    }
968
969    private void startLoading() {
970        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
971            Log.d(Email.LOG_TAG, "MessageListFragment startLoading");
972        }
973        mOpenRequested = false;
974
975        // Clear the list. (ListFragment will show the "Loading" animation)
976        showNoMessageText(false);
977        setListShown(false);
978        showSendCommand(false);
979
980        // Start loading...
981        final LoaderManager lm = getLoaderManager();
982
983        // If we're loading a different mailbox, discard the previous result.
984        // It also causes not to preserve the list position.
985        boolean mailboxChanging = false;
986        if ((mLastLoadedMailboxId != -1) && (mLastLoadedMailboxId != mMailboxId)) {
987            mailboxChanging = true;
988            stopLoaders();
989        }
990        lm.initLoader(LOADER_ID_MAILBOX_LOADER, null,
991                new MailboxAccountLoaderCallback(mailboxChanging));
992    }
993
994    private void stopLoaders() {
995        final LoaderManager lm = getLoaderManager();
996        lm.stopLoader(LOADER_ID_MAILBOX_LOADER);
997        lm.stopLoader(LOADER_ID_MESSAGES_LOADER);
998    }
999
1000    /**
1001     * Loader callbacks for {@link MailboxAccountLoader}.
1002     */
1003    private class MailboxAccountLoaderCallback implements LoaderManager.LoaderCallbacks<
1004            MailboxAccountLoader.Result> {
1005        private boolean mMailboxChanging;
1006
1007        public MailboxAccountLoaderCallback(boolean mailboxChanging) {
1008            mMailboxChanging = mailboxChanging;
1009        }
1010
1011        @Override
1012        public Loader<MailboxAccountLoader.Result> onCreateLoader(int id, Bundle args) {
1013            if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
1014                Log.d(Email.LOG_TAG,
1015                        "MessageListFragment onCreateLoader(mailbox) mailboxId=" + mMailboxId);
1016            }
1017            return new MailboxAccountLoader(getActivity().getApplicationContext(), mMailboxId);
1018        }
1019
1020        @Override
1021        public void onLoadFinished(Loader<MailboxAccountLoader.Result> loader,
1022                MailboxAccountLoader.Result result) {
1023            if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
1024                Log.d(Email.LOG_TAG, "MessageListFragment onLoadFinished(mailbox) mailboxId="
1025                        + mMailboxId);
1026            }
1027            if (!result.mIsFound) {
1028                mCallback.onMailboxNotFound();
1029                return;
1030            }
1031
1032            mLastLoadedMailboxId = mMailboxId;
1033            mAccount = result.mAccount;
1034            mMailbox = result.mMailbox;
1035            mIsEasAccount = result.mIsEasAccount;
1036            mIsRefreshable = result.mIsRefreshable;
1037            mCountTotalAccounts = result.mCountTotalAccounts;
1038            getLoaderManager().initLoader(LOADER_ID_MESSAGES_LOADER, null,
1039                    new MessagesLoaderCallback(mMailboxChanging));
1040
1041            // Clear this for next reload triggered by content changed events.
1042            mMailboxChanging = false;
1043        }
1044    }
1045
1046    /**
1047     * Reload the data and refresh the list view.
1048     */
1049    private void refreshList() {
1050        getLoaderManager().restartLoader(LOADER_ID_MESSAGES_LOADER, null,
1051                new MessagesLoaderCallback(false));
1052    }
1053
1054    /**
1055     * Loader callbacks for message list.
1056     */
1057    private class MessagesLoaderCallback implements LoaderManager.LoaderCallbacks<Cursor> {
1058        private boolean mMailboxChanging;
1059
1060        public MessagesLoaderCallback(boolean mailboxChanging) {
1061            mMailboxChanging = mailboxChanging;
1062        }
1063
1064        @Override
1065        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
1066            if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
1067                Log.d(Email.LOG_TAG,
1068                        "MessageListFragment onCreateLoader(messages) mailboxId=" + mMailboxId);
1069            }
1070            return MessagesAdapter.createLoader(getActivity(), mMailboxId);
1071        }
1072
1073        @Override
1074        public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
1075            if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
1076                Log.d(Email.LOG_TAG,
1077                        "MessageListFragment onLoadFinished(messages) mailboxId=" + mMailboxId);
1078            }
1079
1080            // Save list view state (primarily scroll position)
1081            final ListView lv = getListView();
1082            final Utility.ListStateSaver lss;
1083            if (mMailboxChanging) {
1084                lss = null; // Don't preserve list state
1085            } else if (mSavedListState != null) {
1086                lss = mSavedListState;
1087                mSavedListState = null;
1088            } else {
1089                lss = new Utility.ListStateSaver(lv);
1090            }
1091
1092            // Update the list
1093            mListAdapter.changeCursor(cursor);
1094            // Show chips if combined view.
1095            mListAdapter.setShowColorChips(mMailboxId < 0 && mCountTotalAccounts > 1);
1096            setListAdapter(mListAdapter);
1097            setListShown(true);
1098
1099            // Various post processing...
1100            autoRefreshStaleMailbox();
1101            addFooterView();
1102            updateSelectionMode();
1103            showSendCommandIfNecessary();
1104            showNoMessageTextIfNecessary();
1105
1106            // We want to make selection visible only when the loader was explicitly started.
1107            // i.e. Refresh caused by content changed events shouldn't scroll the list.
1108            highlightSelectedMessage(mMailboxChanging);
1109
1110            // Restore the state -- this step has to be the last, because Some of the
1111            // "post processing" seems to reset the scroll position.
1112            if (lss != null) {
1113                lss.restore(lv);
1114            }
1115
1116            resetNewMessageCount(mActivity, mMailboxId, getAccountId());
1117
1118            // Clear this for next reload triggered by content changed events.
1119            mMailboxChanging = false;
1120        }
1121    }
1122
1123    /**
1124     * Reset the "new message" count.
1125     * <ul>
1126     * <li>If {@code mailboxId} is {@link Mailbox#QUERY_ALL_INBOXES}, reset the
1127     * counts of all accounts.
1128     * <li>If {@code mailboxId} is not of a magic inbox (i.e. >= 0) and {@code
1129     * accountId} is valid, reset the count of the specified account.
1130     * </ul>
1131     */
1132    /* protected */static void resetNewMessageCount(
1133            Context context, long mailboxId, long accountId) {
1134        if (mailboxId == Mailbox.QUERY_ALL_INBOXES) {
1135            MailService.resetNewMessageCount(context, -1);
1136        } else if (mailboxId >= 0 && accountId != -1) {
1137            MailService.resetNewMessageCount(context, accountId);
1138        }
1139    }
1140
1141    /**
1142     * Show/hide the "selection" action mode, according to the number of selected messages,
1143     * and update the content (title and menus) if necessary.
1144     */
1145    public void updateSelectionMode() {
1146        final int numSelected = getSelectedCount();
1147        if (numSelected == 0) {
1148            finishSelectionMode();
1149            return;
1150        }
1151        if (isInSelectionMode()) {
1152            updateSelectionModeView();
1153        } else {
1154            getActivity().startActionMode(new SelectionModeCallback());
1155        }
1156    }
1157
1158    /** Finish the "selection" action mode */
1159    private void finishSelectionMode() {
1160        if (isInSelectionMode()) {
1161            mSelectionMode.finish();
1162            mSelectionMode = null;
1163        }
1164    }
1165
1166    /** Update the "selection" action mode bar */
1167    private void updateSelectionModeView() {
1168        mSelectionMode.invalidate();
1169    }
1170
1171    private class SelectionModeCallback implements ActionMode.Callback {
1172        private MenuItem mMarkRead;
1173        private MenuItem mMarkUnread;
1174        private MenuItem mAddStar;
1175        private MenuItem mRemoveStar;
1176
1177        @Override
1178        public boolean onCreateActionMode(ActionMode mode, Menu menu) {
1179            mSelectionMode = mode;
1180
1181            MenuInflater inflater = getActivity().getMenuInflater();
1182            inflater.inflate(R.menu.message_list_selection_mode, menu);
1183            mMarkRead = menu.findItem(R.id.mark_read);
1184            mMarkUnread = menu.findItem(R.id.mark_unread);
1185            mAddStar = menu.findItem(R.id.add_star);
1186            mRemoveStar = menu.findItem(R.id.remove_star);
1187
1188            mCallback.onEnterSelectionMode(true);
1189            return true;
1190        }
1191
1192        @Override
1193        public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
1194            int num = getSelectedCount();
1195            // Set title -- "# selected"
1196            mSelectionMode.setTitle(getActivity().getResources().getQuantityString(
1197                    R.plurals.message_view_selected_message_count, num, num));
1198
1199            // Show appropriate menu items.
1200            boolean nonStarExists = doesSelectionContainNonStarredMessage();
1201            boolean readExists = doesSelectionContainReadMessage();
1202            mMarkRead.setVisible(!readExists);
1203            mMarkUnread.setVisible(readExists);
1204            mAddStar.setVisible(nonStarExists);
1205            mRemoveStar.setVisible(!nonStarExists);
1206            return true;
1207        }
1208
1209        @Override
1210        public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
1211            switch (item.getItemId()) {
1212                case R.id.mark_read:
1213                case R.id.mark_unread:
1214                    onMultiToggleRead();
1215                    break;
1216                case R.id.add_star:
1217                case R.id.remove_star:
1218                    onMultiToggleFavorite();
1219                    break;
1220                case R.id.delete:
1221                    onMultiDelete();
1222                    break;
1223                case R.id.move:
1224                    onMultiMove();
1225                    break;
1226            }
1227            return true;
1228        }
1229
1230        @Override
1231        public void onDestroyActionMode(ActionMode mode) {
1232            mCallback.onEnterSelectionMode(false);
1233            onDeselectAll();
1234            mSelectionMode = null;
1235        }
1236    }
1237
1238    private class RefreshListener implements RefreshManager.Listener {
1239        @Override
1240        public void onMessagingError(long accountId, long mailboxId, String message) {
1241        }
1242
1243        @Override
1244        public void onRefreshStatusChanged(long accountId, long mailboxId) {
1245            updateListFooter();
1246        }
1247    }
1248
1249    /**
1250     * Object that holds the current state (right now it's only the ListView state) of the fragment.
1251     *
1252     * Used by {@link MessageListXLFragmentManager} to preserve scroll position through fragment
1253     * transitions.
1254     */
1255    public static class State implements Parcelable {
1256        private final ListStateSaver mListState;
1257
1258        private State(Parcel p) {
1259            mListState = p.readParcelable(getClass().getClassLoader());
1260        }
1261
1262        private State(MessageListFragment messageListFragment) {
1263            mListState = new Utility.ListStateSaver(messageListFragment.getListView());
1264        }
1265
1266        public void restore(MessageListFragment messageListFragment) {
1267            messageListFragment.mSavedListState = mListState;
1268        }
1269
1270        @Override
1271        public int describeContents() {
1272            return 0;
1273        }
1274
1275        @Override
1276        public void writeToParcel(Parcel dest, int flags) {
1277            dest.writeParcelable(mListState, flags);
1278        }
1279
1280        public static final Parcelable.Creator<State> CREATOR
1281                = new Parcelable.Creator<State>() {
1282                    public State createFromParcel(Parcel in) {
1283                        return new State(in);
1284                    }
1285
1286                    public State[] newArray(int size) {
1287                        return new State[size];
1288                    }
1289                };
1290    }
1291
1292    public State getState() {
1293        return new State(this);
1294    }
1295
1296    /**
1297     * Highlight the selected message.
1298     */
1299    private void highlightSelectedMessage(boolean ensureSelectionVisible) {
1300        if (mSelectedMessageId == -1) {
1301            // No mailbox selected
1302            mListView.clearChoices();
1303            return;
1304        }
1305
1306        final int count = mListView.getCount();
1307        for (int i = 0; i < count; i++) {
1308            if (mListView.getItemIdAtPosition(i) != mSelectedMessageId) {
1309                continue;
1310            }
1311            mListView.setItemChecked(i, true);
1312            if (ensureSelectionVisible) {
1313                Utility.listViewSmoothScrollToPosition(getActivity(), mListView, i);
1314            }
1315            break;
1316        }
1317    }
1318}
1319