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.ConversationListCallbacks;
44import com.android.mail.ui.ConversationSelectionSet;
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 ConversationSelectionSet mSelectionSet;
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, ConversationSelectionSet selectionSet, Folder folder) {
99        mActivity = activity;
100        mListController = activity.getListHandler();
101        mSelectionSet = selectionSet;
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    @Override
115    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
116        boolean handled = true;
117        // If the user taps a new menu item, commit any existing destructive actions.
118        mListController.commitDestructiveActions(true);
119        final int itemId = item.getItemId();
120
121        Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, itemId,
122                "cab_mode", 0);
123
124        UndoCallback undoCallback = null;   // not applicable here (yet)
125        if (itemId == R.id.delete) {
126            LogUtils.i(LOG_TAG, "Delete selected from CAB menu");
127            performDestructiveAction(R.id.delete, undoCallback);
128        } else if (itemId == R.id.discard_drafts) {
129            LogUtils.i(LOG_TAG, "Discard drafts selected from CAB menu");
130            performDestructiveAction(R.id.discard_drafts, undoCallback);
131        } else if (itemId == R.id.discard_outbox) {
132            LogUtils.i(LOG_TAG, "Discard outbox selected from CAB menu");
133            performDestructiveAction(R.id.discard_outbox, undoCallback);
134        } else if (itemId == R.id.archive) {
135            LogUtils.i(LOG_TAG, "Archive selected from CAB menu");
136            performDestructiveAction(R.id.archive, undoCallback);
137        } else if (itemId == R.id.remove_folder) {
138            destroy(R.id.remove_folder, mSelectionSet.values(),
139                    mUpdater.getDeferredRemoveFolder(mSelectionSet.values(), mFolder, true,
140                            true, true, undoCallback));
141        } else if (itemId == R.id.mute) {
142            destroy(R.id.mute, mSelectionSet.values(), mUpdater.getBatchAction(R.id.mute,
143                    undoCallback));
144        } else if (itemId == R.id.report_spam) {
145            destroy(R.id.report_spam, mSelectionSet.values(),
146                    mUpdater.getBatchAction(R.id.report_spam, undoCallback));
147        } else if (itemId == R.id.mark_not_spam) {
148            // Currently, since spam messages are only shown in list with other spam messages,
149            // marking a message not as spam is a destructive action
150            destroy (R.id.mark_not_spam,
151                    mSelectionSet.values(), mUpdater.getBatchAction(R.id.mark_not_spam,
152                            undoCallback)) ;
153        } else if (itemId == R.id.report_phishing) {
154            destroy(R.id.report_phishing,
155                    mSelectionSet.values(), mUpdater.getBatchAction(R.id.report_phishing,
156                            undoCallback));
157        } else if (itemId == R.id.read) {
158            markConversationsRead(true);
159        } else if (itemId == R.id.unread) {
160            markConversationsRead(false);
161        } else if (itemId == R.id.star) {
162            starConversations(true);
163        } else if (itemId == R.id.remove_star) {
164            if (mFolder.isType(UIProvider.FolderType.STARRED)) {
165                LogUtils.d(LOG_TAG, "We are in a starred folder, removing the star");
166                performDestructiveAction(R.id.remove_star, undoCallback);
167            } else {
168                LogUtils.d(LOG_TAG, "Not in a starred folder.");
169                starConversations(false);
170            }
171        } else if (itemId == R.id.move_to || itemId == R.id.change_folders) {
172            boolean cantMove = false;
173            Account acct = mAccount;
174            // Special handling for virtual folders
175            if (mFolder.supportsCapability(FolderCapabilities.IS_VIRTUAL)) {
176                Uri accountUri = null;
177                for (Conversation conv: mSelectionSet.values()) {
178                    if (accountUri == null) {
179                        accountUri = conv.accountUri;
180                    } else if (!accountUri.equals(conv.accountUri)) {
181                        // Tell the user why we can't do this
182                        Toast.makeText(mContext, R.string.cant_move_or_change_labels,
183                                Toast.LENGTH_LONG).show();
184                        cantMove = true;
185                        return handled;
186                    }
187                }
188                if (!cantMove) {
189                    // Get the actual account here, so that we display its folders in the dialog
190                    acct = MailAppProvider.getAccountFromAccountUri(accountUri);
191                }
192            }
193            if (!cantMove) {
194                final FolderSelectionDialog dialog = FolderSelectionDialog.getInstance(
195                        acct, mSelectionSet.values(), true, mFolder,
196                        item.getItemId() == R.id.move_to);
197                if (dialog != null) {
198                    dialog.show(mActivity.getFragmentManager(), null);
199                }
200            }
201        } else if (itemId == R.id.move_to_inbox) {
202            new AsyncTask<Void, Void, Folder>() {
203                @Override
204                protected Folder doInBackground(final Void... params) {
205                    // Get the "move to" inbox
206                    return Utils.getFolder(mContext, mAccount.settings.moveToInbox,
207                            true /* allowHidden */);
208                }
209
210                @Override
211                protected void onPostExecute(final Folder moveToInbox) {
212                    final List<FolderOperation> ops = Lists.newArrayListWithCapacity(1);
213                    // Add inbox
214                    ops.add(new FolderOperation(moveToInbox, true));
215                    mUpdater.assignFolder(ops, mSelectionSet.values(), true,
216                            true /* showUndo */, false /* isMoveTo */);
217                }
218            }.execute((Void[]) null);
219        } else if (itemId == R.id.mark_important) {
220            markConversationsImportant(true);
221        } else if (itemId == R.id.mark_not_important) {
222            if (mFolder.supportsCapability(UIProvider.FolderCapabilities.ONLY_IMPORTANT)) {
223                performDestructiveAction(R.id.mark_not_important, undoCallback);
224            } else {
225                markConversationsImportant(false);
226            }
227        } else {
228            handled = false;
229        }
230        return handled;
231    }
232
233    /**
234     * Clear the selection and perform related UI changes to keep the state consistent.
235     */
236    private void clearSelection() {
237        mSelectionSet.clear();
238    }
239
240    /**
241     * Update the underlying list adapter and redraw the menus if necessary.
242     */
243    private void updateSelection() {
244        mUpdater.refreshConversationList();
245        if (mActionMode != null) {
246            // Calling mActivity.invalidateOptionsMenu doesn't have the correct behavior, since
247            // the action mode is not refreshed when activity's options menu is invalidated.
248            // Since we need to refresh our own menu, it is easy to call onPrepareActionMode
249            // directly.
250            onPrepareActionMode(mActionMode, mActionMode.getMenu());
251        }
252    }
253
254    private void performDestructiveAction(final int action, UndoCallback undoCallback) {
255        final Collection<Conversation> conversations = mSelectionSet.values();
256        final Settings settings = mAccount.settings;
257        final boolean showDialog;
258        // no confirmation dialog by default unless user preference or common sense dictates one
259        if (action == R.id.discard_drafts) {
260            // drafts are lost forever, so always confirm
261            showDialog = true;
262        } else if (settings != null && (action == R.id.archive || action == R.id.delete)) {
263            showDialog = (action == R.id.delete) ? settings.confirmDelete : settings.confirmArchive;
264        } else {
265            showDialog = false;
266        }
267        if (showDialog) {
268            mUpdater.makeDialogListener(action, true /* fromSelectedSet */, null /* undoCallback */);
269            final int resId;
270            if (action == R.id.delete) {
271                resId = R.plurals.confirm_delete_conversation;
272            } else if (action == R.id.discard_drafts) {
273                resId = R.plurals.confirm_discard_drafts_conversation;
274            } else {
275                resId = R.plurals.confirm_archive_conversation;
276            }
277            final CharSequence message = Utils.formatPlural(mContext, resId, conversations.size());
278            final ConfirmDialogFragment c = ConfirmDialogFragment.newInstance(message);
279            c.displayDialog(mActivity.getFragmentManager());
280        } else {
281            // No need to show the dialog, just make a destructive action and destroy the
282            // selected set immediately.
283            // TODO(viki): Stop using the deferred action here. Use the registered action.
284            destroy(action, conversations, mUpdater.getDeferredBatchAction(action, undoCallback));
285        }
286    }
287
288    /**
289     * Destroy these conversations through the conversation updater
290     * @param actionId the ID of the action: R.id.archive, R.id.delete, ...
291     * @param target conversations to destroy
292     * @param action the action that performs the destruction
293     */
294    private void destroy(int actionId, final Collection<Conversation> target,
295            final DestructiveAction action) {
296        LogUtils.i(LOG_TAG, "About to remove %d converations", target.size());
297        mUpdater.delete(actionId, target, action, true);
298    }
299
300    /**
301     * Marks the read state of currently selected conversations (<b>and</b> the backing storage)
302     * to the value provided here.
303     * @param read is true if the conversations are to be marked as read, false if they are to be
304     * marked unread.
305     */
306    private void markConversationsRead(boolean read) {
307        final Collection<Conversation> targets = mSelectionSet.values();
308        // The conversations are marked read but not viewed.
309        mUpdater.markConversationsRead(targets, read, false);
310        updateSelection();
311    }
312
313    /**
314     * Marks the important state of currently selected conversations (<b>and</b> the backing
315     * storage) to the value provided here.
316     * @param important is true if the conversations are to be marked as important, false if they
317     * are to be marked not important.
318     */
319    private void markConversationsImportant(boolean important) {
320        final Collection<Conversation> target = mSelectionSet.values();
321        final int priority = important ? UIProvider.ConversationPriority.HIGH
322                : UIProvider.ConversationPriority.LOW;
323        mUpdater.updateConversation(target, ConversationColumns.PRIORITY, priority);
324        // Update the conversations in the selection too.
325        for (final Conversation c : target) {
326            c.priority = priority;
327        }
328        updateSelection();
329    }
330
331    /**
332     * Marks the selected conversations with the star setting provided here.
333     * @param star true if you want all the conversations to have stars, false if you want to remove
334     * stars from all conversations
335     */
336    private void starConversations(boolean star) {
337        final Collection<Conversation> target = mSelectionSet.values();
338        mUpdater.updateConversation(target, ConversationColumns.STARRED, star);
339        // Update the conversations in the selection too.
340        for (final Conversation c : target) {
341            c.starred = star;
342        }
343        updateSelection();
344    }
345
346    @Override
347    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
348        mSelectionSet.addObserver(this);
349        final MenuInflater inflater = mActivity.getMenuInflater();
350        inflater.inflate(R.menu.conversation_list_selection_actions_menu, menu);
351        mActionMode = mode;
352        return true;
353    }
354
355    @Override
356    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
357        // Update the actionbar to select operations available on the current conversation.
358        final Collection<Conversation> conversations = mSelectionSet.values();
359        boolean showStar = false;
360        boolean showMarkUnread = false;
361        boolean showMarkImportant = false;
362        boolean showMarkNotSpam = false;
363        boolean showMarkAsPhishing = false;
364
365        // TODO(shahrk): Clean up these dirty calls using Utils.setMenuItemVisibility(...) or
366        // in another way
367
368        for (Conversation conversation : conversations) {
369            if (!conversation.starred) {
370                showStar = true;
371            }
372            if (conversation.read) {
373                showMarkUnread = true;
374            }
375            if (!conversation.isImportant()) {
376                showMarkImportant = true;
377            }
378            if (conversation.spam) {
379                showMarkNotSpam = true;
380            }
381            if (!conversation.phishing) {
382                showMarkAsPhishing = true;
383            }
384            if (showStar && showMarkUnread && showMarkImportant && showMarkNotSpam &&
385                    showMarkAsPhishing) {
386                break;
387            }
388        }
389        final MenuItem star = menu.findItem(R.id.star);
390        star.setVisible(showStar);
391        final MenuItem unstar = menu.findItem(R.id.remove_star);
392        unstar.setVisible(!showStar);
393        final MenuItem read = menu.findItem(R.id.read);
394        read.setVisible(!showMarkUnread);
395        final MenuItem unread = menu.findItem(R.id.unread);
396        unread.setVisible(showMarkUnread);
397        // We only ever show one of:
398        // 1) remove folder
399        // 2) archive
400        final MenuItem removeFolder = menu.findItem(R.id.remove_folder);
401        final MenuItem moveTo = menu.findItem(R.id.move_to);
402        final MenuItem moveToInbox = menu.findItem(R.id.move_to_inbox);
403        final boolean showRemoveFolder = mFolder != null && mFolder.isType(FolderType.DEFAULT)
404                && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)
405                && !mFolder.isProviderFolder()
406                && mAccount.supportsCapability(AccountCapabilities.ARCHIVE);
407        final boolean showMoveTo = mFolder != null
408                && mFolder.supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION);
409        final boolean showMoveToInbox = mFolder != null
410                && mFolder.supportsCapability(FolderCapabilities.ALLOWS_MOVE_TO_INBOX);
411        removeFolder.setVisible(showRemoveFolder);
412        moveTo.setVisible(showMoveTo);
413        moveToInbox.setVisible(showMoveToInbox);
414
415        final MenuItem changeFolders = menu.findItem(R.id.change_folders);
416        changeFolders.setVisible(mAccount.supportsCapability(
417                UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV));
418
419        if (mFolder != null && showRemoveFolder) {
420            removeFolder.setTitle(mActivity.getActivityContext().getString(R.string.remove_folder,
421                    mFolder.name));
422        }
423        final MenuItem archive = menu.findItem(R.id.archive);
424        if (archive != null) {
425            archive.setVisible(
426                    mAccount.supportsCapability(UIProvider.AccountCapabilities.ARCHIVE) &&
427                    mFolder.supportsCapability(FolderCapabilities.ARCHIVE));
428        }
429        final MenuItem spam = menu.findItem(R.id.report_spam);
430        spam.setVisible(!showMarkNotSpam
431                && mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_SPAM)
432                && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM));
433        final MenuItem notSpam = menu.findItem(R.id.mark_not_spam);
434        notSpam.setVisible(showMarkNotSpam &&
435                mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_SPAM) &&
436                mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM));
437        final MenuItem phishing = menu.findItem(R.id.report_phishing);
438        phishing.setVisible(showMarkAsPhishing &&
439                mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_PHISHING) &&
440                mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING));
441
442        final MenuItem mute = menu.findItem(R.id.mute);
443        if (mute != null) {
444            mute.setVisible(mAccount.supportsCapability(UIProvider.AccountCapabilities.MUTE)
445                    && (mFolder != null && mFolder.isInbox()));
446        }
447        final MenuItem markImportant = menu.findItem(R.id.mark_important);
448        markImportant.setVisible(showMarkImportant
449                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
450        final MenuItem markNotImportant = menu.findItem(R.id.mark_not_important);
451        markNotImportant.setVisible(!showMarkImportant
452                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
453
454        boolean shouldShowDiscardOutbox = mFolder != null && mFolder.isType(FolderType.OUTBOX);
455        mDiscardOutboxMenuItem = menu.findItem(R.id.discard_outbox);
456        if (mDiscardOutboxMenuItem != null) {
457            mDiscardOutboxMenuItem.setVisible(shouldShowDiscardOutbox);
458            mDiscardOutboxMenuItem.setEnabled(shouldEnableDiscardOutbox(conversations));
459        }
460        final boolean showDelete = mFolder != null && !mFolder.isType(FolderType.OUTBOX)
461                && mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE);
462        final MenuItem trash = menu.findItem(R.id.delete);
463        trash.setVisible(showDelete);
464        // We only want to show the discard drafts menu item if we are not showing the delete menu
465        // item, and the current folder is a draft folder and the account supports discarding
466        // drafts for a conversation
467        final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() &&
468                mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS);
469        final MenuItem discardDrafts = menu.findItem(R.id.discard_drafts);
470        if (discardDrafts != null) {
471            discardDrafts.setVisible(showDiscardDrafts);
472        }
473
474        return true;
475    }
476
477    private boolean shouldEnableDiscardOutbox(Collection<Conversation> conversations) {
478        boolean shouldEnableDiscardOutbox = true;
479        // Java should be smart enough to realize that once showDiscardOutbox becomes false it can
480        // just skip everything remaining in the for-loop..
481        for (Conversation conv : conversations) {
482            shouldEnableDiscardOutbox &=
483                    conv.sendingState != UIProvider.ConversationSendingState.SENDING &&
484                    conv.sendingState != UIProvider.ConversationSendingState.RETRYING;
485        }
486        return shouldEnableDiscardOutbox;
487    }
488
489    @Override
490    public void onDestroyActionMode(ActionMode mode) {
491        mActionMode = null;
492        // The action mode may have been destroyed due to this menu being deactivated, in which
493        // case resources need not be cleaned up. However, if it was destroyed while this menu is
494        // active, that implies the user hit "Done" in the top right, and resources need cleaning.
495        if (mActivated) {
496            destroy();
497            // Only commit destructive actions if the user actually pressed
498            // done; otherwise, this was handled when we toggled conversation
499            // selection state.
500            mActivity.getListHandler().commitDestructiveActions(true);
501        }
502    }
503
504    @Override
505    public void onSetPopulated(ConversationSelectionSet set) {
506        // Noop. This object can only exist while the set is non-empty.
507    }
508
509    @Override
510    public void onSetEmpty() {
511        LogUtils.d(LOG_TAG, "onSetEmpty called.");
512        destroy();
513    }
514
515    @Override
516    public void onSetChanged(ConversationSelectionSet set) {
517        // If the set is empty, the menu buttons are invalid and most like the menu will be cleaned
518        // up. Avoid making any changes to stop flickering ("Add Star" -> "Remove Star") just
519        // before hiding the menu.
520        if (set.isEmpty()) {
521            return;
522        }
523
524        if (mFolder.isType(FolderType.OUTBOX) && mDiscardOutboxMenuItem != null) {
525            mDiscardOutboxMenuItem.setEnabled(shouldEnableDiscardOutbox(set.values()));
526        }
527    }
528
529    /**
530     * Activates and shows this menu (essentially starting an {@link ActionMode}) if the selected
531     * set is non-empty.
532     */
533    public void activate() {
534        if (mSelectionSet.isEmpty()) {
535            return;
536        }
537        mListController.onCabModeEntered();
538        mActivated = true;
539        if (mActionMode == null) {
540            mActivity.startSupportActionMode(this);
541        }
542    }
543
544    /**
545     * De-activates and hides the menu (essentially disabling the {@link ActionMode}), but maintains
546     * the selection conversation set, and internally updates state as necessary.
547     */
548    public void deactivate() {
549        mListController.onCabModeExited();
550
551        if (mActionMode != null) {
552            mActivated = false;
553            mActionMode.finish();
554        }
555    }
556
557    @VisibleForTesting
558    public boolean isActivated() {
559        return mActivated;
560    }
561
562    /**
563     * Destroys and cleans up the resources associated with this menu.
564     */
565    private void destroy() {
566        deactivate();
567        mSelectionSet.removeObserver(this);
568        clearSelection();
569        mUpdater.refreshConversationList();
570        if (mAccountObserver != null) {
571            mAccountObserver.unregisterAndDestroy();
572            mAccountObserver = null;
573        }
574    }
575}
576