AbstractConversationViewFragment.java revision f4fce1227d8b49f45e6569f1590565f2df9e8d6e
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.Activity;
21import android.app.Fragment;
22import android.app.LoaderManager;
23import android.content.Context;
24import android.content.CursorLoader;
25import android.content.Loader;
26import android.database.Cursor;
27import android.database.DataSetObservable;
28import android.database.DataSetObserver;
29import android.net.Uri;
30import android.os.Bundle;
31import android.view.Menu;
32import android.view.MenuInflater;
33import android.view.MenuItem;
34
35import com.android.mail.ContactInfo;
36import com.android.mail.ContactInfoSource;
37import com.android.mail.FormattedDateBuilder;
38import com.android.mail.R;
39import com.android.mail.SenderInfoLoader;
40import com.android.mail.browse.MessageCursor;
41import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController;
42import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
43import com.android.mail.browse.MessageCursor.ConversationController;
44import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
45import com.android.mail.providers.Account;
46import com.android.mail.providers.AccountObserver;
47import com.android.mail.providers.Address;
48import com.android.mail.providers.Conversation;
49import com.android.mail.providers.Folder;
50import com.android.mail.providers.ListParams;
51import com.android.mail.providers.UIProvider;
52import com.android.mail.providers.UIProvider.AccountCapabilities;
53import com.android.mail.providers.UIProvider.FolderCapabilities;
54import com.android.mail.utils.LogTag;
55import com.android.mail.utils.LogUtils;
56import com.android.mail.utils.Utils;
57import com.google.common.collect.ImmutableMap;
58import com.google.common.collect.Maps;
59
60import java.util.Map;
61import java.util.Set;
62
63public abstract class AbstractConversationViewFragment extends Fragment implements
64        ConversationController, ConversationAccountController, MessageHeaderViewCallbacks,
65        ConversationViewHeaderCallbacks {
66
67    private static final String ARG_ACCOUNT = "account";
68    public static final String ARG_CONVERSATION = "conversation";
69    private static final String ARG_FOLDER = "folder";
70    private static final String LOG_TAG = LogTag.getLogTag();
71    protected static final int MESSAGE_LOADER = 0;
72    protected static final int CONTACT_LOADER = 1;
73    protected ControllableActivity mActivity;
74    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
75    protected FormattedDateBuilder mDateBuilder;
76    private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks();
77    private MenuItem mChangeFoldersMenuItem;
78    protected Conversation mConversation;
79    protected Folder mFolder;
80    protected String mBaseUri;
81    protected Account mAccount;
82    protected final Map<String, Address> mAddressCache = Maps.newHashMap();
83    protected boolean mEnableContentReadySignal;
84    private MessageCursor mCursor;
85    private Context mContext;
86    public boolean mUserVisible;
87    private final AccountObserver mAccountObserver = new AccountObserver() {
88        @Override
89        public void onChanged(Account newAccount) {
90            mAccount = newAccount;
91            onAccountChanged();
92        }
93    };
94
95    public static Bundle makeBasicArgs(Account account, Folder folder) {
96        Bundle args = new Bundle();
97        args.putParcelable(ARG_ACCOUNT, account);
98        args.putParcelable(ARG_FOLDER, folder);
99        return args;
100    }
101
102    /**
103     * Constructor needs to be public to handle orientation changes and activity
104     * lifecycle events.
105     */
106    public AbstractConversationViewFragment() {
107        super();
108    }
109
110    /**
111     * Subclasses must override, since this depends on how many messages are
112     * shown in the conversation view.
113     */
114    protected abstract void markUnread();
115
116    /**
117     * Subclasses must override this, since they may want to display a single or
118     * many messages related to this conversation.
119     */
120    protected abstract void onMessageCursorLoadFinished(Loader<Cursor> loader, Cursor data,
121            boolean wasNull, boolean messageCursorChanged);
122
123    /**
124     * Subclasses must override this, since they may want to display a single or
125     * many messages related to this conversation.
126     */
127    @Override
128    public abstract void onConversationViewHeaderHeightChange(int newHeight);
129
130    public abstract void onUserVisibleHintChanged();
131
132    /**
133     * Subclasses must override this.
134     */
135    protected abstract void onAccountChanged();
136
137    @Override
138    public void onCreate(Bundle savedState) {
139        super.onCreate(savedState);
140
141        final Bundle args = getArguments();
142        mAccount = args.getParcelable(ARG_ACCOUNT);
143        mConversation = args.getParcelable(ARG_CONVERSATION);
144        mFolder = args.getParcelable(ARG_FOLDER);
145        // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
146        // Below JB, try to speed up initial render by having the webview do supplemental draws to
147        // custom a software canvas.
148        // TODO(mindyp):
149        //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
150        // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
151        // animation that immediately runs on page load. The app uses this as a signal that the
152        // content is loaded and ready to draw, since WebView delays firing this event until the
153        // layers are composited and everything is ready to draw.
154        // This signal does not seem to be reliable, so just use the old method for now.
155        mEnableContentReadySignal = false; //Utils.isRunningJellybeanOrLater();
156        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
157        // Not really, we just want to get a crack to store a reference to the change_folder item
158        setHasOptionsMenu(true);
159    }
160
161    @Override
162    public void onActivityCreated(Bundle savedInstanceState) {
163        super.onActivityCreated(savedInstanceState);
164        Activity activity = getActivity();
165        if (!(activity instanceof ControllableActivity)) {
166            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
167                    + "create it. Cannot proceed.");
168        }
169        if (activity.isFinishing()) {
170            // Activity is finishing, just bail.
171            return;
172        }
173        mActivity = (ControllableActivity) getActivity();
174        mContext = activity.getApplicationContext();
175        mDateBuilder = new FormattedDateBuilder((Context) mActivity);
176        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
177    }
178
179    @Override
180    public ConversationUpdater getListController() {
181        final ControllableActivity activity = (ControllableActivity) getActivity();
182        return activity != null ? activity.getConversationUpdater() : null;
183    }
184
185    public Context getContext() {
186        return mContext;
187    }
188
189    public Conversation getConversation() {
190        return mConversation;
191    }
192
193    @Override
194    public MessageCursor getMessageCursor() {
195        return mCursor;
196    }
197
198    public MessageLoaderCallbacks getMessageLoaderCallbacks() {
199        return mMessageLoaderCallbacks;
200    }
201
202    public ContactLoaderCallbacks getContactInfoSource() {
203        return mContactLoaderCallbacks;
204    }
205
206    @Override
207    public Account getAccount() {
208        return mAccount;
209    }
210
211    @Override
212    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
213        super.onCreateOptionsMenu(menu, inflater);
214        mChangeFoldersMenuItem = menu.findItem(R.id.change_folder);
215    }
216
217    @Override
218    public void onPrepareOptionsMenu(Menu menu) {
219        super.onPrepareOptionsMenu(menu);
220        final boolean showMarkImportant = !mConversation.isImportant();
221        Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant
222                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
223        Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant
224                && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT));
225        final boolean showDelete = mFolder != null &&
226                mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE);
227        Utils.setMenuItemVisibility(menu, R.id.delete, showDelete);
228        // We only want to show the discard drafts menu item if we are not showing the delete menu
229        // item, and the current folder is a draft folder and the account supports discarding
230        // drafts for a conversation
231        final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() &&
232                mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS);
233        Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts);
234        final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE)
235                && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE)
236                && !mFolder.isTrash();
237        Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible);
238        Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null
239                && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)
240                && !mFolder.isProviderFolder());
241        final MenuItem removeFolder = menu.findItem(R.id.remove_folder);
242        if (removeFolder != null) {
243            removeFolder.setTitle(getString(R.string.remove_folder, mFolder.name));
244        }
245        Utils.setMenuItemVisibility(menu, R.id.report_spam,
246                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
247                        && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM)
248                        && !mConversation.spam);
249        Utils.setMenuItemVisibility(menu, R.id.mark_not_spam,
250                mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null
251                        && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM)
252                        && mConversation.spam);
253        Utils.setMenuItemVisibility(menu, R.id.report_phishing,
254                mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null
255                        && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING)
256                        && !mConversation.phishing);
257        Utils.setMenuItemVisibility(menu, R.id.mute,
258                        mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null
259                        && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)
260                        && !mConversation.muted);
261    }
262
263    @Override
264    public boolean onOptionsItemSelected(MenuItem item) {
265        boolean handled = false;
266        switch (item.getItemId()) {
267            case R.id.inside_conversation_unread:
268                markUnread();
269                handled = true;
270                break;
271        }
272        return handled;
273    }
274
275    // BEGIN conversation header callbacks
276    @Override
277    public void onFoldersClicked() {
278        if (mChangeFoldersMenuItem == null) {
279            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
280            return;
281        }
282        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
283    }
284
285    @Override
286    public String getSubjectRemainder(String subject) {
287        final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
288        if (sdc == null) {
289            return subject;
290        }
291        return sdc.getUnshownSubject(subject);
292    }
293    // END conversation header callbacks
294
295    @Override
296    public void onDestroyView() {
297        super.onDestroyView();
298        mAccountObserver.unregisterAndDestroy();
299    }
300
301    /**
302     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
303     * reliability on older platforms.
304     */
305    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
306        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
307        if (mUserVisible != isVisibleToUser) {
308            mUserVisible = isVisibleToUser;
309            onUserVisibleHintChanged();
310        }
311    }
312
313    private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
314
315        @Override
316        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
317            return new MessageLoader(mActivity.getActivityContext(), mConversation,
318                    AbstractConversationViewFragment.this);
319        }
320
321        @Override
322        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
323            // ignore truly duplicate results
324            // this can happen when restoring after rotation
325            if (mCursor == data) {
326                return;
327            } else {
328                MessageCursor messageCursor = (MessageCursor) data;
329
330                if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
331                    LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
332                }
333
334                // TODO: handle ERROR status
335
336                // When the last cursor had message(s), and the new version has
337                // no messages, we need to exit conversation view.
338                if (messageCursor.getCount() == 0 && mCursor != null) {
339                    if (mUserVisible) {
340                        // need to exit this view- conversation may have been
341                        // deleted, or for whatever reason is now invalid (e.g.
342                        // discard single draft)
343                        //
344                        // N.B. this may involve a fragment transaction, which
345                        // FragmentManager will refuse to execute directly
346                        // within onLoadFinished. Make sure the controller knows.
347                        LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode");
348                        mActivity.getListHandler()
349                                .onConversationSelected(null, true /* inLoaderCallbacks */);
350                    } else {
351                        // we expect that the pager adapter will remove this
352                        // conversation fragment on its own due to a separate
353                        // conversation cursor update (we might get here if the
354                        // message list update fires first. nothing to do
355                        // because we expect to be torn down soon.)
356                        LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update"
357                                + " in anticipation of conv cursor update. c=%s", mConversation.uri);
358                    }
359
360                    return;
361                }
362
363                // ignore cursors that are still loading results
364                if (!messageCursor.isLoaded()) {
365                    return;
366                }
367                boolean wasNull = mCursor == null;
368                boolean messageCursorChanged = mCursor != null
369                        && messageCursor.hashCode() != mCursor.hashCode();
370                mCursor = (MessageCursor) data;
371                onMessageCursorLoadFinished(loader, data, wasNull, messageCursorChanged);
372            }
373        }
374
375        @Override
376        public void onLoaderReset(Loader<Cursor> loader) {
377            mCursor = null;
378        }
379
380    }
381
382    private static class MessageLoader extends CursorLoader {
383        private boolean mDeliveredFirstResults = false;
384        private final Conversation mConversation;
385        private final ConversationController mController;
386
387        public MessageLoader(Context c, Conversation conv, ConversationController controller) {
388            super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
389            mConversation = conv;
390            mController = controller;
391        }
392
393        @Override
394        public Cursor loadInBackground() {
395            return new MessageCursor(super.loadInBackground(), mConversation, mController);
396        }
397
398        @Override
399        public void deliverResult(Cursor result) {
400            // We want to deliver these results, and then we want to make sure
401            // that any subsequent
402            // queries do not hit the network
403            super.deliverResult(result);
404
405            if (!mDeliveredFirstResults) {
406                mDeliveredFirstResults = true;
407                Uri uri = getUri();
408
409                // Create a ListParams that tells the provider to not hit the
410                // network
411                final ListParams listParams = new ListParams(ListParams.NO_LIMIT,
412                        false /* useNetwork */);
413
414                // Build the new uri with this additional parameter
415                uri = uri
416                        .buildUpon()
417                        .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER,
418                                listParams.serialize()).build();
419                setUri(uri);
420            }
421        }
422    }
423
424    /**
425     * Inner class to to asynchronously load contact data for all senders in the conversation,
426     * and notify observers when the data is ready.
427     *
428     */
429    protected class ContactLoaderCallbacks implements ContactInfoSource,
430            LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> {
431
432        private Set<String> mSenders;
433        private ImmutableMap<String, ContactInfo> mContactInfoMap;
434        private DataSetObservable mObservable = new DataSetObservable();
435
436        public void setSenders(Set<String> emailAddresses) {
437            mSenders = emailAddresses;
438        }
439
440        @Override
441        public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) {
442            return new SenderInfoLoader(mActivity.getActivityContext(), mSenders);
443        }
444
445        @Override
446        public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader,
447                ImmutableMap<String, ContactInfo> data) {
448            mContactInfoMap = data;
449            mObservable.notifyChanged();
450        }
451
452        @Override
453        public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) {
454        }
455
456        @Override
457        public ContactInfo getContactInfo(String email) {
458            if (mContactInfoMap == null) {
459                return null;
460            }
461            return mContactInfoMap.get(email);
462        }
463
464        @Override
465        public void registerObserver(DataSetObserver observer) {
466            mObservable.registerObserver(observer);
467        }
468
469        @Override
470        public void unregisterObserver(DataSetObserver observer) {
471            mObservable.unregisterObserver(observer);
472        }
473
474    }
475}
476