1/*
2 * Copyright (C) 2010 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.browse;
19
20import android.content.Context;
21import android.net.Uri;
22import android.os.AsyncTask;
23import android.support.v7.view.ActionMode;
24import android.view.Menu;
25import android.view.MenuInflater;
26import android.view.MenuItem;
27import android.widget.Toast;
28
29import com.android.mail.R;
30import com.android.mail.analytics.Analytics;
31import com.android.mail.providers.Account;
32import com.android.mail.providers.AccountObserver;
33import com.android.mail.providers.Conversation;
34import com.android.mail.providers.Folder;
35import com.android.mail.providers.MailAppProvider;
36import com.android.mail.providers.Settings;
37import com.android.mail.providers.UIProvider;
38import com.android.mail.providers.UIProvider.AccountCapabilities;
39import com.android.mail.providers.UIProvider.ConversationColumns;
40import com.android.mail.providers.UIProvider.FolderCapabilities;
41import com.android.mail.providers.UIProvider.FolderType;
42import com.android.mail.ui.ControllableActivity;
43import com.android.mail.ui.ConversationCheckedSet;
44import com.android.mail.ui.ConversationListCallbacks;
45import com.android.mail.ui.ConversationSetObserver;
46import com.android.mail.ui.ConversationUpdater;
47import com.android.mail.ui.DestructiveAction;
48import com.android.mail.ui.FolderOperation;
49import com.android.mail.ui.FolderSelectionDialog;
50import com.android.mail.utils.LogTag;
51import com.android.mail.utils.LogUtils;
52import com.android.mail.utils.Utils;
53import com.google.common.annotations.VisibleForTesting;
54import com.google.common.collect.Lists;
55
56import java.util.Collection;
57import java.util.List;
58
59/**
60 * A component that displays a custom view for an {@code ActionBar}'s {@code
61 * ContextMode} specific to operating on a set of conversations.
62 */
63public class SelectedConversationsActionMenu implements ActionMode.Callback,
64        ConversationSetObserver {
65
66    private static final String LOG_TAG = LogTag.getLogTag();
67
68    /**
69     * The set of conversations to display the menu for.
70     */
71    protected final ConversationCheckedSet mCheckedSet;
72
73    private final ControllableActivity mActivity;
74    private final ConversationListCallbacks mListController;
75    /**
76     * Context of the activity. A dialog requires the context of an activity rather than the global
77     * root context of the process. So mContext = mActivity.getApplicationContext() will fail.
78     */
79    private final Context mContext;
80
81    @VisibleForTesting
82    private ActionMode mActionMode;
83
84    private boolean mActivated = false;
85
86    /** Object that can update conversation state on our behalf. */
87    private final ConversationUpdater mUpdater;
88
89    private Account mAccount;
90
91    private final Folder mFolder;
92
93    private AccountObserver mAccountObserver;
94
95    private MenuItem mDiscardOutboxMenuItem;
96
97    public SelectedConversationsActionMenu(
98            ControllableActivity activity, ConversationCheckedSet checkedSet, Folder folder) {
99        mActivity = activity;
100        mListController = activity.getListHandler();
101        mCheckedSet = checkedSet;
102        mAccountObserver = new AccountObserver() {
103            @Override
104            public void onChanged(Account newAccount) {
105                mAccount = newAccount;
106            }
107        };
108        mAccount = mAccountObserver.initialize(activity.getAccountController());
109        mFolder = folder;
110        mContext = mActivity.getActivityContext();
111        mUpdater = activity.getConversationUpdater();
112    }
113
114    public boolean onActionItemClicked(MenuItem item) {
115        return onActionItemClicked(mActionMode, item);
116    }
117
118    @Override
119    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
120        boolean handled = true;
121        // If the user taps a new menu item, commit any existing destructive actions.
122        mListController.commitDestructiveActions(true);
123        final int itemId = item.getItemId();
124
125        Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, itemId,
126                "cab_mode", 0);
127
128        UndoCallback undoCallback = null;   // not applicable here (yet)
129        if (itemId == R.id.delete) {
130            LogUtils.i(LOG_TAG, "Delete selected from CAB menu");
131            performDestructiveAction(R.id.delete, undoCallback);
132        } else if (itemId == R.id.discard_drafts) {
133            LogUtils.i(LOG_TAG, "Discard drafts selected from CAB menu");
134            performDestructiveAction(R.id.discard_drafts, undoCallback);
135        } else if (itemId == R.id.discard_outbox) {
136            LogUtils.i(LOG_TAG, "Discard outbox selected from CAB menu");
137            performDestructiveAction(R.id.discard_outbox, undoCallback);
138        } else if (itemId == R.id.archive) {
139            LogUtils.i(LOG_TAG, "Archive selected from CAB menu");
140            performDestructiveAction(R.id.archive, undoCallback);
141        } else if (itemId == R.id.remove_folder) {
142            destroy(R.id.remove_folder, mCheckedSet.values(),
143                    mUpdater.getDeferredRemoveFolder(mCheckedSet.values(), mFolder, true,
144                            true, true, undoCallback));
145        } else if (itemId == R.id.mute) {
146            destroy(R.id.mute, mCheckedSet.values(), mUpdater.getBatchAction(R.id.mute,
147                    undoCallback));
148        } else if (itemId == R.id.report_spam) {
149            destroy(R.id.report_spam, mCheckedSet.values(),
150                    mUpdater.getBatchAction(R.id.report_spam, undoCallback));
151        } else if (itemId == R.id.mark_not_spam) {
152            // Currently, since spam messages are only shown in list with other spam messages,
153            // marking a message not as spam is a destructive action
154            destroy (R.id.mark_not_spam,
155                    mCheckedSet.values(), mUpdater.getBatchAction(R.id.mark_not_spam,
156                            undoCallback)) ;
157        } else if (itemId == R.id.report_phishing) {
158            destroy(R.id.report_phishing,
159                    mCheckedSet.values(), mUpdater.getBatchAction(R.id.report_phishing,
160                            undoCallback));
161        } else if (itemId == R.id.read) {
162            markConversationsRead(true);
163        } else if (itemId == R.id.unread) {
164            markConversationsRead(false);
165        } else if (itemId == R.id.star) {
166            starConversations(true);
167        } else if (itemId == R.id.toggle_read_unread) {
168            if (mActionMode != null) {
169                markConversationsRead(mActionMode.getMenu().findItem(R.id.read).isVisible());
170            }
171        } else if (itemId == R.id.remove_star) {
172            if (mFolder.isType(UIProvider.FolderType.STARRED)) {
173                LogUtils.d(LOG_TAG, "We are in a starred folder, removing the star");
174                performDestructiveAction(R.id.remove_star, undoCallback);
175            } else {
176                LogUtils.d(LOG_TAG, "Not in a starred folder.");
177                starConversations(false);
178            }
179        } else if (itemId == R.id.move_to || itemId == R.id.change_folders) {
180            boolean cantMove = false;
181            Account acct = mAccount;
182            // Special handling for virtual folders
183            if (mFolder.supportsCapability(FolderCapabilities.IS_VIRTUAL)) {
184                Uri accountUri = null;
185                for (Conversation conv: mCheckedSet.values()) {
186                    if (accountUri == null) {
187                        accountUri = conv.accountUri;
188                    } else if (!accountUri.equals(conv.accountUri)) {
189                        // Tell the user why we can't do this
190                        Toast.makeText(mContext, R.string.cant_move_or_change_labels,
191                                Toast.LENGTH_LONG).show();
192                        cantMove = true;
193                        return handled;
194                    }
195                }
196                if (!cantMove) {
197                    // Get the actual account here, so that we display its folders in the dialog
198                    acct = MailAppProvider.getAccountFromAccountUri(accountUri);
199                }
200            }
201            if (!cantMove) {
202                final FolderSelectionDialog dialog = FolderSelectionDialog.getInstance(
203                        acct, mCheckedSet.values(), true, mFolder,
204                        item.getItemId() == R.id.move_to);
205                if (dialog != null) {
206                    dialog.show(mActivity.getFragmentManager(), null);
207                }
208            }
209        } else if (itemId == R.id.move_to_inbox) {
210            new AsyncTask<Void, Void, Folder>() {
211                @Override
212                protected Folder doInBackground(final Void... params) {
213                    // Get the "move to" inbox
214                    return Utils.getFolder(mContext, mAccount.settings.moveToInbox,
215                            true /* allowHidden */);
216                }
217
218                @Override
219                protected void onPostExecute(final Folder moveToInbox) {
220                    final List<FolderOperation> ops = Lists.newArrayListWithCapacity(1);
221                    // Add inbox
222                    ops.add(new FolderOperation(moveToInbox, true));
223                    mUpdater.assignFolder(ops, mCheckedSet.values(), true,
224                            true /* showUndo */, false /* isMoveTo */);
225                }
226            }.execute((Void[]) null);
227        } else if (itemId == R.id.mark_important) {
228            markConversationsImportant(true);
229        } else if (itemId == R.id.mark_not_important) {
230            if (mFolder.supportsCapability(UIProvider.FolderCapabilities.ONLY_IMPORTANT)) {
231                performDestructiveAction(R.id.mark_not_important, undoCallback);
232            } else {
233                markConversationsImportant(false);
234            }
235        } else {
236            handled = false;
237        }
238        return handled;
239    }
240
241    /**
242     * Clear the selection and perform related UI changes to keep the state consistent.
243     */
244    private void clearChecked() {
245        mCheckedSet.clear();
246    }
247
248    /**
249     * Update the underlying list adapter and redraw the menus if necessary.
250     */
251    private void updateSelection() {
252        mUpdater.refreshConversationList();
253        if (mActionMode != null) {
254            // Calling mActivity.invalidateOptionsMenu doesn't have the correct behavior, since
255            // the action mode is not refreshed when activity's options menu is invalidated.
256            // Since we need to refresh our own menu, it is easy to call onPrepareActionMode
257            // directly.
258            onPrepareActionMode(mActionMode, mActionMode.getMenu());
259        }
260    }
261
262    private void performDestructiveAction(final int action, UndoCallback undoCallback) {
263        final Collection<Conversation> conversations = mCheckedSet.values();
264        final Settings settings = mAccount.settings;
265        final boolean showDialog;
266        // no confirmation dialog by default unless user preference or common sense dictates one
267        if (action == R.id.discard_drafts) {
268            // drafts are lost forever, so always confirm
269            showDialog = true;
270        } else if (settings != null && (action == R.id.archive || action == R.id.delete)) {
271            showDialog = (action == R.id.delete) ? settings.confirmDelete : settings.confirmArchive;
272        } else {
273            showDialog = false;
274        }
275        if (showDialog) {
276            mUpdater.makeDialogListener(action, true /* fromSelectedSet */, null /* undoCallback */);
277            final int resId;
278            if (action == R.id.delete) {
279                resId = R.plurals.confirm_delete_conversation;
280            } else if (action == R.id.discard_drafts) {
281                resId = R.plurals.confirm_discard_drafts_conversation;
282            } else {
283                resId = R.plurals.confirm_archive_conversation;
284            }
285            final CharSequence message = Utils.formatPlural(mContext, resId, conversations.size());
286            final ConfirmDialogFragment c = ConfirmDialogFragment.newInstance(message);
287            c.displayDialog(mActivity.getFragmentManager());
288        } else {
289            // No need to show the dialog, just make a destructive action and destroy the
290            // selected set immediately.
291            // TODO(viki): Stop using the deferred action here. Use the registered action.
292            destroy(action, conversations, mUpdater.getDeferredBatchAction(action, undoCallback));
293        }
294    }
295
296    /**
297     * Destroy these conversations through the conversation updater
298     * @param actionId the ID of the action: R.id.archive, R.id.delete, ...
299     * @param target conversations to destroy
300     * @param action the action that performs the destruction
301     */
302    private void destroy(int actionId, final Collection<Conversation> target,
303            final DestructiveAction action) {
304        LogUtils.i(LOG_TAG, "About to remove %d converations", target.size());
305        mUpdater.delete(actionId, target, action, true);
306    }
307
308    /**
309     * Marks the read state of currently selected conversations (<b>and</b> the backing storage)
310     * to the value provided here.
311     * @param read is true if the conversations are to be marked as read, false if they are to be
312     * marked unread.
313     */
314    private void markConversationsRead(boolean read) {
315        final Collection<Conversation> targets = mCheckedSet.values();
316        // The conversations are marked read but not viewed.
317        mUpdater.markConversationsRead(targets, read, false);
318        updateSelection();
319    }
320
321    /**
322     * Marks the important state of currently selected conversations (<b>and</b> the backing
323     * storage) to the value provided here.
324     * @param important is true if the conversations are to be marked as important, false if they
325     * are to be marked not important.
326     */
327    private void markConversationsImportant(boolean important) {
328        final Collection<Conversation> target = mCheckedSet.values();
329        final int priority = important ? UIProvider.ConversationPriority.HIGH
330                : UIProvider.ConversationPriority.LOW;
331        mUpdater.updateConversation(target, ConversationColumns.PRIORITY, priority);
332        // Update the conversations in the selection too.
333        for (final Conversation c : target) {
334            c.priority = priority;
335        }
336        updateSelection();
337    }
338
339    /**
340     * Marks the selected conversations with the star setting provided here.
341     * @param star true if you want all the conversations to have stars, false if you want to remove
342     * stars from all conversations
343     */
344    private void starConversations(boolean star) {
345        final Collection<Conversation> target = mCheckedSet.values();
346        mUpdater.updateConversation(target, ConversationColumns.STARRED, star);
347        // Update the conversations in the selection too.
348        for (final Conversation c : target) {
349            c.starred = star;
350        }
351        updateSelection();
352    }
353
354    @Override
355    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
356        mCheckedSet.addObserver(this);
357        final MenuInflater inflater = mActivity.getMenuInflater();
358        inflater.inflate(R.menu.conversation_list_selection_actions_menu, menu);
359        mActionMode = mode;
360        updateCount();
361        return true;
362    }
363
364    @Override
365    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
366        // Update the actionbar to select operations available on the current conversation.
367        final Collection<Conversation> conversations = mCheckedSet.values();
368        boolean showStar = false;
369        boolean showMarkUnread = false;
370        boolean showMarkImportant = false;
371        boolean showMarkNotSpam = false;
372        boolean showMarkAsPhishing = false;
373
374        // TODO(shahrk): Clean up these dirty calls using Utils.setMenuItemPresent(...) or
375        // in another way
376
377        for (Conversation conversation : conversations) {
378            if (!conversation.starred) {
379                showStar = true;
380            }
381            if (conversation.read) {
382                showMarkUnread = true;
383            }
384            if (!conversation.isImportant()) {
385                showMarkImportant = true;
386            }
387            if (conversation.spam) {
388                showMarkNotSpam = true;
389            }
390            if (!conversation.phishing) {
391                showMarkAsPhishing = true;
392            }
393            if (showStar && showMarkUnread && showMarkImportant && showMarkNotSpam &&
394                    showMarkAsPhishing) {
395                break;
396            }
397        }
398        final boolean canStar = mFolder != null && !mFolder.isTrash();
399        final MenuItem star = menu.findItem(R.id.star);
400        star.setVisible(showStar && canStar);
401        final MenuItem unstar = menu.findItem(R.id.remove_star);
402        unstar.setVisible(!showStar && canStar);
403        final MenuItem read = menu.findItem(R.id.read);
404        read.setVisible(!showMarkUnread);
405        final MenuItem unread = menu.findItem(R.id.unread);
406        unread.setVisible(showMarkUnread);
407
408        // We only ever show one of:
409        // 1) remove folder
410        // 2) archive
411        final MenuItem removeFolder = menu.findItem(R.id.remove_folder);
412        final MenuItem moveTo = menu.findItem(R.id.move_to);
413        final MenuItem moveToInbox = menu.findItem(R.id.move_to_inbox);
414        final boolean showRemoveFolder = mFolder != null && mFolder.isType(FolderType.DEFAULT)
415                && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)
416                && !mFolder.isProviderFolder()
417                && mAccount.supportsCapability(AccountCapabilities.ARCHIVE);
418        final boolean showMoveTo = mFolder != null
419                && mFolder.supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION);
420        final boolean showMoveToInbox = mFolder != null
421                && mFolder.supportsCapability(FolderCapabilities.ALLOWS_MOVE_TO_INBOX);
422        removeFolder.setVisible(showRemoveFolder);
423        moveTo.setVisible(showMoveTo);
424        moveToInbox.setVisible(showMoveToInbox);
425
426        final MenuItem changeFolders = menu.findItem(R.id.change_folders);
427        changeFolders.setVisible(mAccount.supportsCapability(
428                UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV));
429
430        if (mFolder != null && showRemoveFolder) {
431            removeFolder.setTitle(mActivity.getActivityContext().getString(R.string.remove_folder,
432                    mFolder.name));
433        }
434        final MenuItem archive = menu.findItem(R.id.archive);
435        if (archive != null) {
436            archive.setVisible(
437                    mAccount.supportsCapability(UIProvider.AccountCapabilities.ARCHIVE) &&
438                    mFolder.supportsCapability(FolderCapabilities.ARCHIVE));
439        }
440        final MenuItem spam = menu.findItem(R.id.report_spam);
441        spam.setVisible(!showMarkNotSpam
442                && mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_SPAM)
443                && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM));
444        final MenuItem notSpam = menu.findItem(R.id.mark_not_spam);
445        notSpam.setVisible(showMarkNotSpam &&
446                mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_SPAM) &&
447                mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM));
448        final MenuItem phishing = menu.findItem(R.id.report_phishing);
449        phishing.setVisible(showMarkAsPhishing &&
450                mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_PHISHING) &&
451                mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING));
452
453        final MenuItem mute = menu.findItem(R.id.mute);
454        if (mute != null) {
455            mute.setVisible(mAccount.supportsCapability(UIProvider.AccountCapabilities.MUTE)
456                    && (mFolder != null && mFolder.isInbox()));
457        }
458        final MenuItem markImportant = menu.findItem(R.id.mark_important);
459        markImportant.setVisible(showMarkImportant
460                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
461        final MenuItem markNotImportant = menu.findItem(R.id.mark_not_important);
462        markNotImportant.setVisible(!showMarkImportant
463                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
464
465        boolean shouldShowDiscardOutbox = mFolder != null && mFolder.isType(FolderType.OUTBOX);
466        mDiscardOutboxMenuItem = menu.findItem(R.id.discard_outbox);
467        if (mDiscardOutboxMenuItem != null) {
468            mDiscardOutboxMenuItem.setVisible(shouldShowDiscardOutbox);
469        }
470        final boolean showDelete = mFolder != null && !mFolder.isType(FolderType.OUTBOX)
471                && mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE);
472        final MenuItem trash = menu.findItem(R.id.delete);
473        trash.setVisible(showDelete);
474        // We only want to show the discard drafts menu item if we are not showing the delete menu
475        // item, and the current folder is a draft folder and the account supports discarding
476        // drafts for a conversation
477        final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() &&
478                mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS);
479        final MenuItem discardDrafts = menu.findItem(R.id.discard_drafts);
480        if (discardDrafts != null) {
481            discardDrafts.setVisible(showDiscardDrafts);
482        }
483
484        return true;
485    }
486
487    @Override
488    public void onDestroyActionMode(ActionMode mode) {
489        mActionMode = null;
490        // The action mode may have been destroyed due to this menu being deactivated, in which
491        // case resources need not be cleaned up. However, if it was destroyed while this menu is
492        // active, that implies the user hit "Done" in the top right, and resources need cleaning.
493        if (mActivated) {
494            destroy();
495            // Only commit destructive actions if the user actually pressed
496            // done; otherwise, this was handled when we toggled conversation
497            // selection state.
498            mActivity.getListHandler().commitDestructiveActions(true);
499        }
500    }
501
502    @Override
503    public void onSetPopulated(ConversationCheckedSet set) {
504        // Noop. This object can only exist while the set is non-empty.
505    }
506
507    @Override
508    public void onSetEmpty() {
509        LogUtils.d(LOG_TAG, "onSetEmpty called.");
510        destroy();
511    }
512
513    @Override
514    public void onSetChanged(ConversationCheckedSet set) {
515        // If the set is empty, the menu buttons are invalid and most like the menu will be cleaned
516        // up. Avoid making any changes to stop flickering ("Add Star" -> "Remove Star") just
517        // before hiding the menu.
518        if (set.isEmpty()) {
519            return;
520        }
521        updateCount();
522    }
523
524    /**
525     * Updates the visible count of how many conversations are selected.
526     */
527    private void updateCount() {
528        if (mActionMode != null) {
529            mActionMode.setTitle(String.format("%d", mCheckedSet.size()));
530        }
531    }
532
533    /**
534     * Activates and shows this menu (essentially starting an {@link ActionMode}) if the selected
535     * set is non-empty.
536     */
537    public void activate() {
538        if (mCheckedSet.isEmpty()) {
539            return;
540        }
541        mListController.onCabModeEntered();
542        mActivated = true;
543        if (mActionMode == null) {
544            mActivity.startSupportActionMode(this);
545        }
546    }
547
548    /**
549     * De-activates and hides the menu (essentially disabling the {@link ActionMode}), but maintains
550     * the selection conversation set, and internally updates state as necessary.
551     */
552    public void deactivate() {
553        mListController.onCabModeExited();
554        mActivated = false;
555        if (mActionMode != null) {
556            mActionMode.finish();
557        }
558    }
559
560    @VisibleForTesting
561    /**
562     * Returns true if CAB mode is active.
563     */
564    public boolean isActivated() {
565        return mActivated;
566    }
567
568    /**
569     * Destroys and cleans up the resources associated with this menu.
570     */
571    private void destroy() {
572        deactivate();
573        mCheckedSet.removeObserver(this);
574        clearChecked();
575        mUpdater.refreshConversationList();
576        if (mAccountObserver != null) {
577            mAccountObserver.unregisterAndDestroy();
578            mAccountObserver = null;
579        }
580    }
581}
582