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