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