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