AbstractConversationViewFragment.java revision ba4cce62e2de2b818190b81bb07ecc5e94544165
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.Animator.AnimatorListener;
22import android.animation.AnimatorInflater;
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.text.Spannable;
43import android.text.SpannableStringBuilder;
44import android.text.TextUtils;
45import android.text.style.ForegroundColorSpan;
46import android.view.Menu;
47import android.view.MenuInflater;
48import android.view.MenuItem;
49import android.view.View;
50import android.webkit.WebView;
51import android.webkit.WebViewClient;
52import android.widget.TextView;
53
54import com.android.mail.ContactInfo;
55import com.android.mail.ContactInfoSource;
56import com.android.mail.FormattedDateBuilder;
57import com.android.mail.R;
58import com.android.mail.SenderInfoLoader;
59import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController;
60import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
61import com.android.mail.browse.MessageCursor;
62import com.android.mail.browse.MessageCursor.ConversationController;
63import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
64import com.android.mail.providers.Account;
65import com.android.mail.providers.AccountObserver;
66import com.android.mail.providers.Address;
67import com.android.mail.providers.Conversation;
68import com.android.mail.providers.Folder;
69import com.android.mail.providers.ListParams;
70import com.android.mail.providers.UIProvider;
71import com.android.mail.utils.LogTag;
72import com.android.mail.utils.LogUtils;
73import com.android.mail.utils.Utils;
74import com.google.common.collect.ImmutableMap;
75import com.google.common.collect.Maps;
76
77import java.util.Arrays;
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 sSubjectColor = Integer.MIN_VALUE;
93    private static int sSnippetColor = Integer.MIN_VALUE;
94    private static int sMinDelay = -1;
95    private static int sMinShowTime = -1;
96    protected ControllableActivity mActivity;
97    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
98    protected FormattedDateBuilder mDateBuilder;
99    private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks();
100    private MenuItem mChangeFoldersMenuItem;
101    protected Conversation mConversation;
102    protected Folder mFolder;
103    protected String mBaseUri;
104    protected Account mAccount;
105    protected final Map<String, Address> mAddressCache = Maps.newHashMap();
106    protected boolean mEnableContentReadySignal;
107    private MessageCursor mCursor;
108    private Context mContext;
109    /**
110     * A backwards-compatible version of {{@link #getUserVisibleHint()}. Like the framework flag,
111     * this flag is saved and restored.
112     */
113    private boolean mUserVisible;
114    private View mProgressView;
115    private View mBackgroundView;
116    private View mInfoView;
117    private final Handler mHandler = new Handler();
118
119    /**
120     * Parcelable state of the conversation view. Can safely be used without null checking any time
121     * after {@link #onCreate(Bundle)}.
122     */
123    protected ConversationViewState mViewState;
124
125    /**
126     * Handles a deferred 'mark read' operation, necessary when the conversation view has finished
127     * loading before the conversation cursor. Normally null unless this situation occurs.
128     * When finally able to 'mark read', this observer will also be unregistered and cleaned up.
129     */
130    private MarkReadObserver mMarkReadObserver;
131
132    private long mLoadingShownTime = -1;
133
134    private Runnable mDelayedShow = new Runnable() {
135        @Override
136        public void run() {
137            mLoadingShownTime = System.currentTimeMillis();
138            String senders = mConversation.getSenders(getContext());
139            if (!TextUtils.isEmpty(senders) && mConversation.subject != null) {
140                mInfoView.setVisibility(View.VISIBLE);
141                mSendersView.setText(senders);
142                mSubjectView.setText(createSubjectSnippet(mConversation.subject,
143                        mConversation.getSnippet()));
144            } else {
145                mProgressView.setVisibility(View.VISIBLE);
146            }
147        }
148    };
149
150    private Runnable mDelayedDismiss = new Runnable() {
151        @Override
152        public void run() {
153            dismiss();
154        }
155    };
156    private final AccountObserver mAccountObserver = new AccountObserver() {
157        @Override
158        public void onChanged(Account newAccount) {
159            mAccount = newAccount;
160            onAccountChanged();
161        }
162    };
163    private TextView mSendersView;
164    private TextView mSubjectView;
165
166    private static final String BUNDLE_VIEW_STATE =
167            AbstractConversationViewFragment.class.getName() + "viewstate";
168    private static final String BUNDLE_USER_VISIBLE =
169            AbstractConversationViewFragment.class.getName() + "uservisible";
170
171    public static Bundle makeBasicArgs(Account account, Folder folder) {
172        Bundle args = new Bundle();
173        args.putParcelable(ARG_ACCOUNT, account);
174        args.putParcelable(ARG_FOLDER, folder);
175        return args;
176    }
177
178    /**
179     * Constructor needs to be public to handle orientation changes and activity
180     * lifecycle events.
181     */
182    public AbstractConversationViewFragment() {
183        super();
184    }
185
186    /**
187     * Subclasses must override, since this depends on how many messages are
188     * shown in the conversation view.
189     */
190    protected abstract void markUnread();
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();
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        // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
227        // Below JB, try to speed up initial render by having the webview do supplemental draws to
228        // custom a software canvas.
229        // TODO(mindyp):
230        //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
231        // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
232        // animation that immediately runs on page load. The app uses this as a signal that the
233        // content is loaded and ready to draw, since WebView delays firing this event until the
234        // layers are composited and everything is ready to draw.
235        // This signal does not seem to be reliable, so just use the old method for now.
236        mEnableContentReadySignal = false; //Utils.isRunningJellybeanOrLater();
237        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
238        // Not really, we just want to get a crack to store a reference to the change_folder item
239        setHasOptionsMenu(true);
240
241        if (savedState != null) {
242            mViewState = savedState.getParcelable(BUNDLE_VIEW_STATE);
243            mUserVisible = savedState.getBoolean(BUNDLE_USER_VISIBLE);
244        } else {
245            mViewState = getNewViewState();
246        }
247    }
248
249    public void instantiateProgressIndicators(View rootView) {
250        mSendersView = (TextView) rootView.findViewById(R.id.senders_view);
251        mSubjectView = (TextView) rootView.findViewById(R.id.info_subject_view);
252        mBackgroundView = rootView.findViewById(R.id.background_view);
253        mInfoView = rootView.findViewById(R.id.info_view);
254        mProgressView = rootView.findViewById(R.id.loading_progress);
255    }
256
257    protected void dismissLoadingStatus() {
258        if (mLoadingShownTime == -1) {
259            // The runnable hasn't run yet, so just remove it.
260            mBackgroundView.setVisibility(View.GONE);
261            mHandler.removeCallbacks(mDelayedShow);
262            return;
263        }
264        final long diff = Math.abs(System.currentTimeMillis() - mLoadingShownTime);
265        if (diff > sMinShowTime) {
266            dismiss();
267        } else {
268            mHandler.postDelayed(mDelayedDismiss, Math.abs(sMinShowTime - diff));
269        }
270    }
271
272    private void dismiss() {
273        // Reset loading shown time.
274        mLoadingShownTime = -1;
275        // Fade out the info view.
276        if (mBackgroundView.getVisibility() == View.VISIBLE) {
277            Animator animator = AnimatorInflater.loadAnimator(getContext(), R.anim.fade_out);
278            animator.setTarget(mBackgroundView);
279            animator.addListener(new AnimatorListener() {
280                @Override
281                public void onAnimationStart(Animator animation) {
282                    if (mProgressView.getVisibility() != View.VISIBLE) {
283                        mProgressView.setVisibility(View.GONE);
284                    }
285                }
286
287                @Override
288                public void onAnimationEnd(Animator animation) {
289                    mBackgroundView.setVisibility(View.GONE);
290                    mInfoView.setVisibility(View.GONE);
291                    mProgressView.setVisibility(View.GONE);
292                }
293
294                @Override
295                public void onAnimationCancel(Animator animation) {
296                    // Do nothing.
297                }
298
299                @Override
300                public void onAnimationRepeat(Animator animation) {
301                    // Do nothing.
302                }
303            });
304            animator.start();
305        } else {
306            mBackgroundView.setVisibility(View.GONE);
307            mInfoView.setVisibility(View.GONE);
308            mProgressView.setVisibility(View.GONE);
309        }
310    }
311
312    @Override
313    public void onActivityCreated(Bundle savedInstanceState) {
314        super.onActivityCreated(savedInstanceState);
315        final Activity activity = getActivity();
316        if (!(activity instanceof ControllableActivity)) {
317            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
318                    + "create it. Cannot proceed.");
319        }
320        if (activity == null || activity.isFinishing()) {
321            // Activity is finishing, just bail.
322            return;
323        }
324        mActivity = (ControllableActivity) activity;
325        mContext = activity.getApplicationContext();
326        mDateBuilder = new FormattedDateBuilder((Context) mActivity);
327        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
328    }
329
330    @Override
331    public ConversationUpdater getListController() {
332        final ControllableActivity activity = (ControllableActivity) getActivity();
333        return activity != null ? activity.getConversationUpdater() : null;
334    }
335
336
337    protected void showLoadingStatus() {
338        if (!mUserVisible) {
339            return;
340        }
341        if (sMinDelay == -1) {
342            Resources res = getContext().getResources();
343            sMinDelay = res.getInteger(R.integer.conversationview_show_loading_delay);
344            sMinShowTime = res.getInteger(R.integer.conversationview_min_show_loading);
345        }
346        // If the loading view isn't already showing, show it and remove any
347        // pending calls to show the loading screen.
348        mBackgroundView.setVisibility(View.VISIBLE);
349        mHandler.removeCallbacks(mDelayedShow);
350        mHandler.postDelayed(mDelayedShow, sMinDelay);
351    }
352
353    private CharSequence createSubjectSnippet(CharSequence subject, CharSequence snippet) {
354        if (TextUtils.isEmpty(subject) && TextUtils.isEmpty(snippet)) {
355            return "";
356        }
357        if (subject == null) {
358            subject = "";
359        }
360        if (snippet == null) {
361            snippet = "";
362        }
363        SpannableStringBuilder subjectText = new SpannableStringBuilder(getContext().getString(
364                R.string.subject_and_snippet, subject, snippet));
365        ensureSubjectSnippetColors();
366        int snippetStart = 0;
367        int fontColor = sSubjectColor;
368        subjectText.setSpan(new ForegroundColorSpan(fontColor), 0, subject.length(),
369                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
370        snippetStart = subject.length() + 1;
371        fontColor = sSnippetColor;
372        subjectText.setSpan(new ForegroundColorSpan(fontColor), snippetStart, subjectText.length(),
373                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
374        return subjectText;
375    }
376
377    private void ensureSubjectSnippetColors() {
378        if (sSubjectColor == Integer.MIN_VALUE) {
379            Resources res = getContext().getResources();
380            sSubjectColor = res.getColor(R.color.subject_text_color_read);
381            sSnippetColor = res.getColor(R.color.snippet_text_color_read);
382        }
383    }
384
385    public Context getContext() {
386        return mContext;
387    }
388
389    public Conversation getConversation() {
390        return mConversation;
391    }
392
393    @Override
394    public MessageCursor getMessageCursor() {
395        return mCursor;
396    }
397
398    public Handler getHandler() {
399        return mHandler;
400    }
401
402    public MessageLoaderCallbacks getMessageLoaderCallbacks() {
403        return mMessageLoaderCallbacks;
404    }
405
406    public ContactLoaderCallbacks getContactInfoSource() {
407        return mContactLoaderCallbacks;
408    }
409
410    @Override
411    public Account getAccount() {
412        return mAccount;
413    }
414
415    @Override
416    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
417        super.onCreateOptionsMenu(menu, inflater);
418        mChangeFoldersMenuItem = menu.findItem(R.id.change_folder);
419    }
420
421    @Override
422    public boolean onOptionsItemSelected(MenuItem item) {
423        boolean handled = false;
424        switch (item.getItemId()) {
425            case R.id.inside_conversation_unread:
426                markUnread();
427                handled = true;
428                break;
429        }
430        return handled;
431    }
432
433    // BEGIN conversation header callbacks
434    @Override
435    public void onFoldersClicked() {
436        if (mChangeFoldersMenuItem == null) {
437            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
438            return;
439        }
440        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
441    }
442
443    @Override
444    public String getSubjectRemainder(String subject) {
445        final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
446        if (sdc == null) {
447            return subject;
448        }
449        return sdc.getUnshownSubject(subject);
450    }
451    // END conversation header callbacks
452
453    @Override
454    public void onSaveInstanceState(Bundle outState) {
455        if (mViewState != null) {
456            outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
457        }
458        outState.putBoolean(BUNDLE_USER_VISIBLE, mUserVisible);
459    }
460
461    @Override
462    public void onDestroyView() {
463        super.onDestroyView();
464        mAccountObserver.unregisterAndDestroy();
465        if (mMarkReadObserver != null) {
466            mActivity.getConversationUpdater().unregisterConversationListObserver(
467                    mMarkReadObserver);
468            mMarkReadObserver = null;
469        }
470    }
471
472    /**
473     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
474     * reliability on older platforms.
475     */
476    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
477        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
478        if (mUserVisible != isVisibleToUser) {
479            mUserVisible = isVisibleToUser;
480            MessageCursor cursor = getMessageCursor();
481            if (mUserVisible && (cursor != null && cursor.isLoaded() && cursor.getCount() == 0)) {
482                // Pop back to conversation list and show error.
483                onError();
484                return;
485            }
486            onUserVisibleHintChanged();
487        }
488    }
489
490    public boolean isUserVisible() {
491        return mUserVisible;
492    }
493
494    private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
495
496        @Override
497        public Loader<Cursor> onCreateLoader(int id, Bundle args) {
498            return new MessageLoader(mActivity.getActivityContext(), mConversation,
499                    AbstractConversationViewFragment.this);
500        }
501
502        @Override
503        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
504            // ignore truly duplicate results
505            // this can happen when restoring after rotation
506            if (mCursor == data) {
507                return;
508            } else {
509                MessageCursor messageCursor = (MessageCursor) data;
510
511                if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
512                    LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
513                }
514
515                // When the last cursor had message(s), and the new version has
516                // no messages, we need to exit conversation view.
517                if (messageCursor.getCount() == 0 && mCursor != null) {
518                    if (mUserVisible) {
519                        onError();
520                    } else {
521                        // we expect that the pager adapter will remove this
522                        // conversation fragment on its own due to a separate
523                        // conversation cursor update (we might get here if the
524                        // message list update fires first. nothing to do
525                        // because we expect to be torn down soon.)
526                        LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update"
527                                + " in anticipation of conv cursor update. c=%s", mConversation.uri);
528                    }
529                    return;
530                }
531
532                // ignore cursors that are still loading results
533                if (!messageCursor.isLoaded()) {
534                    return;
535                }
536                final MessageCursor oldCursor = mCursor;
537                mCursor = (MessageCursor) data;
538                onMessageCursorLoadFinished(loader, mCursor, oldCursor);
539            }
540        }
541
542        @Override
543        public void onLoaderReset(Loader<Cursor> loader) {
544            mCursor = null;
545        }
546
547    }
548
549    private void onError() {
550        // need to exit this view- conversation may have been
551        // deleted, or for whatever reason is now invalid (e.g.
552        // discard single draft)
553        //
554        // N.B. this may involve a fragment transaction, which
555        // FragmentManager will refuse to execute directly
556        // within onLoadFinished. Make sure the controller knows.
557        LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode");
558        // TODO(mindyp): handle ERROR status by showing an error
559        // message to the user that there are no messages in
560        // this conversation
561        mHandler.post(new Runnable() {
562
563            @Override
564            public void run() {
565                mActivity.getListHandler()
566                .onConversationSelected(null, true /* inLoaderCallbacks */);
567            }
568
569        });
570    }
571
572    protected void onConversationSeen() {
573        // Ignore unsafe calls made after a fragment is detached from an activity
574        final ControllableActivity activity = (ControllableActivity) getActivity();
575        if (activity == null) {
576            LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id);
577            return;
578        }
579
580        mViewState.setInfoForConversation(mConversation);
581
582        // mark viewed/read if not previously marked viewed by this conversation view,
583        // or if unread messages still exist in the message list cursor
584        // we don't want to keep marking viewed on rotation or restore
585        // but we do want future re-renders to mark read (e.g. "New message from X" case)
586        MessageCursor cursor = getMessageCursor();
587        if (!mConversation.isViewed() || (cursor != null && !cursor.isConversationRead())) {
588            final ConversationUpdater listController = activity.getConversationUpdater();
589            // The conversation cursor may not have finished loading by now (when launched via
590            // notification), so watch for when it finishes and mark it read then.
591            if (listController.getConversationListCursor() == null) {
592                LogUtils.i(LOG_TAG, "deferring conv mark read on open for id=%d",
593                        mConversation.id);
594                mMarkReadObserver = new MarkReadObserver(listController);
595                listController.registerConversationListObserver(mMarkReadObserver);
596            } else {
597                markReadOnSeen(listController);
598            }
599        }
600
601        activity.getListHandler().onConversationSeen(mConversation);
602    }
603
604    protected void markReadOnSeen(ConversationUpdater listController) {
605        // Mark the conversation viewed and read.
606        listController.markConversationsRead(Arrays.asList(mConversation), true /* read */,
607                true /* viewed */);
608
609        // and update the Message objects in the cursor so the next time a cursor update happens
610        // with these messages marked read, we know to ignore it
611        MessageCursor cursor = getMessageCursor();
612        if (cursor != null) {
613            cursor.markMessagesRead();
614        }
615    }
616
617    protected ConversationViewState getNewViewState() {
618        return new ConversationViewState();
619    }
620
621    private static class MessageLoader extends CursorLoader {
622        private boolean mDeliveredFirstResults = false;
623        private final Conversation mConversation;
624        private final ConversationController mController;
625
626        public MessageLoader(Context c, Conversation conv, ConversationController controller) {
627            super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
628            mConversation = conv;
629            mController = controller;
630        }
631
632        @Override
633        public Cursor loadInBackground() {
634            return new MessageCursor(super.loadInBackground(), mConversation, mController);
635        }
636
637        @Override
638        public void deliverResult(Cursor result) {
639            // We want to deliver these results, and then we want to make sure
640            // that any subsequent
641            // queries do not hit the network
642            super.deliverResult(result);
643
644            if (!mDeliveredFirstResults) {
645                mDeliveredFirstResults = true;
646                Uri uri = getUri();
647
648                // Create a ListParams that tells the provider to not hit the
649                // network
650                final ListParams listParams = new ListParams(ListParams.NO_LIMIT,
651                        false /* useNetwork */);
652
653                // Build the new uri with this additional parameter
654                uri = uri
655                        .buildUpon()
656                        .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER,
657                                listParams.serialize()).build();
658                setUri(uri);
659            }
660        }
661    }
662
663    /**
664     * Inner class to to asynchronously load contact data for all senders in the conversation,
665     * and notify observers when the data is ready.
666     *
667     */
668    protected class ContactLoaderCallbacks implements ContactInfoSource,
669            LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> {
670
671        private Set<String> mSenders;
672        private ImmutableMap<String, ContactInfo> mContactInfoMap;
673        private DataSetObservable mObservable = new DataSetObservable();
674
675        public void setSenders(Set<String> emailAddresses) {
676            mSenders = emailAddresses;
677        }
678
679        @Override
680        public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) {
681            return new SenderInfoLoader(mActivity.getActivityContext(), mSenders);
682        }
683
684        @Override
685        public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader,
686                ImmutableMap<String, ContactInfo> data) {
687            mContactInfoMap = data;
688            mObservable.notifyChanged();
689        }
690
691        @Override
692        public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) {
693        }
694
695        @Override
696        public ContactInfo getContactInfo(String email) {
697            if (mContactInfoMap == null) {
698                return null;
699            }
700            return mContactInfoMap.get(email);
701        }
702
703        @Override
704        public void registerObserver(DataSetObserver observer) {
705            mObservable.registerObserver(observer);
706        }
707
708        @Override
709        public void unregisterObserver(DataSetObserver observer) {
710            mObservable.unregisterObserver(observer);
711        }
712    }
713
714    protected class AbstractConversationWebViewClient extends WebViewClient {
715        @Override
716        public boolean shouldOverrideUrlLoading(WebView view, String url) {
717            final Activity activity = getActivity();
718            if (activity == null) {
719                return false;
720            }
721
722            boolean result = false;
723            final Intent intent;
724            Uri uri = Uri.parse(url);
725            if (!Utils.isEmpty(mAccount.viewIntentProxyUri)) {
726                intent = generateProxyIntent(uri);
727            } else {
728                intent = new Intent(Intent.ACTION_VIEW, uri);
729                intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName());
730            }
731
732            try {
733                intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
734                activity.startActivity(intent);
735                result = true;
736            } catch (ActivityNotFoundException ex) {
737                // If no application can handle the URL, assume that the
738                // caller can handle it.
739            }
740
741            return result;
742        }
743
744        private Intent generateProxyIntent(Uri uri) {
745            final Intent intent = new Intent(Intent.ACTION_VIEW, mAccount.viewIntentProxyUri);
746            intent.putExtra(UIProvider.ViewProxyExtras.EXTRA_ORIGINAL_URI, uri);
747            intent.putExtra(UIProvider.ViewProxyExtras.EXTRA_ACCOUNT, mAccount);
748
749            final Context context = getContext();
750            PackageManager manager = null;
751            // We need to catch the exception to make CanvasConversationHeaderView
752            // test pass.  Bug: http://b/issue?id=3470653.
753            try {
754                manager = context.getPackageManager();
755            } catch (UnsupportedOperationException e) {
756                LogUtils.e(LOG_TAG, e, "Error getting package manager");
757            }
758
759            if (manager != null) {
760                // Try and resolve the intent, to find an activity from this package
761                final List<ResolveInfo> resolvedActivities = manager.queryIntentActivities(
762                        intent, PackageManager.MATCH_DEFAULT_ONLY);
763
764                final String packageName = context.getPackageName();
765
766                // Now try and find one that came from this package, if one is not found, the UI
767                // provider must have specified an intent that is to be handled by a different apk.
768                // In that case, the class name will not be set on the intent, so the default
769                // intent resolution will be used.
770                for (ResolveInfo resolveInfo: resolvedActivities) {
771                    final ActivityInfo activityInfo = resolveInfo.activityInfo;
772                    if (packageName.equals(activityInfo.packageName)) {
773                        intent.setClassName(activityInfo.packageName, activityInfo.name);
774                        break;
775                    }
776                }
777            }
778
779            return intent;
780        }
781    }
782
783    private class MarkReadObserver extends DataSetObserver {
784        private final ConversationUpdater mListController;
785
786        private MarkReadObserver(ConversationUpdater listController) {
787            mListController = listController;
788        }
789
790        @Override
791        public void onChanged() {
792            if (mListController.getConversationListCursor() == null) {
793                // nothing yet, keep watching
794                return;
795            }
796            // done loading, safe to mark read now
797            mListController.unregisterConversationListObserver(this);
798            mMarkReadObserver = null;
799            LogUtils.i(LOG_TAG, "running deferred conv mark read on open, id=%d", mConversation.id);
800            markReadOnSeen(mListController);
801        }
802    }
803
804    public abstract void onConversationUpdated(Conversation conversation);
805
806    /**
807     * Small Runnable-like wrapper that first checks that the Fragment is in a good state before
808     * doing any work. Ideal for use with a {@link Handler}.
809     */
810    protected abstract class FragmentRunnable implements Runnable {
811
812        private final String mOpName;
813
814        public FragmentRunnable(String opName) {
815            mOpName = opName;
816        }
817
818        public abstract void go();
819
820        @Override
821        public void run() {
822            if (!isAdded()) {
823                LogUtils.i(LOG_TAG, "Unable to run op='%s' b/c fragment is not attached: %s",
824                        mOpName, AbstractConversationViewFragment.this);
825                return;
826            }
827            go();
828        }
829
830    }
831
832}
833