MailboxListFragment.java revision 38a33bd28fae9eb3226c90d3225b812ca23856f0
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.provider.EmailProvider;
24import com.android.emailcommon.Logging;
25import com.android.emailcommon.provider.EmailContent.Mailbox;
26import com.android.emailcommon.provider.EmailContent.Message;
27import com.android.emailcommon.utility.EmailAsyncTask;
28import com.android.emailcommon.utility.Utility;
29
30import android.app.Activity;
31import android.app.ListFragment;
32import android.app.LoaderManager;
33import android.app.LoaderManager.LoaderCallbacks;
34import android.content.ClipData;
35import android.content.ClipDescription;
36import android.content.Loader;
37import android.content.res.Resources;
38import android.database.Cursor;
39import android.graphics.Rect;
40import android.graphics.drawable.Drawable;
41import android.net.Uri;
42import android.os.Bundle;
43import android.os.Parcelable;
44import android.util.Log;
45import android.view.DragEvent;
46import android.view.LayoutInflater;
47import android.view.View;
48import android.view.View.OnDragListener;
49import android.view.ViewGroup;
50import android.widget.AdapterView;
51import android.widget.AdapterView.OnItemClickListener;
52import android.widget.ListView;
53
54import java.security.InvalidParameterException;
55import java.util.Timer;
56import java.util.TimerTask;
57
58/**
59 * This fragment presents a list of mailboxes for a given account.  The "API" includes the
60 * following elements which must be provided by the host Activity.
61 *
62 *  - call bindActivityInfo() to provide the account ID and set callbacks
63 *  - provide callbacks for onOpen and onRefresh
64 *  - pass-through implementations of onCreateContextMenu() and onContextItemSelected() (temporary)
65 *
66 * TODO Restoring ListView state -- don't do this when changing accounts
67 * TODO Restoring scroll position on screen rotation is broken.
68 * TODO Remove clearContent -> most probably this is the cause for the scroll position not being
69 *      restored issue.
70 */
71public class MailboxListFragment extends ListFragment implements OnItemClickListener,
72        OnDragListener {
73    private static final String TAG = "MailboxListFragment";
74    private static final String BUNDLE_KEY_SELECTED_MAILBOX_ID
75            = "MailboxListFragment.state.selected_mailbox_id";
76    private static final String BUNDLE_LIST_STATE = "MailboxListFragment.state.listState";
77    private static final boolean DEBUG_DRAG_DROP = false; // MUST NOT SUBMIT SET TO TRUE
78    /** While in drag-n-drop, amount of time before it auto expands; in ms */
79    private static final long AUTO_EXPAND_DELAY = 750L;
80
81    /** No drop target is available where the user is currently hovering over */
82    private static final int NO_DROP_TARGET = -1;
83    // Total height of the top and bottom scroll zones, in pixels
84    private static final int SCROLL_ZONE_SIZE = 64;
85    // The amount of time to scroll by one pixel, in ms
86    private static final int SCROLL_SPEED = 4;
87
88    /** Arbitrary number for use with the loader manager */
89    private static final int MAILBOX_LOADER_ID = 1;
90
91    /** Argument name(s) */
92    private static final String ARG_ACCOUNT_ID = "accountId";
93    private static final String ARG_FORCE_RELOAD = "forceReload";
94
95    // TODO Clean up usage of mailbox ID. We use both '-1' and '0' to mean "not selected". To
96    // confuse matters, the database uses '-1' for "no mailbox" and '0' for "invalid mailbox".
97    // Once legacy accounts properly support nested folders, we need to make sure we're only
98    // ever using '-1'.
99    // STOPSHIP Change value to '-1' when legacy protocols support folders
100    private final static long DEFAULT_MAILBOX_ID = 0;
101
102    /** Timer to auto-expand folder lists during drag-n-drop */
103    private static final Timer sDragTimer = new Timer();
104    /** Rectangle used for hit testing children */
105    private static final Rect sTouchFrame = new Rect();
106
107    private RefreshManager mRefreshManager;
108
109    // UI Support
110    private Activity mActivity;
111    private MailboxesAdapter mListAdapter;
112    private Callback mCallback = EmptyCallback.INSTANCE;
113
114    private ListView mListView;
115
116    private boolean mResumed;
117
118    // Colors used for drop targets
119    private static Integer sDropTrashColor;
120    private static Drawable sDropActiveDrawable;
121
122    private long mLastLoadedAccountId = -1;
123    private long mAccountId = -1;
124    private long mSelectedMailboxId = DEFAULT_MAILBOX_ID;
125    /** The ID of the mailbox that we have been asked to load */
126    private long mLoadedMailboxId = -1;
127
128    private boolean mOpenRequested;
129
130    // True if a drag is currently in progress
131    private boolean mDragInProgress;
132    /** Mailbox ID of the item being dragged. Used to determine valid drop targets. */
133    private long mDragItemMailboxId = -1;
134    /** A unique identifier for the drop target. May be {@link #NO_DROP_TARGET}. */
135    private int mDropTargetId = NO_DROP_TARGET;
136    // The mailbox list item view that the user's finger is hovering over
137    private MailboxListItem mDropTargetView;
138    // Lazily instantiated height of a mailbox list item (-1 is a sentinel for 'not initialized')
139    private int mDragItemHeight = -1;
140    /** Task that actually does the work to auto-expand folder lists during drag-n-drop */
141    private TimerTask mDragTimerTask;
142    // True if we are currently scrolling under the drag item
143    private boolean mTargetScrolling;
144
145    private Parcelable mSavedListState;
146
147    private final MailboxesAdapter.Callback mMailboxesAdapterCallback =
148            new MailboxesAdapter.Callback() {
149        @Override
150        public void onBind(MailboxListItem listItem) {
151            listItem.setDropTargetBackground(mDragInProgress, mDragItemMailboxId);
152        }
153    };
154
155    /**
156     * Callback interface that owning activities must implement
157     */
158    public interface Callback {
159        /**
160         * Called when any mailbox (even a combined mailbox) is selected.
161         * @param accountId
162         *          The ID of the account for which a mailbox was selected
163         * @param mailboxId
164         *          The ID of the selected mailbox. This may be real mailbox ID [e.g. a number > 0],
165         *          or a special mailbox ID [e.g. {@link MessageListXLFragmentManager#NO_MAILBOX},
166         *          {@link Mailbox#QUERY_ALL_INBOXES}, etc...].
167         */
168        public void onMailboxSelected(long accountId, long mailboxId);
169
170        /** Called when an account is selected on the combined view. */
171        public void onAccountSelected(long accountId);
172
173        /**
174         * Called when the list updates to propagate the current mailbox name and the unread count
175         * for it.
176         *
177         * Note the reason why it's separated from onMailboxSelected is because this needs to be
178         * reported when the unread count changes without changing the current mailbox.
179         */
180        public void onCurrentMailboxUpdated(long mailboxId, String mailboxName, int unreadCount);
181    }
182
183    private static class EmptyCallback implements Callback {
184        public static final Callback INSTANCE = new EmptyCallback();
185        @Override public void onMailboxSelected(long accountId, long mailboxId) { }
186        @Override public void onAccountSelected(long accountId) { }
187        @Override public void onCurrentMailboxUpdated(long mailboxId, String mailboxName,
188                int unreadCount) { }
189    }
190
191    /**
192     * Returns the index of the view located at the specified coordinates in the given list.
193     * If the coordinates are outside of the list, {@code NO_DROP_TARGET} is returned.
194     */
195    private static int pointToIndex(ListView list, int x, int y) {
196        final int count = list.getChildCount();
197        for (int i = count - 1; i >= 0; i--) {
198            final View child = list.getChildAt(i);
199            if (child.getVisibility() == View.VISIBLE) {
200                child.getHitRect(sTouchFrame);
201                if (sTouchFrame.contains(x, y)) {
202                    return i;
203                }
204            }
205        }
206        return NO_DROP_TARGET;
207    }
208
209    /**
210     * Create a new instance with initialization parameters.
211     */
212    public static MailboxListFragment newInstance(long accountId, boolean forceReload) {
213        final MailboxListFragment instance = new MailboxListFragment();
214        final Bundle args = new Bundle();
215        args.putLong(ARG_ACCOUNT_ID, accountId);
216        args.putBoolean(ARG_FORCE_RELOAD, forceReload);
217        instance.setArguments(args);
218        return instance;
219    }
220
221    /**
222     * Called to do initial creation of a fragment.  This is called after
223     * {@link #onAttach(Activity)} and before {@link #onActivityCreated(Bundle)}.
224     */
225    @Override
226    public void onCreate(Bundle savedInstanceState) {
227        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
228            Log.d(Logging.LOG_TAG, "MailboxListFragment onCreate");
229        }
230        super.onCreate(savedInstanceState);
231
232        mActivity = getActivity();
233        mRefreshManager = RefreshManager.getInstance(mActivity);
234        mListAdapter = new MailboxFragmentAdapter(mActivity, mMailboxesAdapterCallback);
235        if (savedInstanceState != null) {
236            restoreInstanceState(savedInstanceState);
237        }
238        if (sDropTrashColor == null) {
239            Resources res = getResources();
240            sDropTrashColor = res.getColor(R.color.mailbox_drop_destructive_bg_color);
241            sDropActiveDrawable = res.getDrawable(R.drawable.list_activated_holo);
242        }
243    }
244
245    @Override
246    public View onCreateView(
247            LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
248        return inflater.inflate(R.layout.mailbox_list_fragment, container, false);
249    }
250
251    @Override
252    public void onActivityCreated(Bundle savedInstanceState) {
253        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
254            Log.d(Logging.LOG_TAG, "MailboxListFragment onActivityCreated");
255        }
256        super.onActivityCreated(savedInstanceState);
257
258        mListView = getListView();
259        mListView.setOnItemClickListener(this);
260        mListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
261        mListView.setOnDragListener(this);
262        registerForContextMenu(mListView);
263
264        final Bundle args = getArguments();
265        openMailboxes(args.getLong(ARG_ACCOUNT_ID), args.getBoolean(ARG_FORCE_RELOAD));
266    }
267
268    public void setCallback(Callback callback) {
269        mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback;
270    }
271
272    private void clearContent() {
273        getLoaderManager().destroyLoader(MAILBOX_LOADER_ID);
274
275        mLastLoadedAccountId = -1;
276        mAccountId = -1;
277        mSelectedMailboxId = DEFAULT_MAILBOX_ID;
278        mLoadedMailboxId = -1;
279
280        mOpenRequested = false;
281        mDropTargetId = NO_DROP_TARGET;
282        mDropTargetView = null;
283
284        if (mListAdapter != null) {
285            mListAdapter.swapCursor(null);
286        }
287        setListShownNoAnimation(false);
288    }
289
290    /**
291     * Opens the top-level mailboxes for the given account ID. If the account is currently
292     * loaded, the list of top-level mailbox will not be reloaded unless <code>forceReload</code>
293     * is <code>true</code>.
294     * @param accountId The ID of the account we want to view
295     * @param forceReload If <code>true</code>, always load the list of top-level mailboxes.
296     * Otherwise, only load the list of top-level mailboxes if the account changes.
297     */
298    // STOPSHIP Make it private once phone activities are gone
299    void openMailboxes(long accountId, boolean forceReload) {
300        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
301            Log.d(Logging.LOG_TAG, "MailboxListFragment openMailboxes");
302        }
303        if (accountId == -1) {
304            throw new InvalidParameterException();
305        }
306        if (!forceReload && mAccountId == accountId) {
307            return;
308        }
309        clearContent();
310        mOpenRequested = true;
311        mAccountId = accountId;
312        if (mResumed) {
313            startLoading();
314        }
315    }
316
317    /**
318     * Selects the given mailbox ID and possibly navigates to it. This loads any mailboxes
319     * contained within it and may cause the mailbox list to be updated. If the current fragment
320     * is not in the resumed state or if the mailbox cannot be navigated to, the given mailbox
321     * will only be selected. The mailbox is assumed to be associated with the account passed
322     * into {@link #openMailboxes(long)}.
323     * @param mailboxId The ID of the mailbox to select and navigate to.
324     */
325    public void navigateToMailbox(long mailboxId) {
326        setSelectedMailbox(mailboxId);
327        if (mResumed && isNavigable(mailboxId)) {
328            startLoading();
329        }
330    }
331
332    /**
333     * Returns whether or not the specified mailbox can be navigated to.
334     */
335    private boolean isNavigable(long mailboxId) {
336        final int count = mListView.getCount();
337        for (int i = 0; i < count; i++) {
338            final MailboxListItem item = (MailboxListItem) mListView.getChildAt(i);
339            if (item.mMailboxId != mSelectedMailboxId) {
340                continue;
341            }
342            return item.isNavigable();
343        }
344        return false;
345    }
346
347    /**
348     * Sets the selected mailbox to the given ID. Sub-folders will not be loaded.
349     * @param mailboxId The ID of the mailbox to select.
350     */
351    public void setSelectedMailbox(long mailboxId) {
352        mSelectedMailboxId = mailboxId;
353        if (mResumed) {
354            highlightSelectedMailbox(true);
355        }
356    }
357
358    /**
359     * Called when the Fragment is visible to the user.
360     */
361    @Override
362    public void onStart() {
363        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
364            Log.d(Logging.LOG_TAG, "MailboxListFragment onStart");
365        }
366        super.onStart();
367    }
368
369    /**
370     * Called when the fragment is visible to the user and actively running.
371     */
372    @Override
373    public void onResume() {
374        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
375            Log.d(Logging.LOG_TAG, "MailboxListFragment onResume");
376        }
377        super.onResume();
378        mResumed = true;
379
380        // If we're recovering from the stopped state, we don't have to reload.
381        // (when mOpenRequested = false)
382        if (mAccountId != -1 && mOpenRequested) {
383            startLoading();
384        }
385    }
386
387    @Override
388    public void onPause() {
389        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
390            Log.d(Logging.LOG_TAG, "MailboxListFragment onPause");
391        }
392        mResumed = false;
393        super.onPause();
394        mSavedListState = getListView().onSaveInstanceState();
395    }
396
397    /**
398     * Called when the Fragment is no longer started.
399     */
400    @Override
401    public void onStop() {
402        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
403            Log.d(Logging.LOG_TAG, "MailboxListFragment onStop");
404        }
405        super.onStop();
406    }
407
408    /**
409     * Called when the fragment is no longer in use.
410     */
411    @Override
412    public void onDestroy() {
413        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
414            Log.d(Logging.LOG_TAG, "MailboxListFragment onDestroy");
415        }
416        super.onDestroy();
417    }
418
419    @Override
420    public void onSaveInstanceState(Bundle outState) {
421        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
422            Log.d(Logging.LOG_TAG, "MailboxListFragment onSaveInstanceState");
423        }
424        super.onSaveInstanceState(outState);
425        outState.putLong(BUNDLE_KEY_SELECTED_MAILBOX_ID, mSelectedMailboxId);
426        outState.putParcelable(BUNDLE_LIST_STATE, getListView().onSaveInstanceState());
427    }
428
429    private void restoreInstanceState(Bundle savedInstanceState) {
430        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
431            Log.d(Logging.LOG_TAG, "MailboxListFragment restoreInstanceState");
432        }
433        mSelectedMailboxId = savedInstanceState.getLong(BUNDLE_KEY_SELECTED_MAILBOX_ID);
434        mSavedListState = savedInstanceState.getParcelable(BUNDLE_LIST_STATE);
435    }
436
437    private void startLoading() {
438        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
439            Log.d(Logging.LOG_TAG, "MailboxListFragment startLoading");
440        }
441        mOpenRequested = false;
442        // Clear the list.  (ListFragment will show the "Loading" animation)
443        setListShown(false);
444
445        // If we've already loaded for a different account OR if we've loaded for a different
446        // mailbox, discard the previous result and load again.
447        boolean saveListState = true;
448        final LoaderManager lm = getLoaderManager();
449        long lastLoadedMailboxId = mLoadedMailboxId;
450        mLoadedMailboxId = mSelectedMailboxId;
451        if ((lastLoadedMailboxId != mSelectedMailboxId) ||
452                ((mLastLoadedAccountId != -1) && (mLastLoadedAccountId != mAccountId))) {
453            lm.destroyLoader(MAILBOX_LOADER_ID);
454            saveListState = false;
455            refreshMailboxListIfStale();
456        }
457        /**
458         * Don't use {@link LoaderManager#restartLoader(int, Bundle, LoaderCallbacks)}, because
459         * we want to reuse the previous result if the Loader has been retained.
460         */
461        lm.initLoader(MAILBOX_LOADER_ID, null,
462                new MailboxListLoaderCallbacks(saveListState, mLoadedMailboxId));
463    }
464
465    // TODO This class probably should be made static. There are many calls into the enclosing
466    // class and we need to be cautious about what we call while in these callbacks
467    private class MailboxListLoaderCallbacks implements LoaderCallbacks<Cursor> {
468        private boolean mSaveListState;
469        private final long mMailboxId;
470
471        public MailboxListLoaderCallbacks(boolean saveListState, long mailboxId) {
472            mSaveListState = saveListState;
473            mMailboxId = mailboxId;
474        }
475
476        @Override
477        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
478            if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
479                Log.d(Logging.LOG_TAG, "MailboxListFragment onCreateLoader");
480            }
481            return MailboxFragmentAdapter.createLoader(getActivity(), mAccountId, mMailboxId);
482        }
483
484        @Override
485        public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
486            if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
487                Log.d(Logging.LOG_TAG, "MailboxListFragment onLoadFinished");
488            }
489            if (mMailboxId != mLoadedMailboxId) {
490                return;
491            }
492            mLastLoadedAccountId = mAccountId;
493
494            // Save list view state (primarily scroll position)
495            final ListView lv = getListView();
496            final Parcelable listState;
497            if (!mSaveListState) {
498                listState = null; // Don't preserve list state
499            } else if (mSavedListState != null) {
500                listState = mSavedListState;
501                mSavedListState = null;
502            } else {
503                listState = lv.onSaveInstanceState();
504            }
505
506            if (cursor.getCount() == 0) {
507                // If there's no row, don't set it to the ListView.
508                // Instead use setListShown(false) to make ListFragment show progress icon.
509                mListAdapter.swapCursor(null);
510                setListShown(false);
511            } else {
512                // Set the adapter.
513                mListAdapter.swapCursor(cursor);
514                setListAdapter(mListAdapter);
515                setListShown(true);
516
517                // We want to make selection visible only when account is changing..
518                // i.e. Refresh caused by content changed events shouldn't scroll the list.
519                highlightSelectedMailbox(!mSaveListState);
520            }
521
522            // List has been reloaded; clear any drop target information
523            mDropTargetId = NO_DROP_TARGET;
524            mDropTargetView = null;
525
526            // Restore the state
527            if (listState != null) {
528                lv.onRestoreInstanceState(listState);
529            }
530
531            // Clear this for next reload triggered by content changed events.
532            mSaveListState = true;
533        }
534
535        @Override
536        public void onLoaderReset(Loader<Cursor> loader) {
537            if (mMailboxId != mLoadedMailboxId) {
538                return;
539            }
540            mListAdapter.swapCursor(null);
541        }
542    }
543
544    public void onItemClick(AdapterView<?> parent, View view, int position,
545            long idDontUseIt /* see MailboxesAdapter */ ) {
546        final long id = mListAdapter.getId(position);
547        if (mListAdapter.isAccountRow(position)) {
548            mCallback.onAccountSelected(id);
549        } else {
550            mCallback.onMailboxSelected(mAccountId, id);
551        }
552    }
553
554    public void onRefresh() {
555        if (mAccountId != -1) {
556            mRefreshManager.refreshMailboxList(mAccountId);
557        }
558    }
559
560    private void refreshMailboxListIfStale() {
561        if (mRefreshManager.isMailboxListStale(mAccountId)) {
562            mRefreshManager.refreshMailboxList(mAccountId);
563        }
564    }
565
566    /**
567     * Highlight the selected mailbox.
568     */
569    private void highlightSelectedMailbox(boolean ensureSelectionVisible) {
570        String mailboxName = "";
571        int unreadCount = 0;
572        if (mSelectedMailboxId == DEFAULT_MAILBOX_ID) {
573            // No mailbox selected
574            mListView.clearChoices();
575        } else {
576            // TODO Don't mix list view & list adapter indices. This is a recipe for disaster.
577            final int count = mListView.getCount();
578            for (int i = 0; i < count; i++) {
579                if (mListAdapter.getId(i) != mSelectedMailboxId) {
580                    continue;
581                }
582                mListView.setItemChecked(i, true);
583                if (ensureSelectionVisible) {
584                    Utility.listViewSmoothScrollToPosition(getActivity(), mListView, i);
585                }
586                mailboxName = mListAdapter.getDisplayName(mActivity, i);
587                unreadCount = mListAdapter.getUnreadCount(i);
588                break;
589            }
590        }
591        mCallback.onCurrentMailboxUpdated(mSelectedMailboxId, mailboxName, unreadCount);
592    }
593
594    // Drag & Drop handling
595
596    /**
597     * Update all of the list's child views with the proper target background (for now, orange if
598     * a valid target, except red if the trash; standard background otherwise)
599     */
600    private void updateChildViews() {
601        int itemCount = mListView.getChildCount();
602        // Lazily initialize the height of our list items
603        if (itemCount > 0 && mDragItemHeight < 0) {
604            mDragItemHeight = mListView.getChildAt(0).getHeight();
605        }
606        for (int i = 0; i < itemCount; i++) {
607            MailboxListItem item = (MailboxListItem)mListView.getChildAt(i);
608            item.setDropTargetBackground(mDragInProgress, mDragItemMailboxId);
609        }
610    }
611
612    /**
613     * Starts the timer responsible for auto-selecting mailbox items while in drag-n-drop.
614     * If there is already an active task, we first try to cancel it. There are only two
615     * reasons why a new timer may not be started. First, if we are unable to cancel a
616     * previous timer, we must assume that a new mailbox has already been loaded. Second,
617     * if the target item is not permitted to be auto selected.
618     * @param newTarget The drag target that needs to be auto selected
619     */
620    private void startDragTimer(final MailboxListItem newTarget) {
621        boolean canceledInTime = mDragTimerTask == null || stopDragTimer();
622        if (canceledInTime
623                && newTarget != null
624                && newTarget.isNavigable()
625                && newTarget.isDropTarget(mDragItemMailboxId)) {
626            mDragTimerTask = new TimerTask() {
627                @Override
628                public void run() {
629                    mActivity.runOnUiThread(new Runnable() {
630                        @Override
631                        public void run() {
632                            stopDragTimer();
633                            mCallback.onMailboxSelected(mAccountId, newTarget.mMailboxId);
634                        }
635                    });
636                }
637            };
638            sDragTimer.schedule(mDragTimerTask, AUTO_EXPAND_DELAY);
639        }
640    }
641
642    /**
643     * Stops the timer responsible for auto-selecting mailbox items while in drag-n-drop.
644     * If the timer is not active, nothing will happen.
645     * @return Whether or not the timer was interrupted. {@link TimerTask#cancel()}.
646     */
647    private boolean stopDragTimer() {
648        boolean timerInterrupted = false;
649        synchronized (sDragTimer) {
650            if (mDragTimerTask != null) {
651                timerInterrupted = mDragTimerTask.cancel();
652                mDragTimerTask = null;
653            }
654        }
655        return timerInterrupted;
656    }
657
658    /**
659     * Called when the user has dragged outside of the mailbox list area.
660     */
661    private void onDragExited() {
662        // Reset the background of the current target
663        if (mDropTargetView != null) {
664            mDropTargetView.setDropTargetBackground(mDragInProgress, mDragItemMailboxId);
665            mDropTargetView = null;
666        }
667        mDropTargetId = NO_DROP_TARGET;
668        stopDragTimer();
669        stopScrolling();
670    }
671
672    /**
673     * Called while dragging;  highlight possible drop targets, and auto scroll the list.
674     */
675    private void onDragLocation(DragEvent event) {
676        // TODO The list may be changing while in drag-n-drop; temporarily suspend drag-n-drop
677        // if the list is being updated [i.e. navigated to another mailbox]
678        if (mDragItemHeight <= 0) {
679            // This shouldn't be possible, but avoid NPE
680            Log.w(TAG, "drag item height is not set");
681            return;
682        }
683        // Find out which item we're in and highlight as appropriate
684        final int rawTouchX = (int) event.getX();
685        final int rawTouchY = (int) event.getY();
686        final int viewIndex = pointToIndex(mListView, rawTouchX, rawTouchY);
687        int targetId = viewIndex;
688        if (targetId != mDropTargetId) {
689            if (DEBUG_DRAG_DROP) {
690                Log.d(TAG, "=== Target changed; oldId: " + mDropTargetId + ", newId: " + targetId);
691            }
692            // Remove highlight the current target; if there was one
693            if (mDropTargetView != null) {
694                mDropTargetView.setDropTargetBackground(true, mDragItemMailboxId);
695                mDropTargetView = null;
696            }
697            // Get the new target mailbox view
698            final MailboxListItem newTarget = (MailboxListItem) mListView.getChildAt(viewIndex);
699            if (newTarget == null) {
700                // In any event, we're no longer dragging in the list view if newTarget is null
701                if (DEBUG_DRAG_DROP) {
702                    Log.d(TAG, "=== Drag off the list");
703                }
704                final int childCount = mListView.getChildCount();
705                if (viewIndex >= childCount) {
706                    // Touching beyond the end of the list; may happen for small lists
707                    onDragExited();
708                    return;
709                } else {
710                    // We should never get here
711                    Log.w(TAG, "null view; idx: " + viewIndex + ", cnt: " + childCount);
712                }
713            } else if (newTarget.mMailboxType == Mailbox.TYPE_TRASH) {
714                if (DEBUG_DRAG_DROP) {
715                    Log.d(TAG, "=== Trash mailbox; id: " + newTarget.mMailboxId);
716                }
717                newTarget.setBackgroundColor(sDropTrashColor);
718            } else if (newTarget.isDropTarget(mDragItemMailboxId)) {
719                if (DEBUG_DRAG_DROP) {
720                    Log.d(TAG, "=== Target mailbox; id: " + newTarget.mMailboxId);
721                }
722                newTarget.setBackgroundDrawable(sDropActiveDrawable);
723            } else {
724                if (DEBUG_DRAG_DROP) {
725                    Log.d(TAG, "=== Non-droppable mailbox; id: " + newTarget.mMailboxId);
726                }
727                newTarget.setDropTargetBackground(true, mDragItemMailboxId);
728                targetId = NO_DROP_TARGET;
729            }
730            // Save away our current position and view
731            mDropTargetId = targetId;
732            mDropTargetView = newTarget;
733            startDragTimer(newTarget);
734        }
735
736        // This is a quick-and-dirty implementation of drag-under-scroll; something like this
737        // should eventually find its way into the framework
738        int scrollDiff = rawTouchY - (mListView.getHeight() - SCROLL_ZONE_SIZE);
739        boolean scrollDown = (scrollDiff > 0);
740        boolean scrollUp = (SCROLL_ZONE_SIZE > rawTouchY);
741        if (!mTargetScrolling && scrollDown) {
742            int itemsToScroll = mListView.getCount() - mListView.getLastVisiblePosition();
743            int pixelsToScroll = (itemsToScroll + 1) * mDragItemHeight;
744            mListView.smoothScrollBy(pixelsToScroll, pixelsToScroll * SCROLL_SPEED);
745            if (DEBUG_DRAG_DROP) {
746                Log.d(TAG, "=== Start scrolling list down");
747            }
748            mTargetScrolling = true;
749        } else if (!mTargetScrolling && scrollUp) {
750            int pixelsToScroll = (mListView.getFirstVisiblePosition() + 1) * mDragItemHeight;
751            mListView.smoothScrollBy(-pixelsToScroll, pixelsToScroll * SCROLL_SPEED);
752            if (DEBUG_DRAG_DROP) {
753                Log.d(TAG, "=== Start scrolling list up");
754            }
755            mTargetScrolling = true;
756        } else if (!scrollUp && !scrollDown) {
757            stopScrolling();
758        }
759    }
760
761    /**
762     * Indicate that scrolling has stopped
763     */
764    private void stopScrolling() {
765        if (mTargetScrolling) {
766            mTargetScrolling = false;
767            if (DEBUG_DRAG_DROP) {
768                Log.d(TAG, "=== Stop scrolling list");
769            }
770            // Stop the scrolling
771            mListView.smoothScrollBy(0, 0);
772        }
773    }
774
775    private void onDragEnded() {
776        stopDragTimer();
777        if (mDragInProgress) {
778            mDragInProgress = false;
779            // Reenable updates to the view and redraw (in case it changed)
780            MailboxesAdapter.enableUpdates(true);
781            mListAdapter.notifyDataSetChanged();
782            // Stop highlighting targets
783            updateChildViews();
784            // Stop any scrolling that was going on
785            stopScrolling();
786        }
787    }
788
789    private boolean onDragStarted(DragEvent event) {
790        // We handle dropping of items with our email mime type
791        // If the mime type has a mailbox id appended, that is the mailbox of the item
792        // being draged
793        ClipDescription description = event.getClipDescription();
794        int mimeTypeCount = description.getMimeTypeCount();
795        for (int i = 0; i < mimeTypeCount; i++) {
796            String mimeType = description.getMimeType(i);
797            if (mimeType.startsWith(EmailProvider.EMAIL_MESSAGE_MIME_TYPE)) {
798                if (DEBUG_DRAG_DROP) {
799                    Log.d(TAG, "=== Drag started");
800                }
801                mDragItemMailboxId = -1;
802                // See if we find a mailbox id here
803                int dash = mimeType.lastIndexOf('-');
804                if (dash > 0) {
805                    try {
806                        mDragItemMailboxId = Long.parseLong(mimeType.substring(dash + 1));
807                    } catch (NumberFormatException e) {
808                        // Ignore; we just won't know the mailbox
809                    }
810                }
811                mDragInProgress = true;
812                // Stop the list from updating
813                MailboxesAdapter.enableUpdates(false);
814                // Update the backgrounds of our child views to highlight drop targets
815                updateChildViews();
816                return true;
817            }
818        }
819        return false;
820    }
821
822    /**
823     * Perform a "drop" action. If the user is not on top of a valid drop target, no action
824     * is performed.
825     * @return {@code true} if the drop action was performed. Otherwise {@code false}.
826     */
827    private boolean onDrop(DragEvent event) {
828        stopDragTimer();
829        stopScrolling();
830        // If we're not on a target, we're done
831        if (mDropTargetId == NO_DROP_TARGET) {
832            return false;
833        }
834        final Controller controller = Controller.getInstance(mActivity);
835        ClipData clipData = event.getClipData();
836        int count = clipData.getItemCount();
837        if (DEBUG_DRAG_DROP) {
838            Log.d(TAG, "=== Dropping " + count + " items.");
839        }
840        // Extract the messageId's to move from the ClipData (set up in MessageListItem)
841        final long[] messageIds = new long[count];
842        for (int i = 0; i < count; i++) {
843            Uri uri = clipData.getItemAt(i).getUri();
844            String msgNum = uri.getPathSegments().get(1);
845            long id = Long.parseLong(msgNum);
846            messageIds[i] = id;
847        }
848        final MailboxListItem targetItem = mDropTargetView;
849        // Call either deleteMessage or moveMessage, depending on the target
850        EmailAsyncTask.runAsyncSerial(new Runnable() {
851            @Override
852            public void run() {
853                if (targetItem.mMailboxType == Mailbox.TYPE_TRASH) {
854                    for (long messageId: messageIds) {
855                        // TODO Get this off UI thread (put in clip)
856                        Message msg = Message.restoreMessageWithId(mActivity, messageId);
857                        if (msg != null) {
858                            controller.deleteMessage(messageId, msg.mAccountKey);
859                        }
860                    }
861                } else {
862                    controller.moveMessage(messageIds, targetItem.mMailboxId);
863                }
864            }
865        });
866        return true;
867    }
868
869    @Override
870    public boolean onDrag(View view, DragEvent event) {
871        boolean result = false;
872        switch (event.getAction()) {
873            case DragEvent.ACTION_DRAG_STARTED:
874                result = onDragStarted(event);
875                break;
876            case DragEvent.ACTION_DRAG_ENTERED:
877                // The drag has entered the ListView window
878                if (DEBUG_DRAG_DROP) {
879                    Log.d(TAG, "=== Drag entered; targetId: " + mDropTargetId);
880                }
881                break;
882            case DragEvent.ACTION_DRAG_EXITED:
883                // The drag has left the building
884                if (DEBUG_DRAG_DROP) {
885                    Log.d(TAG, "=== Drag exited; targetId: " + mDropTargetId);
886                }
887                onDragExited();
888                break;
889            case DragEvent.ACTION_DRAG_ENDED:
890                // The drag is over
891                if (DEBUG_DRAG_DROP) {
892                    Log.d(TAG, "=== Drag ended");
893                }
894                onDragEnded();
895                break;
896            case DragEvent.ACTION_DRAG_LOCATION:
897                // We're moving around within our window; handle scroll, if necessary
898                onDragLocation(event);
899                break;
900            case DragEvent.ACTION_DROP:
901                // The drag item was dropped
902                if (DEBUG_DRAG_DROP) {
903                    Log.d(TAG, "=== Drop");
904                }
905                result = onDrop(event);
906                break;
907            default:
908                break;
909        }
910        return result;
911    }
912}
913