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