1/*
2 * Copyright (C) 2012 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.ui;
19
20import android.app.SearchManager;
21import android.app.SearchableInfo;
22import android.content.ContentResolver;
23import android.content.Context;
24import android.database.Cursor;
25import android.net.Uri;
26import android.os.AsyncTask;
27import android.os.Bundle;
28import android.support.v4.view.MenuItemCompat;
29import android.support.v7.app.ActionBar;
30import android.support.v7.widget.SearchView;
31import android.support.v7.widget.SearchView.OnQueryTextListener;
32import android.support.v7.widget.SearchView.OnSuggestionListener;
33import android.text.TextUtils;
34import android.view.Menu;
35import android.view.MenuItem;
36
37import com.android.mail.ConversationListContext;
38import com.android.mail.R;
39import com.android.mail.providers.Account;
40import com.android.mail.providers.AccountObserver;
41import com.android.mail.providers.Conversation;
42import com.android.mail.providers.Folder;
43import com.android.mail.providers.FolderObserver;
44import com.android.mail.providers.SearchRecentSuggestionsProvider;
45import com.android.mail.providers.UIProvider;
46import com.android.mail.providers.UIProvider.AccountCapabilities;
47import com.android.mail.providers.UIProvider.FolderCapabilities;
48import com.android.mail.providers.UIProvider.FolderType;
49import com.android.mail.utils.LogTag;
50import com.android.mail.utils.LogUtils;
51import com.android.mail.utils.Utils;
52
53/**
54 * Controller to manage the various states of the {@link android.app.ActionBar}.
55 */
56public class ActionBarController implements ViewMode.ModeChangeListener,
57        OnQueryTextListener, OnSuggestionListener, MenuItemCompat.OnActionExpandListener {
58
59    private final Context mContext;
60
61    protected ActionBar mActionBar;
62    protected ControllableActivity mActivity;
63    protected ActivityController mController;
64    /**
65     * The current mode of the ActionBar and Activity
66     */
67    private ViewMode mViewModeController;
68
69    /**
70     * The account currently being shown
71     */
72    private Account mAccount;
73    /**
74     * The folder currently being shown
75     */
76    private Folder mFolder;
77
78    private SearchView mSearchWidget;
79    private MenuItem mSearch;
80    private MenuItem mEmptyTrashItem;
81    private MenuItem mEmptySpamItem;
82
83    /** True if the current device is a tablet, false otherwise. */
84    protected final boolean mIsOnTablet;
85    private Conversation mCurrentConversation;
86
87    public static final String LOG_TAG = LogTag.getLogTag();
88
89    private FolderObserver mFolderObserver;
90
91    /** Updates the resolver and tells it the most recent account. */
92    private final class UpdateProvider extends AsyncTask<Bundle, Void, Void> {
93        final Uri mAccount;
94        final ContentResolver mResolver;
95        public UpdateProvider(Uri account, ContentResolver resolver) {
96            mAccount = account;
97            mResolver = resolver;
98        }
99
100        @Override
101        protected Void doInBackground(Bundle... params) {
102            mResolver.call(mAccount, UIProvider.AccountCallMethods.SET_CURRENT_ACCOUNT,
103                    mAccount.toString(), params[0]);
104            return null;
105        }
106    }
107
108    private final AccountObserver mAccountObserver = new AccountObserver() {
109        @Override
110        public void onChanged(Account newAccount) {
111            updateAccount(newAccount);
112        }
113    };
114
115    public ActionBarController(Context context) {
116        mContext = context;
117        mIsOnTablet = Utils.useTabletUI(context.getResources());
118    }
119
120    public void expandSearch() {
121        if (mSearch != null) {
122            MenuItemCompat.expandActionView(mSearch);
123        }
124    }
125
126    /**
127     * Close the search view if it is expanded.
128     */
129    public void collapseSearch() {
130        if (mSearch != null) {
131            MenuItemCompat.collapseActionView(mSearch);
132        }
133    }
134
135    /**
136     * Get the search menu item.
137     */
138    protected MenuItem getSearch() {
139        return mSearch;
140    }
141
142    public boolean onCreateOptionsMenu(Menu menu) {
143        mEmptyTrashItem = menu.findItem(R.id.empty_trash);
144        mEmptySpamItem = menu.findItem(R.id.empty_spam);
145        mSearch = menu.findItem(R.id.search);
146
147        if (mSearch != null) {
148            mSearchWidget = (SearchView) MenuItemCompat.getActionView(mSearch);
149            MenuItemCompat.setOnActionExpandListener(mSearch, this);
150            SearchManager searchManager = (SearchManager) mActivity.getActivityContext()
151                    .getSystemService(Context.SEARCH_SERVICE);
152            if (searchManager != null && mSearchWidget != null) {
153                SearchableInfo info = searchManager.getSearchableInfo(mActivity.getComponentName());
154                mSearchWidget.setSearchableInfo(info);
155                mSearchWidget.setOnQueryTextListener(this);
156                mSearchWidget.setOnSuggestionListener(this);
157                mSearchWidget.setIconifiedByDefault(true);
158            }
159        }
160
161        // the menu should be displayed if the mode is known
162        return getMode() != ViewMode.UNKNOWN;
163    }
164
165    public int getOptionsMenuId() {
166        switch (getMode()) {
167            case ViewMode.UNKNOWN:
168                return R.menu.conversation_list_menu;
169            case ViewMode.CONVERSATION:
170                return R.menu.conversation_actions;
171            case ViewMode.CONVERSATION_LIST:
172                return R.menu.conversation_list_menu;
173            case ViewMode.SEARCH_RESULTS_LIST:
174                return R.menu.conversation_list_search_results_actions;
175            case ViewMode.SEARCH_RESULTS_CONVERSATION:
176                return R.menu.conversation_actions;
177            case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION:
178                return R.menu.wait_mode_actions;
179        }
180        LogUtils.wtf(LOG_TAG, "Menu requested for unknown view mode");
181        return R.menu.conversation_list_menu;
182    }
183
184    public void initialize(ControllableActivity activity, ActivityController callback,
185            ActionBar actionBar) {
186        mActionBar = actionBar;
187        mController = callback;
188        mActivity = activity;
189
190        mFolderObserver = new FolderObserver() {
191            @Override
192            public void onChanged(Folder newFolder) {
193                onFolderUpdated(newFolder);
194            }
195        };
196        // Return values are purposely discarded. Initialization happens quite early, and we don't
197        // have a valid folder, or a valid list of accounts.
198        mFolderObserver.initialize(mController);
199        updateAccount(mAccountObserver.initialize(activity.getAccountController()));
200    }
201
202    private void updateAccount(Account account) {
203        final boolean accountChanged = mAccount == null || !mAccount.uri.equals(account.uri);
204        mAccount = account;
205        if (mAccount != null && accountChanged) {
206            final ContentResolver resolver = mActivity.getActivityContext().getContentResolver();
207            final Bundle bundle = new Bundle(1);
208            bundle.putParcelable(UIProvider.SetCurrentAccountColumns.ACCOUNT, account);
209            final UpdateProvider updater = new UpdateProvider(mAccount.uri, resolver);
210            updater.execute(bundle);
211            setFolderAndAccount();
212        }
213    }
214
215    /**
216     * Called by the owner of the ActionBar to change the current folder.
217     */
218    public void setFolder(Folder folder) {
219        mFolder = folder;
220        setFolderAndAccount();
221    }
222
223    public void onDestroy() {
224        if (mFolderObserver != null) {
225            mFolderObserver.unregisterAndDestroy();
226            mFolderObserver = null;
227        }
228        mAccountObserver.unregisterAndDestroy();
229    }
230
231    @Override
232    public void onViewModeChanged(int newMode) {
233        mActivity.supportInvalidateOptionsMenu();
234        // Check if we are either on a phone, or in Conversation mode on tablet. For these, the
235        // recent folders is enabled.
236        switch (getMode()) {
237            case ViewMode.UNKNOWN:
238                break;
239            case ViewMode.CONVERSATION_LIST:
240                showNavList();
241                break;
242            case ViewMode.SEARCH_RESULTS_CONVERSATION:
243                mActionBar.setDisplayHomeAsUpEnabled(true);
244                setEmptyMode();
245                break;
246            case ViewMode.CONVERSATION:
247            case ViewMode.AD:
248                closeSearchField();
249                mActionBar.setDisplayHomeAsUpEnabled(true);
250                setEmptyMode();
251                break;
252            case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION:
253                // We want the user to be able to switch accounts while waiting for an account
254                // to sync.
255                showNavList();
256                break;
257        }
258    }
259
260    /**
261     * Close the search query entry field to avoid keyboard events, and to restore the actionbar
262     * to non-search mode.
263     */
264    private void closeSearchField() {
265        if (mSearch == null) {
266            return;
267        }
268        mSearch.collapseActionView();
269    }
270
271    protected int getMode() {
272        if (mViewModeController != null) {
273            return mViewModeController.getMode();
274        } else {
275            return ViewMode.UNKNOWN;
276        }
277    }
278
279    /**
280     * Helper function to ensure that the menu items that are prone to variable changes and race
281     * conditions are properly set to the correct visibility
282     */
283    public void validateVolatileMenuOptionVisibility() {
284        if (mEmptyTrashItem != null) {
285            mEmptyTrashItem.setVisible(mAccount != null && mFolder != null
286                    && mAccount.supportsCapability(AccountCapabilities.EMPTY_TRASH)
287                    && mFolder.isTrash() && mFolder.totalCount > 0
288                    && (mController.getConversationListCursor() == null
289                    || mController.getConversationListCursor().getCount() > 0));
290        }
291        if (mEmptySpamItem != null) {
292            mEmptySpamItem.setVisible(mAccount != null && mFolder != null
293                    && mAccount.supportsCapability(AccountCapabilities.EMPTY_SPAM)
294                    && mFolder.isType(FolderType.SPAM) && mFolder.totalCount > 0
295                    && (mController.getConversationListCursor() == null
296                    || mController.getConversationListCursor().getCount() > 0));
297        }
298    }
299
300    public boolean onPrepareOptionsMenu(Menu menu) {
301        // We start out with every option enabled. Based on the current view, we disable actions
302        // that are possible.
303        LogUtils.d(LOG_TAG, "ActionBarView.onPrepareOptionsMenu().");
304
305        if (mController.shouldHideMenuItems()) {
306            // Shortcut: hide all menu items if the drawer is shown
307            final int size = menu.size();
308
309            for (int i = 0; i < size; i++) {
310                final MenuItem item = menu.getItem(i);
311                item.setVisible(false);
312            }
313            return false;
314        }
315        validateVolatileMenuOptionVisibility();
316
317        switch (getMode()) {
318            case ViewMode.CONVERSATION:
319            case ViewMode.SEARCH_RESULTS_CONVERSATION:
320                // We update the ActionBar options when we are entering conversation view because
321                // waiting for the AbstractConversationViewFragment to do it causes duplicate icons
322                // to show up during the time between the conversation is selected and the fragment
323                // is added.
324                setConversationModeOptions(menu);
325                break;
326            case ViewMode.CONVERSATION_LIST:
327                // Show search if the account supports it
328                Utils.setMenuItemVisibility(menu, R.id.search, mAccount.supportsSearch());
329                break;
330            case ViewMode.SEARCH_RESULTS_LIST:
331                // Hide compose and search
332                Utils.setMenuItemVisibility(menu, R.id.compose, false);
333                Utils.setMenuItemVisibility(menu, R.id.search, false);
334                break;
335        }
336
337        return false;
338    }
339
340    /**
341     * Put the ActionBar in List navigation mode.
342     */
343    private void showNavList() {
344        setTitleModeFlags(ActionBar.DISPLAY_SHOW_TITLE);
345        setFolderAndAccount();
346    }
347
348    private void setTitle(String title) {
349        if (!TextUtils.equals(title, mActionBar.getTitle())) {
350            mActionBar.setTitle(title);
351        }
352    }
353
354    /**
355     * Set the actionbar mode to empty: no title, no subtitle, no custom view.
356     */
357    protected void setEmptyMode() {
358        // Disable title/subtitle and the custom view by setting the bitmask to all off.
359        setTitleModeFlags(0);
360    }
361
362    /**
363     * Removes the back button from being shown
364     */
365    public void removeBackButton() {
366        if (mActionBar == null) {
367            return;
368        }
369        // Remove the back button but continue showing an icon.
370        final int mask = ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME;
371        mActionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_HOME, mask);
372        mActionBar.setHomeButtonEnabled(false);
373    }
374
375    public void setBackButton() {
376        if (mActionBar == null) {
377            return;
378        }
379        // Show home as up, and show an icon.
380        final int mask = ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME;
381        mActionBar.setDisplayOptions(mask, mask);
382        mActionBar.setHomeButtonEnabled(true);
383    }
384
385    @Override
386    public boolean onQueryTextSubmit(String query) {
387        if (mSearch != null) {
388            MenuItemCompat.collapseActionView(mSearch);
389            mSearchWidget.setQuery("", false);
390        }
391        mController.executeSearch(query.trim());
392        return true;
393    }
394
395    @Override
396    public boolean onQueryTextChange(String newText) {
397        return false;
398    }
399
400    // Next two methods are called when search suggestions are clicked.
401    @Override
402    public boolean onSuggestionSelect(int position) {
403        return onSuggestionClick(position);
404    }
405
406    @Override
407    public boolean onSuggestionClick(int position) {
408        final Cursor c = mSearchWidget.getSuggestionsAdapter().getCursor();
409        final boolean haveValidQuery = (c != null) && c.moveToPosition(position);
410        if (!haveValidQuery) {
411            LogUtils.d(LOG_TAG, "onSuggestionClick: Couldn't get a search query");
412            // We haven't handled this query, but the default behavior will
413            // leave EXTRA_ACCOUNT un-populated, leading to a crash. So claim
414            // that we have handled the event.
415            return true;
416        }
417        collapseSearch();
418        // what is in the text field
419        String queryText = mSearchWidget.getQuery().toString();
420        // What the suggested query is
421        String query = c.getString(c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1));
422        // If the text the user typed in is a prefix of what is in the search
423        // widget suggestion query, just take the search widget suggestion
424        // query. Otherwise, it is a suffix and we want to remove matching
425        // prefix portions.
426        if (!TextUtils.isEmpty(queryText) && query.indexOf(queryText) != 0) {
427            final int queryTokenIndex = queryText
428                    .lastIndexOf(SearchRecentSuggestionsProvider.QUERY_TOKEN_SEPARATOR);
429            if (queryTokenIndex > -1) {
430                queryText = queryText.substring(0, queryTokenIndex);
431            }
432            // Since we auto-complete on each token in a query, if the query the
433            // user typed up until the last token is a substring of the
434            // suggestion they click, make sure we don't double include the
435            // query text. For example:
436            // user types john, that matches john palo alto
437            // User types john p, that matches john john palo alto
438            // Remove the first john
439            // Only do this if we have multiple query tokens.
440            if (queryTokenIndex > -1 && !TextUtils.isEmpty(query) && query.contains(queryText)
441                    && queryText.length() < query.length()) {
442                int start = query.indexOf(queryText);
443                query = query.substring(0, start) + query.substring(start + queryText.length());
444            }
445        }
446        mController.executeSearch(query.trim());
447        return true;
448    }
449
450    /**
451     * Uses the current state to update the current folder {@link #mFolder} and the current
452     * account {@link #mAccount} shown in the actionbar. Also updates the actionbar subtitle to
453     * momentarily display the unread count if it has changed.
454     */
455    private void setFolderAndAccount() {
456        // Very little can be done if the actionbar or activity is null.
457        if (mActionBar == null || mActivity == null) {
458            return;
459        }
460        if (ViewMode.isWaitingForSync(getMode())) {
461            // Account is not synced: clear title and update the subtitle.
462            setTitle("");
463            return;
464        }
465        // Check if we should be changing the actionbar at all, and back off if not.
466        final boolean isShowingFolder = mIsOnTablet || ViewMode.isListMode(getMode());
467        if (!isShowingFolder) {
468            // It isn't necessary to set the title in this case, as the title view will
469            // be hidden
470            return;
471        }
472        if (mFolder == null) {
473            // Clear the action bar title.  We don't want the app name to be shown while
474            // waiting for the folder query to finish
475            setTitle("");
476            return;
477        }
478        setTitle(mFolder.name);
479    }
480
481
482    /**
483     * Notify that the folder has changed.
484     */
485    public void onFolderUpdated(Folder folder) {
486        if (folder == null) {
487            return;
488        }
489        /** True if we are changing folders. */
490        final boolean changingFolders = (mFolder == null || !mFolder.equals(folder));
491        mFolder = folder;
492        setFolderAndAccount();
493        final ConversationListContext listContext = mController == null ? null :
494                mController.getCurrentListContext();
495        if (changingFolders && !ConversationListContext.isSearchResult(listContext)) {
496            closeSearchField();
497        }
498        // make sure that we re-validate the optional menu items
499        validateVolatileMenuOptionVisibility();
500    }
501
502    @Override
503    public boolean onMenuItemActionExpand(MenuItem item) {
504        // Do nothing. Required as part of the interface, we ar only interested in
505        // onMenuItemActionCollapse(MenuItem).
506        // Have to return true here. Unlike other callbacks, the return value here is whether
507        // we want to suppress the action (rather than consume the action). We don't want to
508        // suppress the action.
509        return true;
510    }
511
512    @Override
513    public boolean onMenuItemActionCollapse(MenuItem item) {
514        // Have to return true here. Unlike other callbacks, the return value
515        // here is whether we want to suppress the action (rather than consume the action). We
516        // don't want to suppress the action.
517        return true;
518    }
519
520    /**
521     * Sets the actionbar mode: Pass it an integer which contains each of these values, perhaps
522     * OR'd together: {@link ActionBar#DISPLAY_SHOW_CUSTOM} and
523     * {@link ActionBar#DISPLAY_SHOW_TITLE}. To disable all, pass a zero.
524     * @param enabledFlags
525     */
526    private void setTitleModeFlags(int enabledFlags) {
527        final int mask = ActionBar.DISPLAY_SHOW_TITLE | ActionBar.DISPLAY_SHOW_CUSTOM;
528        mActionBar.setDisplayOptions(enabledFlags, mask);
529    }
530
531    public void setCurrentConversation(Conversation conversation) {
532        mCurrentConversation = conversation;
533    }
534
535    //We need to do this here instead of in the fragment
536    public void setConversationModeOptions(Menu menu) {
537        if (mCurrentConversation == null) {
538            return;
539        }
540        final boolean showMarkImportant = !mCurrentConversation.isImportant();
541        Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant
542                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
543        Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant
544                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
545        final boolean isOutbox = mFolder.isType(FolderType.OUTBOX);
546        final boolean showDiscardOutbox = mFolder != null && isOutbox &&
547                mCurrentConversation.sendingState == UIProvider.ConversationSendingState.SEND_ERROR;
548        Utils.setMenuItemVisibility(menu, R.id.discard_outbox, showDiscardOutbox);
549        final boolean showDelete = !isOutbox && mFolder != null &&
550                mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE);
551        Utils.setMenuItemVisibility(menu, R.id.delete, showDelete);
552        // We only want to show the discard drafts menu item if we are not showing the delete menu
553        // item, and the current folder is a draft folder and the account supports discarding
554        // drafts for a conversation
555        final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() &&
556                mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS);
557        Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts);
558        final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE)
559                && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE)
560                && !mFolder.isTrash();
561        Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible);
562        Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null
563                && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)
564                && !mFolder.isProviderFolder()
565                && mAccount.supportsCapability(AccountCapabilities.ARCHIVE));
566        Utils.setMenuItemVisibility(menu, R.id.move_to, mFolder != null
567                && mFolder.supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION));
568        Utils.setMenuItemVisibility(menu, R.id.move_to_inbox, mFolder != null
569                && mFolder.supportsCapability(FolderCapabilities.ALLOWS_MOVE_TO_INBOX));
570        Utils.setMenuItemVisibility(menu, R.id.change_folders, mAccount.supportsCapability(
571                UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV));
572
573        final MenuItem removeFolder = menu.findItem(R.id.remove_folder);
574        if (mFolder != null && removeFolder != null) {
575            removeFolder.setTitle(mActivity.getApplicationContext().getString(
576                    R.string.remove_folder, mFolder.name));
577        }
578        Utils.setMenuItemVisibility(menu, R.id.report_spam,
579                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
580                        && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM)
581                        && !mCurrentConversation.spam);
582        Utils.setMenuItemVisibility(menu, R.id.mark_not_spam,
583                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
584                        && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM)
585                        && mCurrentConversation.spam);
586        Utils.setMenuItemVisibility(menu, R.id.report_phishing,
587                mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null
588                        && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING)
589                        && !mCurrentConversation.phishing);
590        Utils.setMenuItemVisibility(menu, R.id.mute,
591                        mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null
592                        && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)
593                        && !mCurrentConversation.muted);
594    }
595
596    public void setViewModeController(ViewMode viewModeController) {
597        mViewModeController = viewModeController;
598        mViewModeController.addListener(this);
599    }
600
601    public Context getContext() {
602        return mContext;
603    }
604}
605