OnePaneController.java revision dd6a7ce32c4003bd0941e2f18fcf5b80b5cd43c5
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.Fragment;
21import android.app.FragmentManager;
22import android.app.FragmentTransaction;
23import android.app.LoaderManager.LoaderCallbacks;
24import android.net.Uri;
25import android.os.Bundle;
26import android.text.Html;
27import com.android.mail.ConversationListContext;
28import com.android.mail.R;
29import com.android.mail.browse.SecureConversationViewFragment;
30import com.android.mail.providers.Account;
31import com.android.mail.providers.Conversation;
32import com.android.mail.providers.Folder;
33import com.android.mail.providers.Settings;
34import com.android.mail.providers.UIProvider;
35import com.android.mail.utils.LogUtils;
36
37/**
38 * Controller for one-pane Mail activity. One Pane is used for phones, where screen real estate is
39 * limited. This controller also does the layout, since the layout is simpler in the one pane case.
40 */
41
42// Called OnePaneActivityController in Gmail.
43public final class OnePaneController extends AbstractActivityController {
44    /** Key used to store {@link #mLastFolderListTransactionId}. */
45    private static final String FOLDER_LIST_TRANSACTION_KEY = "folder-list-transaction";
46    /** Key used to store {@link #mLastInboxConversationListTransactionId} */
47    private static final String INBOX_CONVERSATION_LIST_TRANSACTION_KEY =
48            "inbox_conversation-list-transaction";
49    /** Key used to store {@link #mLastConversationListTransactionId} */
50    private static final String CONVERSATION_LIST_TRANSACTION_KEY = "conversation-list-transaction";
51    /** Key used to store {@link #mLastConversationTransactionId}. */
52    private static final String CONVERSATION_TRANSACTION_KEY = "conversation-transaction";
53    /** Key used to store {@link #mConversationListVisible}. */
54    private static final String CONVERSATION_LIST_VISIBLE_KEY = "conversation-list-visible";
55    /** Key used to store {@link #mConversationListNeverShown}. */
56    private static final String CONVERSATION_LIST_NEVER_SHOWN_KEY = "conversation-list-never-shown";
57    /** Key to store {@link #mInbox}. */
58    private final static String SAVED_INBOX_KEY = "m-inbox";
59
60    private static final int INVALID_ID = -1;
61    private boolean mConversationListVisible = false;
62    private int mLastInboxConversationListTransactionId = INVALID_ID;
63    private int mLastConversationListTransactionId = INVALID_ID;
64    private int mLastConversationTransactionId = INVALID_ID;
65    private int mLastFolderListTransactionId = INVALID_ID;
66    private Folder mInbox;
67    /** Whether a conversation list for this account has ever been shown.*/
68    private boolean mConversationListNeverShown = true;
69
70    /**
71     * @param activity
72     * @param viewMode
73     */
74    public OnePaneController(MailActivity activity, ViewMode viewMode) {
75        super(activity, viewMode);
76    }
77
78    @Override
79    public void onRestoreInstanceState(Bundle inState) {
80        super.onRestoreInstanceState(inState);
81        // TODO(mindyp) handle saved state.
82        if (inState == null) {
83            return;
84        }
85        mLastFolderListTransactionId = inState.getInt(FOLDER_LIST_TRANSACTION_KEY, INVALID_ID);
86        mLastInboxConversationListTransactionId =
87                inState.getInt(INBOX_CONVERSATION_LIST_TRANSACTION_KEY, INVALID_ID);
88        mLastConversationListTransactionId =
89                inState.getInt(CONVERSATION_LIST_TRANSACTION_KEY, INVALID_ID);
90        mLastConversationTransactionId = inState.getInt(CONVERSATION_TRANSACTION_KEY, INVALID_ID);
91        mConversationListVisible = inState.getBoolean(CONVERSATION_LIST_VISIBLE_KEY);
92        mConversationListNeverShown = inState.getBoolean(CONVERSATION_LIST_NEVER_SHOWN_KEY);
93        mInbox = inState.getParcelable(SAVED_INBOX_KEY);
94    }
95
96    @Override
97    public void onSaveInstanceState(Bundle outState) {
98        super.onSaveInstanceState(outState);
99        // TODO(mindyp) handle saved state.
100        outState.putInt(FOLDER_LIST_TRANSACTION_KEY, mLastFolderListTransactionId);
101        outState.putInt(INBOX_CONVERSATION_LIST_TRANSACTION_KEY,
102                mLastInboxConversationListTransactionId);
103        outState.putInt(CONVERSATION_LIST_TRANSACTION_KEY, mLastConversationListTransactionId);
104        outState.putInt(CONVERSATION_TRANSACTION_KEY, mLastConversationTransactionId);
105        outState.putBoolean(CONVERSATION_LIST_VISIBLE_KEY, mConversationListVisible);
106        outState.putBoolean(CONVERSATION_LIST_NEVER_SHOWN_KEY, mConversationListNeverShown);
107        outState.putParcelable(SAVED_INBOX_KEY, mInbox);
108    }
109
110    @Override
111    public void resetActionBarIcon() {
112        final int mode = mViewMode.getMode();
113        // If the viewmode is not set, preserve existing icon.
114        if (mode == ViewMode.UNKNOWN) {
115            return;
116        }
117        if (!inInbox(mAccount, mConvListContext)
118                || mode == ViewMode.SEARCH_RESULTS_LIST
119                || mode == ViewMode.SEARCH_RESULTS_CONVERSATION
120                || mode == ViewMode.CONVERSATION
121                || mode == ViewMode.FOLDER_LIST) {
122            mActionBarView.setBackButton();
123        } else {
124            mActionBarView.removeBackButton();
125        }
126    }
127
128    /**
129     * Returns true if the user is currently in the conversation list view, viewing the default
130     * inbox.
131     * @return
132     */
133    private static boolean inInbox(final Account account, final ConversationListContext context) {
134        // If we don't have valid state, then we are not in the inbox.
135        if (account == null || context == null || context.folder == null
136                || account.settings == null) {
137            return false;
138        }
139        final Uri inboxUri = Settings.getDefaultInboxUri(account.settings);
140        return !ConversationListContext.isSearchResult(context)
141                && context.folder.uri.equals(inboxUri);
142    }
143
144    @Override
145    public void onAccountChanged(Account account) {
146        super.onAccountChanged(account);
147        mConversationListNeverShown = true;
148    }
149
150    @Override
151    public boolean onCreate(Bundle savedInstanceState) {
152        mActivity.setContentView(R.layout.one_pane_activity);
153        // The parent class sets the correct viewmode and starts the application off.
154        return super.onCreate(savedInstanceState);
155    }
156
157    @Override
158    protected boolean isConversationListVisible() {
159        return mConversationListVisible;
160    }
161
162    @Override
163    public void onViewModeChanged(int newMode) {
164        super.onViewModeChanged(newMode);
165
166        // When entering conversation list mode, hide and clean up any currently visible
167        // conversation.
168        // TODO: improve this transition
169        if (ViewMode.isListMode(newMode)) {
170            mPagerController.hide(true /* changeVisibility */);
171        }
172        // When we step away from the conversation mode, we don't have a current conversation
173        // anymore. Let's blank it out so clients calling getCurrentConversation are not misled.
174        if (!ViewMode.isConversationMode(newMode)) {
175            setCurrentConversation(null);
176        }
177        resetActionBarIcon();
178    }
179
180    @Override
181    public void showConversationList(ConversationListContext listContext) {
182        super.showConversationList(listContext);
183        enableCabMode();
184        // TODO(viki): Check if the account has been changed since the previous
185        // time.
186        if (ConversationListContext.isSearchResult(listContext)) {
187            mViewMode.enterSearchResultsListMode();
188        } else {
189            mViewMode.enterConversationListMode();
190        }
191        // TODO(viki): This account transition looks strange in two pane mode.
192        // Revisit as the app is coming together and improve the look and feel.
193        final int transition = mConversationListNeverShown
194                ? FragmentTransaction.TRANSIT_FRAGMENT_FADE
195                : FragmentTransaction.TRANSIT_FRAGMENT_OPEN;
196        Fragment conversationListFragment = ConversationListFragment.newInstance(listContext);
197
198        if (!inInbox(mAccount, mConvListContext)) {
199            // Maintain fragment transaction history so we can get back to the
200            // fragment used to launch this list.
201            mLastConversationListTransactionId = replaceFragment(conversationListFragment,
202                    transition, TAG_CONVERSATION_LIST);
203        } else {
204            // If going to the inbox, clear the folder list transaction history.
205            mInbox = listContext.folder;
206            mLastInboxConversationListTransactionId = replaceFragment(conversationListFragment,
207                    transition, TAG_CONVERSATION_LIST);
208            mLastFolderListTransactionId = INVALID_ID;
209
210            // If we ever to to the inbox, we want to unset the transation id for any other
211            // non-inbox folder.
212            mLastConversationListTransactionId = INVALID_ID;
213        }
214        mConversationListVisible = true;
215        onConversationVisibilityChanged(false);
216        onConversationListVisibilityChanged(true);
217        mConversationListNeverShown = false;
218    }
219
220    @Override
221    protected void showConversation(Conversation conversation, boolean inLoaderCallbacks) {
222        super.showConversation(conversation, inLoaderCallbacks);
223        if (conversation == null) {
224            transitionBackToConversationListMode(inLoaderCallbacks);
225            return;
226        }
227        disableCabMode();
228        if (ConversationListContext.isSearchResult(mConvListContext)) {
229            mViewMode.enterSearchResultsConversationMode();
230        } else {
231            mViewMode.enterConversationMode();
232        }
233        final FragmentManager fm = mActivity.getFragmentManager();
234        final FragmentTransaction ft = fm.beginTransaction();
235        ft.addToBackStack(null);
236        // Switching to conversation view is an incongruous transition:
237        // we are not replacing a fragment with another fragment as
238        // usual. Instead, reveal the heretofore inert conversation
239        // ViewPager and just remove the previously visible fragment
240        // e.g. conversation list, or possibly label list?).
241        final Fragment f = fm.findFragmentById(R.id.content_pane);
242        // FragmentManager#findFragmentById can return fragments that are not added to the activity.
243        // We want to make sure that we don't attempt to remove fragments that are not added to the
244        // activity, as when the transaction is popped off, the FragmentManager will attempt to
245        // readd the same fragment twice
246        if (f != null && f.isAdded()) {
247            // TODO: improve this transition
248            ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
249            ft.remove(f);
250            ft.commitAllowingStateLoss();
251        }
252        mPagerController.show(mAccount, mFolder, conversation, true /* changeVisibility */);
253        onConversationVisibilityChanged(true);
254        mConversationListVisible = false;
255        onConversationListVisibilityChanged(false);
256    }
257
258    @Override
259    public void showWaitForInitialization() {
260        super.showWaitForInitialization();
261
262        replaceFragment(WaitFragment.newInstance(mAccount),
263                FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
264    }
265
266    protected void hideWaitForInitialization() {
267        transitionToInbox();
268    }
269
270    @Override
271    public void showFolderList() {
272        if (mAccount == null) {
273            LogUtils.e(LOG_TAG, "Null account in showFolderList");
274            return;
275        }
276        // Null out the currently selected folder; we have nothing selected the
277        // first time the user enters the folder list
278        setHierarchyFolder(null);
279        mViewMode.enterFolderListMode();
280        enableCabMode();
281        mLastFolderListTransactionId = replaceFragment(
282                FolderListFragment.newInstance(null, mAccount.folderListUri, false),
283                FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_FOLDER_LIST);
284        mConversationListVisible = false;
285        onConversationVisibilityChanged(false);
286        onConversationListVisibilityChanged(false);
287    }
288
289    /**
290     * Replace the content_pane with the fragment specified here. The tag is specified so that
291     * the {@link ActivityController} can look up the fragments through the
292     * {@link android.app.FragmentManager}.
293     * @param fragment
294     * @param transition
295     * @param tag
296     * @return transaction ID returned when the transition is committed.
297     */
298    private int replaceFragment(Fragment fragment, int transition, String tag) {
299        FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction();
300        fragmentTransaction.addToBackStack(null);
301        fragmentTransaction.setTransition(transition);
302        fragmentTransaction.replace(R.id.content_pane, fragment, tag);
303        return fragmentTransaction.commitAllowingStateLoss();
304    }
305
306    /**
307     * Back works as follows:
308     * 1) If the user is in the folder list view, go back
309     * to the account default inbox.
310     * 2) If the user is in a conversation list
311     * that is not the inbox AND:
312     *  a) they got there by going through the folder
313     *  list view, go back to the folder list view.
314     *  b) they got there by using some other means (account dropdown), go back to the inbox.
315     * 3) If the user is in a conversation, go back to the conversation list they were last in.
316     * 4) If the user is in the conversation list for the default account inbox,
317     * back exits the app.
318     */
319    @Override
320    public boolean onBackPressed() {
321        final int mode = mViewMode.getMode();
322        if (mode == ViewMode.FOLDER_LIST) {
323            final Folder hierarchyFolder = getHierarchyFolder();
324            final FolderListFragment folderListFragment = getFolderListFragment();
325            if (folderListFragment != null &&
326                    folderListFragment.showingHierarchy() && hierarchyFolder != null) {
327                // If we are showing the folder list and the user is exploring
328                // the children of a single parent folder,
329                // back should display the parent folder's parent and siblings.
330                goUpFolderHierarchy(hierarchyFolder);
331            } else {
332                // We are at the topmost list of folders: go back
333                mLastFolderListTransactionId = INVALID_ID;
334                transitionToInbox();
335            }
336        } else if (mode == ViewMode.SEARCH_RESULTS_LIST) {
337            mActivity.finish();
338        } else if (mViewMode.isListMode() && !inInbox(mAccount, mConvListContext)) {
339            if (mLastFolderListTransactionId != INVALID_ID) {
340                // If the user got here by navigating via the folder list, back
341                // should bring them back to the folder list.
342                mViewMode.enterFolderListMode();
343                mActivity.getFragmentManager().popBackStack(mLastFolderListTransactionId, 0);
344            } else {
345                transitionToInbox();
346            }
347        } else if (mode == ViewMode.CONVERSATION || mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
348            transitionBackToConversationListMode(false /* inLoaderCallbacks */);
349        } else {
350            mActivity.finish();
351        }
352        mToastBar.hide(false);
353        return true;
354    }
355
356    private void goUpFolderHierarchy(Folder current) {
357        Folder top = current.parent;
358        if (top != null) {
359            setHierarchyFolder(top);
360            // Replace this fragment with a new FolderListFragment
361            // showing this folder's children if we are not already
362            // looking at the child view for this folder.
363            mLastFolderListTransactionId = replaceFragment(FolderListFragment.newInstance(
364                    top, top.childFoldersListUri, false),
365                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_FOLDER_LIST);
366            // Show the up affordance when digging into child folders.
367            mActionBarView.setBackButton();
368        } else {
369            // Otherwise, clear the selected folder and go back to whatever the
370            // last folder list displayed was.
371            showFolderList();
372        }
373    }
374
375    /**
376     * Switch to the Inbox by creating a new conversation list context that loads the inbox.
377     */
378    private void transitionToInbox() {
379        mViewMode.enterConversationListMode();
380        if (mInbox == null) {
381            loadAccountInbox();
382        } else {
383            final ConversationListContext listContext =
384                    ConversationListContext.forFolder(mAccount, mInbox);
385            // Set the correct context for what the conversation view will be now.
386            onFolderChanged(mInbox);
387            showConversationList(listContext);
388        }
389    }
390
391    @Override
392    public void onFolderSelected(Folder folder) {
393        if (folder.hasChildren && !folder.equals(getHierarchyFolder())) {
394            mViewMode.enterFolderListMode();
395            setHierarchyFolder(folder);
396            // Replace this fragment with a new FolderListFragment
397            // showing this folder's children if we are not already
398            // looking at the child view for this folder.
399            mLastFolderListTransactionId = replaceFragment(
400                    FolderListFragment.newInstance(folder, folder.childFoldersListUri, false),
401                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_FOLDER_LIST);
402            // Show the up affordance when digging into child folders.
403            mActionBarView.setBackButton();
404        } else {
405            super.onFolderSelected(folder);
406        }
407    }
408
409    private boolean isTransactionIdValid(int id) {
410        return id >= 0;
411    }
412
413    /**
414     * Up works as follows:
415     * 1) If the user is in a conversation list that is not the default account inbox,
416     * a conversation, or the folder list, up follows the rules of back.
417     * 2) If the user is in search results, up exits search
418     * mode and returns the user to whatever view they were in when they began search.
419     * 3) If the user is in the inbox, there is no up.
420     */
421    @Override
422    public boolean onUpPressed() {
423        final int mode = mViewMode.getMode();
424        if (mode == ViewMode.SEARCH_RESULTS_LIST) {
425            mActivity.finish();
426        } else if ((!inInbox(mAccount, mConvListContext) && mViewMode.isListMode())
427                || mode == ViewMode.CONVERSATION
428                || mode == ViewMode.FOLDER_LIST
429                || mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
430            // Same as go back.
431            mActivity.onBackPressed();
432        }
433        return true;
434    }
435
436    private void transitionBackToConversationListMode(boolean inLoaderCallbacks) {
437        final int mode = mViewMode.getMode();
438        enableCabMode();
439        if (mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
440            mViewMode.enterSearchResultsListMode();
441        } else {
442            mViewMode.enterConversationListMode();
443        }
444        if (isTransactionIdValid(mLastConversationListTransactionId)) {
445            safelyPopBackStack(mLastConversationListTransactionId, inLoaderCallbacks);
446        } else if (isTransactionIdValid(mLastInboxConversationListTransactionId)) {
447            safelyPopBackStack(mLastInboxConversationListTransactionId, inLoaderCallbacks);
448            onFolderChanged(mInbox);
449        } else {
450            // TODO: revisit if this block is necessary
451            final ConversationListContext listContext = ConversationListContext.forFolder(
452                    mAccount, mInbox);
453            // Set the correct context for what the conversation view will be now.
454            onFolderChanged(mInbox);
455            showConversationList(listContext);
456        }
457        mConversationListVisible = true;
458        onConversationVisibilityChanged(false);
459        onConversationListVisibilityChanged(true);
460    }
461
462    /**
463     * Pop to a specified point in the fragment back stack without causing IllegalStateExceptions
464     * from committing a fragment transaction "at the wrong time".
465     * <p>
466     * If the caller specifies that we are in
467     * the scope of an {@link LoaderCallbacks#onLoadFinished(android.content.Loader, Object)},
468     * this method will pop back in a Handler. The deferred job will also check that the Activity
469     * is in a valid state for fragment transactions, using {@link #safeToModifyFragments()}.
470     * Otherwise, this method will pop back immediately if safe. Finally, if we are not in
471     * onLoadFinished and it's not safe, this method will just ignore the request.
472     *
473     * @param transactionId back stack destination to pop to
474     * @param inLoaderCallbacks whether we are in the scope of an onLoadFinished (when fragment
475     * transactions are disallowed)
476     */
477    private void safelyPopBackStack(int transactionId, boolean inLoaderCallbacks) {
478        final PopBackStackRunnable r = new PopBackStackRunnable(transactionId);
479        if (inLoaderCallbacks) {
480            // always run deferred. ensure deferred job checks safety.
481            mHandler.post(r);
482        } else if (safeToModifyFragments()) {
483            // run now
484            r.popBackStack();
485        } else {
486            // ignore
487            LogUtils.i(LOG_TAG, "Activity has been saved; ignoring unsafe immediate request"
488                    + " to pop back stack");
489        }
490    }
491
492    @Override
493    public boolean shouldShowFirstConversation() {
494        return false;
495    }
496
497    @Override
498    public void onUndoAvailable(ToastBarOperation op) {
499        if (op != null && mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO)) {
500            final int mode = mViewMode.getMode();
501            final ConversationListFragment convList = getConversationListFragment();
502            switch (mode) {
503                case ViewMode.SEARCH_RESULTS_CONVERSATION:
504                case ViewMode.CONVERSATION:
505                    mToastBar.setConversationMode(true);
506                    mToastBar.show(
507                            getUndoClickedListener(
508                                    convList != null ? convList.getAnimatedAdapter() : null),
509                            0,
510                            Html.fromHtml(op.getDescription(mActivity.getActivityContext(),
511                                    mFolder)),
512                            true, /* showActionIcon */
513                            R.string.undo,
514                            true,  /* replaceVisibleToast */
515                            op);
516                    break;
517                case ViewMode.SEARCH_RESULTS_LIST:
518                case ViewMode.CONVERSATION_LIST:
519                    if (convList != null) {
520                        mToastBar.setConversationMode(false);
521                        mToastBar.show(
522                                getUndoClickedListener(convList.getAnimatedAdapter()),
523                                0,
524                                Html.fromHtml(op.getDescription(mActivity.getActivityContext(),
525                                        mFolder)),
526                                true, /* showActionIcon */
527                                R.string.undo,
528                                true,  /* replaceVisibleToast */
529                                op);
530                    } else {
531                        mActivity.setPendingToastOperation(op);
532                    }
533                    break;
534            }
535        }
536    }
537
538    @Override
539    public void onError(final Folder folder, boolean replaceVisibleToast) {
540        final int mode = mViewMode.getMode();
541        switch (mode) {
542            case ViewMode.SEARCH_RESULTS_LIST:
543            case ViewMode.CONVERSATION_LIST:
544                showErrorToast(folder, replaceVisibleToast);
545                break;
546            default:
547                break;
548        }
549    }
550
551    @Override
552    public String getHelpContext() {
553        final int mode = mViewMode.getMode();
554        switch (mode) {
555            case ViewMode.FOLDER_LIST:
556                return mContext.getString(R.string.one_pane_folder_list_help_context);
557        }
558        return super.getHelpContext();
559    }
560
561    private final class PopBackStackRunnable implements Runnable {
562
563        private final int mTransactionId;
564
565        public PopBackStackRunnable(int transactionId) {
566            mTransactionId = transactionId;
567        }
568
569        public void popBackStack() {
570            mActivity.getFragmentManager().popBackStack(mTransactionId, 0);
571        }
572
573        @Override
574        public void run() {
575            if (safeToModifyFragments()) {
576                popBackStack();
577            } else {
578                LogUtils.i(LOG_TAG, "Activity has been saved; ignoring unsafe deferred request"
579                        + " to pop back stack");
580            }
581        }
582    }
583
584}
585