AbstractConversationViewFragment.java revision 7729a9aaa1873505191303a35c480b6dcf1c7382
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.AnimatorListenerAdapter;
23import android.app.Activity;
24import android.app.Fragment;
25import android.app.LoaderManager;
26import android.content.ActivityNotFoundException;
27import android.content.Context;
28import android.content.Intent;
29import android.content.Loader;
30import android.content.pm.ActivityInfo;
31import android.content.pm.PackageManager;
32import android.content.pm.ResolveInfo;
33import android.content.res.Resources;
34import android.database.Cursor;
35import android.database.DataSetObservable;
36import android.database.DataSetObserver;
37import android.net.Uri;
38import android.os.Bundle;
39import android.os.Handler;
40import android.provider.Browser;
41import android.util.Log;
42import android.view.Menu;
43import android.view.MenuInflater;
44import android.view.MenuItem;
45import android.view.View;
46import android.webkit.WebView;
47import android.webkit.WebViewClient;
48
49import com.android.mail.ContactInfo;
50import com.android.mail.ContactInfoSource;
51import com.android.mail.FormattedDateBuilder;
52import com.android.mail.R;
53import com.android.mail.SenderInfoLoader;
54import com.android.mail.browse.ConversationAccountController;
55import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
56import com.android.mail.browse.MessageCursor;
57import com.android.mail.browse.MessageCursor.ConversationController;
58import com.android.mail.browse.MessageCursor.ConversationMessage;
59import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
60import com.android.mail.content.ObjectCursor;
61import com.android.mail.content.ObjectCursorLoader;
62import com.android.mail.providers.Account;
63import com.android.mail.providers.AccountObserver;
64import com.android.mail.providers.Address;
65import com.android.mail.providers.Conversation;
66import com.android.mail.providers.Folder;
67import com.android.mail.providers.ListParams;
68import com.android.mail.providers.UIProvider;
69import com.android.mail.providers.UIProvider.CursorStatus;
70import com.android.mail.utils.LogTag;
71import com.android.mail.utils.LogUtils;
72import com.android.mail.utils.Utils;
73import com.google.common.collect.ImmutableMap;
74
75import java.util.Arrays;
76import java.util.Collections;
77import java.util.HashMap;
78import java.util.List;
79import java.util.Map;
80import java.util.Set;
81
82public abstract class AbstractConversationViewFragment extends Fragment implements
83        ConversationController, ConversationAccountController, MessageHeaderViewCallbacks,
84        ConversationViewHeaderCallbacks {
85
86    private static final String ARG_ACCOUNT = "account";
87    public static final String ARG_CONVERSATION = "conversation";
88    private static final String ARG_FOLDER = "folder";
89    private static final String LOG_TAG = LogTag.getLogTag();
90    protected static final int MESSAGE_LOADER = 0;
91    protected static final int CONTACT_LOADER = 1;
92    private static int sMinDelay = -1;
93    private static int sMinShowTime = -1;
94    protected ControllableActivity mActivity;
95    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
96    protected FormattedDateBuilder mDateBuilder;
97    private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks();
98    private MenuItem mChangeFoldersMenuItem;
99    protected Conversation mConversation;
100    protected Folder mFolder;
101    protected String mBaseUri;
102    protected Account mAccount;
103    /**
104     * Cache of email address strings to parsed Address objects.
105     * <p>
106     * Remember to synchronize on the map when reading or writing to this cache, because some
107     * instances use it off the UI thread (e.g. from WebView).
108     */
109    protected final Map<String, Address> mAddressCache = Collections.synchronizedMap(
110            new HashMap<String, Address>());
111    private MessageCursor mCursor;
112    private Context mContext;
113    /**
114     * A backwards-compatible version of {{@link #getUserVisibleHint()}. Like the framework flag,
115     * this flag is saved and restored.
116     */
117    private boolean mUserVisible;
118    private View mProgressView;
119    private View mBackgroundView;
120    private final Handler mHandler = new Handler();
121    /** True if we want to avoid marking the conversation as viewed and read. */
122    private boolean mSuppressMarkingViewed;
123    /**
124     * Parcelable state of the conversation view. Can safely be used without null checking any time
125     * after {@link #onCreate(Bundle)}.
126     */
127    protected ConversationViewState mViewState;
128
129    private long mLoadingShownTime = -1;
130
131    private boolean mIsDetached;
132
133    private boolean mHasConversationBeenTransformed;
134    private boolean mHasConversationTransformBeenReverted;
135
136    private final Runnable mDelayedShow = new FragmentRunnable("mDelayedShow") {
137        @Override
138        public void go() {
139            mLoadingShownTime = System.currentTimeMillis();
140            mProgressView.setVisibility(View.VISIBLE);
141        }
142    };
143
144    private final AccountObserver mAccountObserver = new AccountObserver() {
145        @Override
146        public void onChanged(Account newAccount) {
147            final Account oldAccount = mAccount;
148            mAccount = newAccount;
149            onAccountChanged(newAccount, oldAccount);
150        }
151    };
152
153    private static final String BUNDLE_VIEW_STATE =
154            AbstractConversationViewFragment.class.getName() + "viewstate";
155    /**
156     * We save the user visible flag so the various transitions that occur during rotation do not
157     * cause unnecessary visibility change.
158     */
159    private static final String BUNDLE_USER_VISIBLE =
160            AbstractConversationViewFragment.class.getName() + "uservisible";
161
162    private static final String BUNDLE_DETACHED =
163            AbstractConversationViewFragment.class.getName() + "detached";
164
165    private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED =
166            AbstractConversationViewFragment.class.getName() + "conversationtransformed";
167    private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED =
168            AbstractConversationViewFragment.class.getName() + "conversationreverted";
169
170    public static Bundle makeBasicArgs(Account account, Folder folder) {
171        Bundle args = new Bundle();
172        args.putParcelable(ARG_ACCOUNT, account);
173        args.putParcelable(ARG_FOLDER, folder);
174        return args;
175    }
176
177    /**
178     * Constructor needs to be public to handle orientation changes and activity
179     * lifecycle events.
180     */
181    public AbstractConversationViewFragment() {
182        super();
183    }
184
185    /**
186     * Subclasses must override, since this depends on how many messages are
187     * shown in the conversation view.
188     */
189    protected void markUnread() {
190        // Do not automatically mark this conversation viewed and read.
191        mSuppressMarkingViewed = true;
192    }
193
194    /**
195     * Subclasses must override this, since they may want to display a single or
196     * many messages related to this conversation.
197     */
198    protected abstract void onMessageCursorLoadFinished(
199            Loader<ObjectCursor<ConversationMessage>> loader,
200            MessageCursor newCursor, MessageCursor oldCursor);
201
202    /**
203     * Subclasses must override this, since they may want to display a single or
204     * many messages related to this conversation.
205     */
206    @Override
207    public abstract void onConversationViewHeaderHeightChange(int newHeight);
208
209    public abstract void onUserVisibleHintChanged();
210
211    /**
212     * Subclasses must override this.
213     */
214    protected abstract void onAccountChanged(Account newAccount, Account oldAccount);
215
216    @Override
217    public void onCreate(Bundle savedState) {
218        super.onCreate(savedState);
219
220        final Bundle args = getArguments();
221        mAccount = args.getParcelable(ARG_ACCOUNT);
222        mConversation = args.getParcelable(ARG_CONVERSATION);
223        mFolder = args.getParcelable(ARG_FOLDER);
224
225        // Since the uri specified in the conversation base uri may not be unique, we specify a
226        // base uri that us guaranteed to be unique for this conversation.
227        mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
228
229        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
230        // Not really, we just want to get a crack to store a reference to the change_folder item
231        setHasOptionsMenu(true);
232
233        if (savedState != null) {
234            mViewState = savedState.getParcelable(BUNDLE_VIEW_STATE);
235            mUserVisible = savedState.getBoolean(BUNDLE_USER_VISIBLE);
236            mIsDetached = savedState.getBoolean(BUNDLE_DETACHED, false);
237            mHasConversationBeenTransformed =
238                    savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED, false);
239            mHasConversationTransformBeenReverted =
240                    savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED, false);
241        } else {
242            mViewState = getNewViewState();
243            mHasConversationBeenTransformed = false;
244            mHasConversationTransformBeenReverted = false;
245        }
246    }
247
248    @Override
249    public String toString() {
250        // log extra info at DEBUG level or finer
251        final String s = super.toString();
252        if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
253            return s;
254        }
255        return "(" + s + " conv=" + mConversation + ")";
256    }
257
258    protected abstract WebView getWebView();
259
260    public void instantiateProgressIndicators(View rootView) {
261        mBackgroundView = rootView.findViewById(R.id.background_view);
262        mProgressView = rootView.findViewById(R.id.loading_progress);
263    }
264
265    protected void dismissLoadingStatus() {
266        dismissLoadingStatus(null);
267    }
268
269    /**
270     * Begin the fade-out animation to hide the Progress overlay, either immediately or after some
271     * timeout (to ensure that the progress minimum time elapses).
272     *
273     * @param doAfter an optional Runnable action to execute after the animation completes
274     */
275    protected void dismissLoadingStatus(final Runnable doAfter) {
276        if (mLoadingShownTime == -1) {
277            // The runnable hasn't run yet, so just remove it.
278            mHandler.removeCallbacks(mDelayedShow);
279            dismiss(doAfter);
280            return;
281        }
282        final long diff = Math.abs(System.currentTimeMillis() - mLoadingShownTime);
283        if (diff > sMinShowTime) {
284            dismiss(doAfter);
285        } else {
286            mHandler.postDelayed(new FragmentRunnable("dismissLoadingStatus") {
287                @Override
288                public void go() {
289                    dismiss(doAfter);
290                }
291            }, Math.abs(sMinShowTime - diff));
292        }
293    }
294
295    private void dismiss(final Runnable doAfter) {
296        // Reset loading shown time.
297        mLoadingShownTime = -1;
298        mProgressView.setVisibility(View.GONE);
299        if (mBackgroundView.getVisibility() == View.VISIBLE) {
300            animateDismiss(doAfter);
301        } else {
302            if (doAfter != null) {
303                doAfter.run();
304            }
305        }
306    }
307
308    private void animateDismiss(final Runnable doAfter) {
309        // the animation can only work (and is only worth doing) if this fragment is added
310        // reasons it may not be added: fragment is being destroyed, or in the process of being
311        // restored
312        if (!isAdded()) {
313            mBackgroundView.setVisibility(View.GONE);
314            return;
315        }
316
317        Utils.enableHardwareLayer(mBackgroundView);
318        final Animator animator = AnimatorInflater.loadAnimator(getContext(), R.anim.fade_out);
319        animator.setTarget(mBackgroundView);
320        animator.addListener(new AnimatorListenerAdapter() {
321            @Override
322            public void onAnimationEnd(Animator animation) {
323                mBackgroundView.setVisibility(View.GONE);
324                mBackgroundView.setLayerType(View.LAYER_TYPE_NONE, null);
325                if (doAfter != null) {
326                    doAfter.run();
327                }
328            }
329        });
330        animator.start();
331    }
332
333    @Override
334    public void onActivityCreated(Bundle savedInstanceState) {
335        super.onActivityCreated(savedInstanceState);
336        final Activity activity = getActivity();
337        if (!(activity instanceof ControllableActivity)) {
338            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
339                    + "create it. Cannot proceed.");
340        }
341        if (activity == null || activity.isFinishing()) {
342            // Activity is finishing, just bail.
343            return;
344        }
345        mActivity = (ControllableActivity) activity;
346        mContext = activity.getApplicationContext();
347        mDateBuilder = new FormattedDateBuilder((Context) mActivity);
348        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
349    }
350
351    @Override
352    public ConversationUpdater getListController() {
353        final ControllableActivity activity = (ControllableActivity) getActivity();
354        return activity != null ? activity.getConversationUpdater() : null;
355    }
356
357
358    protected void showLoadingStatus() {
359        if (!mUserVisible) {
360            return;
361        }
362        if (sMinDelay == -1) {
363            Resources res = getContext().getResources();
364            sMinDelay = res.getInteger(R.integer.conversationview_show_loading_delay);
365            sMinShowTime = res.getInteger(R.integer.conversationview_min_show_loading);
366        }
367        // If the loading view isn't already showing, show it and remove any
368        // pending calls to show the loading screen.
369        mBackgroundView.setVisibility(View.VISIBLE);
370        mHandler.removeCallbacks(mDelayedShow);
371        mHandler.postDelayed(mDelayedShow, sMinDelay);
372    }
373
374    public Context getContext() {
375        return mContext;
376    }
377
378    @Override
379    public Conversation getConversation() {
380        return mConversation;
381    }
382
383    @Override
384    public MessageCursor getMessageCursor() {
385        return mCursor;
386    }
387
388    public Handler getHandler() {
389        return mHandler;
390    }
391
392    public MessageLoaderCallbacks getMessageLoaderCallbacks() {
393        return mMessageLoaderCallbacks;
394    }
395
396    public ContactLoaderCallbacks getContactInfoSource() {
397        return mContactLoaderCallbacks;
398    }
399
400    @Override
401    public Account getAccount() {
402        return mAccount;
403    }
404
405    @Override
406    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
407        super.onCreateOptionsMenu(menu, inflater);
408        mChangeFoldersMenuItem = menu.findItem(R.id.change_folder);
409    }
410
411    @Override
412    public boolean onOptionsItemSelected(MenuItem item) {
413        if (!isUserVisible()) {
414            // Unclear how this is happening. Current theory is that this fragment was scheduled
415            // to be removed, but the remove transaction failed. When the Activity is later
416            // restored, the FragmentManager restores this fragment, but Fragment.mMenuVisible is
417            // stuck at its initial value (true), which makes this zombie fragment eligible for
418            // menu item clicks.
419            //
420            // Work around this by relying on the (properly restored) extra user visible hint.
421            LogUtils.e(LOG_TAG,
422                    "ACVF ignoring onOptionsItemSelected b/c userVisibleHint is false. f=%s", this);
423            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
424                Log.e(LOG_TAG, Utils.dumpFragment(this));  // the dump has '%' chars in it...
425            }
426            return false;
427        }
428
429        boolean handled = false;
430        switch (item.getItemId()) {
431            case R.id.inside_conversation_unread:
432                markUnread();
433                handled = true;
434                break;
435            case R.id.show_original:
436                showUntransformedConversation();
437                handled = true;
438                break;
439        }
440        return handled;
441    }
442
443    @Override
444    public void onPrepareOptionsMenu(Menu menu) {
445        // Only show option if we support message transforms and message has been transformed.
446        Utils.setMenuItemVisibility(menu, R.id.show_original, supportsMessageTransforms() &&
447                mHasConversationBeenTransformed && !mHasConversationTransformBeenReverted);
448    }
449
450    // BEGIN conversation header callbacks
451    @Override
452    public void onFoldersClicked() {
453        if (mChangeFoldersMenuItem == null) {
454            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
455            return;
456        }
457        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
458    }
459    // END conversation header callbacks
460
461    @Override
462    public void onSaveInstanceState(Bundle outState) {
463        if (mViewState != null) {
464            outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
465        }
466        outState.putBoolean(BUNDLE_USER_VISIBLE, mUserVisible);
467        outState.putBoolean(BUNDLE_DETACHED, mIsDetached);
468        outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED,
469                mHasConversationBeenTransformed);
470        outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED,
471                mHasConversationTransformBeenReverted);
472    }
473
474    @Override
475    public void onDestroyView() {
476        super.onDestroyView();
477        mAccountObserver.unregisterAndDestroy();
478    }
479
480    /**
481     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
482     * reliability on older platforms.
483     */
484    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
485        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
486        if (mUserVisible != isVisibleToUser) {
487            mUserVisible = isVisibleToUser;
488            MessageCursor cursor = getMessageCursor();
489            if (mUserVisible && (cursor != null && cursor.isLoaded() && cursor.getCount() == 0)) {
490                // Pop back to conversation list and show error.
491                onError();
492                return;
493            }
494            onUserVisibleHintChanged();
495        }
496    }
497
498    public boolean isUserVisible() {
499        return mUserVisible;
500    }
501
502    protected void timerMark(String msg) {
503        if (isUserVisible()) {
504            Utils.sConvLoadTimer.mark(msg);
505        }
506    }
507
508    private class MessageLoaderCallbacks
509            implements LoaderManager.LoaderCallbacks<ObjectCursor<ConversationMessage>> {
510
511        @Override
512        public Loader<ObjectCursor<ConversationMessage>> onCreateLoader(int id, Bundle args) {
513            return new MessageLoader(mActivity.getActivityContext(), mConversation.messageListUri);
514        }
515
516        @Override
517        public void onLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader,
518                    ObjectCursor<ConversationMessage> data) {
519            // ignore truly duplicate results
520            // this can happen when restoring after rotation
521            if (mCursor == data) {
522                return;
523            } else {
524                final MessageCursor messageCursor = (MessageCursor) data;
525
526                // bind the cursor to this fragment so it can access to the current list controller
527                messageCursor.setController(AbstractConversationViewFragment.this);
528
529                if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
530                    LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
531                }
532
533                // We have no messages: exit conversation view.
534                if (messageCursor.getCount() == 0
535                        && (!CursorStatus.isWaitingForResults(messageCursor.getStatus())
536                                || mIsDetached)) {
537                    if (mUserVisible) {
538                        onError();
539                    } else {
540                        // we expect that the pager adapter will remove this
541                        // conversation fragment on its own due to a separate
542                        // conversation cursor update (we might get here if the
543                        // message list update fires first. nothing to do
544                        // because we expect to be torn down soon.)
545                        LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update"
546                                + " in anticipation of conv cursor update. c=%s",
547                                mConversation.uri);
548                    }
549                    // existing mCursor will imminently be closed, must stop referencing it
550                    // since we expect to be kicked out soon, it doesn't matter what mCursor
551                    // becomes
552                    mCursor = null;
553                    return;
554                }
555
556                // ignore cursors that are still loading results
557                if (!messageCursor.isLoaded()) {
558                    // existing mCursor will imminently be closed, must stop referencing it
559                    // in this case, the new cursor is also no good, and since don't expect to get
560                    // here except in initial load situations, it's safest to just ensure the
561                    // reference is null
562                    mCursor = null;
563                    return;
564                }
565                final MessageCursor oldCursor = mCursor;
566                mCursor = messageCursor;
567                onMessageCursorLoadFinished(loader, mCursor, oldCursor);
568            }
569        }
570
571        @Override
572        public void onLoaderReset(Loader<ObjectCursor<ConversationMessage>>  loader) {
573            mCursor = null;
574        }
575
576    }
577
578    private void onError() {
579        // need to exit this view- conversation may have been
580        // deleted, or for whatever reason is now invalid (e.g.
581        // discard single draft)
582        //
583        // N.B. this may involve a fragment transaction, which
584        // FragmentManager will refuse to execute directly
585        // within onLoadFinished. Make sure the controller knows.
586        LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode");
587        // TODO(mindyp): handle ERROR status by showing an error
588        // message to the user that there are no messages in
589        // this conversation
590        popOut();
591    }
592
593    private void popOut() {
594        mHandler.post(new FragmentRunnable("popOut") {
595            @Override
596            public void go() {
597                if (mActivity != null) {
598                    mActivity.getListHandler()
599                            .onConversationSelected(null, true /* inLoaderCallbacks */);
600                }
601            }
602        });
603    }
604
605    protected void onConversationSeen() {
606        LogUtils.d(LOG_TAG, "AbstractConversationViewFragment#onConversationSeen()");
607
608        // Ignore unsafe calls made after a fragment is detached from an activity
609        final ControllableActivity activity = (ControllableActivity) getActivity();
610        if (activity == null) {
611            LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id);
612            return;
613        }
614
615        mViewState.setInfoForConversation(mConversation);
616
617        LogUtils.d(LOG_TAG, "onConversationSeen() - mSuppressMarkingViewed = %b",
618                mSuppressMarkingViewed);
619        // In most circumstances we want to mark the conversation as viewed and read, since the
620        // user has read it.  However, if the user has already marked the conversation unread, we
621        // do not want a  later mark-read operation to undo this.  So we check this variable which
622        // is set in #markUnread() which suppresses automatic mark-read.
623        if (!mSuppressMarkingViewed) {
624            // mark viewed/read if not previously marked viewed by this conversation view,
625            // or if unread messages still exist in the message list cursor
626            // we don't want to keep marking viewed on rotation or restore
627            // but we do want future re-renders to mark read (e.g. "New message from X" case)
628            final MessageCursor cursor = getMessageCursor();
629            LogUtils.d(LOG_TAG, "onConversationSeen() - mConversation.isViewed() = %b, "
630                    + "cursor null = %b, cursor.isConversationRead() = %b",
631                    mConversation.isViewed(), cursor == null,
632                    cursor != null && cursor.isConversationRead());
633            if (!mConversation.isViewed() || (cursor != null && !cursor.isConversationRead())) {
634                // Mark the conversation viewed and read.
635                activity.getConversationUpdater()
636                        .markConversationsRead(Arrays.asList(mConversation), true, true);
637
638                // and update the Message objects in the cursor so the next time a cursor update
639                // happens with these messages marked read, we know to ignore it
640                if (cursor != null && !cursor.isClosed()) {
641                    cursor.markMessagesRead();
642                }
643            }
644        }
645        activity.getListHandler().onConversationSeen(mConversation);
646    }
647
648    protected ConversationViewState getNewViewState() {
649        return new ConversationViewState();
650    }
651
652    private static class MessageLoader extends ObjectCursorLoader<ConversationMessage> {
653        private boolean mDeliveredFirstResults = false;
654
655        public MessageLoader(Context c, Uri messageListUri) {
656            super(c, messageListUri, UIProvider.MESSAGE_PROJECTION, ConversationMessage.FACTORY);
657        }
658
659        @Override
660        public void deliverResult(ObjectCursor<ConversationMessage> result) {
661            // We want to deliver these results, and then we want to make sure
662            // that any subsequent
663            // queries do not hit the network
664            super.deliverResult(result);
665
666            if (!mDeliveredFirstResults) {
667                mDeliveredFirstResults = true;
668                Uri uri = getUri();
669
670                // Create a ListParams that tells the provider to not hit the
671                // network
672                final ListParams listParams = new ListParams(ListParams.NO_LIMIT,
673                        false /* useNetwork */);
674
675                // Build the new uri with this additional parameter
676                uri = uri
677                        .buildUpon()
678                        .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER,
679                                listParams.serialize()).build();
680                setUri(uri);
681            }
682        }
683
684        @Override
685        protected ObjectCursor<ConversationMessage> getObjectCursor(Cursor inner) {
686            return new MessageCursor(inner);
687        }
688    }
689
690    /**
691     * Inner class to to asynchronously load contact data for all senders in the conversation,
692     * and notify observers when the data is ready.
693     *
694     */
695    protected class ContactLoaderCallbacks implements ContactInfoSource,
696            LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> {
697
698        private Set<String> mSenders;
699        private ImmutableMap<String, ContactInfo> mContactInfoMap;
700        private DataSetObservable mObservable = new DataSetObservable();
701
702        public void setSenders(Set<String> emailAddresses) {
703            mSenders = emailAddresses;
704        }
705
706        @Override
707        public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) {
708            return new SenderInfoLoader(mActivity.getActivityContext(), mSenders);
709        }
710
711        @Override
712        public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader,
713                ImmutableMap<String, ContactInfo> data) {
714            mContactInfoMap = data;
715            mObservable.notifyChanged();
716        }
717
718        @Override
719        public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) {
720        }
721
722        @Override
723        public ContactInfo getContactInfo(String email) {
724            if (mContactInfoMap == null) {
725                return null;
726            }
727            return mContactInfoMap.get(email);
728        }
729
730        @Override
731        public void registerObserver(DataSetObserver observer) {
732            mObservable.registerObserver(observer);
733        }
734
735        @Override
736        public void unregisterObserver(DataSetObserver observer) {
737            mObservable.unregisterObserver(observer);
738        }
739    }
740
741    protected class AbstractConversationWebViewClient extends WebViewClient {
742        @Override
743        public boolean shouldOverrideUrlLoading(WebView view, String url) {
744            final Activity activity = getActivity();
745            if (activity == null) {
746                return false;
747            }
748
749            boolean result = false;
750            final Intent intent;
751            Uri uri = Uri.parse(url);
752            if (!Utils.isEmpty(mAccount.viewIntentProxyUri)) {
753                intent = generateProxyIntent(uri);
754            } else {
755                intent = new Intent(Intent.ACTION_VIEW, uri);
756                intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName());
757            }
758
759            try {
760                intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
761                activity.startActivity(intent);
762                result = true;
763            } catch (ActivityNotFoundException ex) {
764                // If no application can handle the URL, assume that the
765                // caller can handle it.
766            }
767
768            return result;
769        }
770
771        private Intent generateProxyIntent(Uri uri) {
772            final Intent intent = new Intent(Intent.ACTION_VIEW, mAccount.viewIntentProxyUri);
773            intent.putExtra(UIProvider.ViewProxyExtras.EXTRA_ORIGINAL_URI, uri);
774            intent.putExtra(UIProvider.ViewProxyExtras.EXTRA_ACCOUNT, mAccount);
775
776            final Context context = getContext();
777            PackageManager manager = null;
778            // We need to catch the exception to make CanvasConversationHeaderView
779            // test pass.  Bug: http://b/issue?id=3470653.
780            try {
781                manager = context.getPackageManager();
782            } catch (UnsupportedOperationException e) {
783                LogUtils.e(LOG_TAG, e, "Error getting package manager");
784            }
785
786            if (manager != null) {
787                // Try and resolve the intent, to find an activity from this package
788                final List<ResolveInfo> resolvedActivities = manager.queryIntentActivities(
789                        intent, PackageManager.MATCH_DEFAULT_ONLY);
790
791                final String packageName = context.getPackageName();
792
793                // Now try and find one that came from this package, if one is not found, the UI
794                // provider must have specified an intent that is to be handled by a different apk.
795                // In that case, the class name will not be set on the intent, so the default
796                // intent resolution will be used.
797                for (ResolveInfo resolveInfo: resolvedActivities) {
798                    final ActivityInfo activityInfo = resolveInfo.activityInfo;
799                    if (packageName.equals(activityInfo.packageName)) {
800                        intent.setClassName(activityInfo.packageName, activityInfo.name);
801                        break;
802                    }
803                }
804            }
805
806            return intent;
807        }
808    }
809
810    public abstract void onConversationUpdated(Conversation conversation);
811
812    /**
813     * Small Runnable-like wrapper that first checks that the Fragment is in a good state before
814     * doing any work. Ideal for use with a {@link Handler}.
815     */
816    protected abstract class FragmentRunnable implements Runnable {
817
818        private final String mOpName;
819
820        public FragmentRunnable(String opName) {
821            mOpName = opName;
822        }
823
824        public abstract void go();
825
826        @Override
827        public void run() {
828            if (!isAdded()) {
829                LogUtils.i(LOG_TAG, "Unable to run op='%s' b/c fragment is not attached: %s",
830                        mOpName, AbstractConversationViewFragment.this);
831                return;
832            }
833            go();
834        }
835
836    }
837
838    public void onDetachedModeEntered() {
839        // If we have no messages, then we have nothing to display, so leave this view.
840        // Otherwise, just set the detached flag.
841        final Cursor messageCursor = getMessageCursor();
842
843        if (messageCursor == null || messageCursor.getCount() == 0) {
844            popOut();
845        } else {
846            mIsDetached = true;
847        }
848    }
849
850    /**
851     * Called when the JavaScript reports that it transformed a message.
852     * Sets a flag to true and invalidates the options menu so it will
853     * include the "Revert auto-sizing" menu option.
854     */
855    public void onConversationTransformed() {
856        mHasConversationBeenTransformed = true;
857        mHandler.post(new FragmentRunnable("invalidateOptionsMenu") {
858            @Override
859            public void go() {
860                mActivity.invalidateOptionsMenu();
861            }
862        });
863    }
864
865    /**
866     * Called when the "Revert auto-sizing" option is selected. Default
867     * implementation simply sets a value on whether transforms should be
868     * applied. Derived classes should override this class and force a
869     * re-render so that the conversation renders without
870     */
871    public void showUntransformedConversation() {
872        // must set the value to true so we don't show the options menu item again
873        mHasConversationTransformBeenReverted = true;
874    }
875
876    /**
877     * Returns {@code true} if the conversation should be transformed. {@code false}, otherwise.
878     * @return {@code true} if the conversation should be transformed. {@code false}, otherwise.
879     */
880    public boolean shouldApplyTransforms() {
881        return (mAccount.enableMessageTransforms > 0) &&
882                !mHasConversationTransformBeenReverted;
883    }
884}
885