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