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