AbstractConversationViewFragment.java revision e8221483ce5f013f02a4ef63d6682cc36313cda7
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.app.Activity;
21import android.app.Fragment;
22import android.app.LoaderManager;
23import android.content.Context;
24import android.content.Loader;
25import android.database.Cursor;
26import android.net.Uri;
27import android.os.Build;
28import android.os.Bundle;
29import android.os.Handler;
30import android.view.Menu;
31import android.view.MenuInflater;
32import android.view.MenuItem;
33
34import com.android.mail.R;
35import com.android.mail.analytics.Analytics;
36import com.android.mail.browse.ConversationAccountController;
37import com.android.mail.browse.ConversationMessage;
38import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
39import com.android.mail.browse.MessageCursor;
40import com.android.mail.browse.MessageCursor.ConversationController;
41import com.android.mail.content.ObjectCursor;
42import com.android.mail.content.ObjectCursorLoader;
43import com.android.mail.preferences.AccountPreferences;
44import com.android.mail.providers.Account;
45import com.android.mail.providers.AccountObserver;
46import com.android.mail.providers.Address;
47import com.android.mail.providers.Conversation;
48import com.android.mail.providers.ListParams;
49import com.android.mail.providers.UIProvider;
50import com.android.mail.providers.UIProvider.CursorStatus;
51import com.android.mail.utils.LogTag;
52import com.android.mail.utils.LogUtils;
53import com.android.mail.utils.Utils;
54
55import java.util.Arrays;
56import java.util.Collections;
57import java.util.HashMap;
58import java.util.Map;
59
60
61public abstract class AbstractConversationViewFragment extends Fragment implements
62        ConversationController, ConversationAccountController,
63        ConversationViewHeaderCallbacks {
64
65    protected static final String ARG_ACCOUNT = "account";
66    public static final String ARG_CONVERSATION = "conversation";
67    private static final String LOG_TAG = LogTag.getLogTag();
68    protected static final int MESSAGE_LOADER = 0;
69    protected static final int CONTACT_LOADER = 1;
70    protected ControllableActivity mActivity;
71    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
72    private ContactLoaderCallbacks mContactLoaderCallbacks;
73    private MenuItem mChangeFoldersMenuItem;
74    protected Conversation mConversation;
75    protected String mBaseUri;
76    protected Account mAccount;
77
78    /**
79     * Must be instantiated in a derived class's onCreate.
80     */
81    protected AbstractConversationWebViewClient mWebViewClient;
82
83    /**
84     * Cache of email address strings to parsed Address objects.
85     * <p>
86     * Remember to synchronize on the map when reading or writing to this cache, because some
87     * instances use it off the UI thread (e.g. from WebView).
88     */
89    protected final Map<String, Address> mAddressCache = Collections.synchronizedMap(
90            new HashMap<String, Address>());
91    private MessageCursor mCursor;
92    private Context mContext;
93    /**
94     * A backwards-compatible version of {{@link #getUserVisibleHint()}. Like the framework flag,
95     * this flag is saved and restored.
96     */
97    private boolean mUserVisible;
98
99    private final Handler mHandler = new Handler();
100    /** True if we want to avoid marking the conversation as viewed and read. */
101    private boolean mSuppressMarkingViewed;
102    /**
103     * Parcelable state of the conversation view. Can safely be used without null checking any time
104     * after {@link #onCreate(Bundle)}.
105     */
106    protected ConversationViewState mViewState;
107
108    private boolean mIsDetached;
109
110    private boolean mHasConversationBeenTransformed;
111    private boolean mHasConversationTransformBeenReverted;
112
113    private final AccountObserver mAccountObserver = new AccountObserver() {
114        @Override
115        public void onChanged(Account newAccount) {
116            final Account oldAccount = mAccount;
117            mAccount = newAccount;
118            mWebViewClient.setAccount(mAccount);
119            onAccountChanged(newAccount, oldAccount);
120        }
121    };
122
123    private static final String BUNDLE_VIEW_STATE =
124            AbstractConversationViewFragment.class.getName() + "viewstate";
125    /**
126     * We save the user visible flag so the various transitions that occur during rotation do not
127     * cause unnecessary visibility change.
128     */
129    private static final String BUNDLE_USER_VISIBLE =
130            AbstractConversationViewFragment.class.getName() + "uservisible";
131
132    private static final String BUNDLE_DETACHED =
133            AbstractConversationViewFragment.class.getName() + "detached";
134
135    private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED =
136            AbstractConversationViewFragment.class.getName() + "conversationtransformed";
137    private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED =
138            AbstractConversationViewFragment.class.getName() + "conversationreverted";
139
140    public static Bundle makeBasicArgs(Account account) {
141        Bundle args = new Bundle();
142        args.putParcelable(ARG_ACCOUNT, account);
143        return args;
144    }
145
146    /**
147     * Constructor needs to be public to handle orientation changes and activity
148     * lifecycle events.
149     */
150    public AbstractConversationViewFragment() {
151        super();
152    }
153
154    /**
155     * Subclasses must override, since this depends on how many messages are
156     * shown in the conversation view.
157     */
158    protected void markUnread() {
159        // Do not automatically mark this conversation viewed and read.
160        mSuppressMarkingViewed = true;
161    }
162
163    /**
164     * Subclasses must override this, since they may want to display a single or
165     * many messages related to this conversation.
166     */
167    protected abstract void onMessageCursorLoadFinished(
168            Loader<ObjectCursor<ConversationMessage>> loader,
169            MessageCursor newCursor, MessageCursor oldCursor);
170
171    /**
172     * Subclasses must override this, since they may want to display a single or
173     * many messages related to this conversation.
174     */
175    @Override
176    public abstract void onConversationViewHeaderHeightChange(int newHeight);
177
178    public abstract void onUserVisibleHintChanged();
179
180    /**
181     * Subclasses must override this.
182     */
183    protected abstract void onAccountChanged(Account newAccount, Account oldAccount);
184
185    @Override
186    public void onCreate(Bundle savedState) {
187        super.onCreate(savedState);
188
189        parseArguments();
190        setBaseUri();
191
192        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
193        // Not really, we just want to get a crack to store a reference to the change_folder item
194        setHasOptionsMenu(true);
195
196        if (savedState != null) {
197            mViewState = savedState.getParcelable(BUNDLE_VIEW_STATE);
198            mUserVisible = savedState.getBoolean(BUNDLE_USER_VISIBLE);
199            mIsDetached = savedState.getBoolean(BUNDLE_DETACHED, false);
200            mHasConversationBeenTransformed =
201                    savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED, false);
202            mHasConversationTransformBeenReverted =
203                    savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED, false);
204        } else {
205            mViewState = getNewViewState();
206            mHasConversationBeenTransformed = false;
207            mHasConversationTransformBeenReverted = false;
208        }
209    }
210
211    /**
212     * Can be overridden in case a subclass needs to get additional arguments.
213     */
214    protected void parseArguments() {
215        final Bundle args = getArguments();
216        mAccount = args.getParcelable(ARG_ACCOUNT);
217        mConversation = args.getParcelable(ARG_CONVERSATION);
218    }
219
220    /**
221     * Can be overridden in case a subclass needs a different uri format
222     * (such as one that does not rely on account and/or conversation.
223     */
224    protected void setBaseUri() {
225        // Since the uri specified in the conversation base uri may not be unique, we specify a
226        // base uri that us guaranteed to be unique for this conversation.
227        mBaseUri = "x-thread://" + mAccount.name.hashCode() + "/" + mConversation.id;
228    }
229
230    @Override
231    public String toString() {
232        // log extra info at DEBUG level or finer
233        final String s = super.toString();
234        if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
235            return s;
236        }
237        return "(" + s + " conv=" + mConversation + ")";
238    }
239
240    @Override
241    public void onActivityCreated(Bundle savedInstanceState) {
242        super.onActivityCreated(savedInstanceState);
243        final Activity activity = getActivity();
244        if (!(activity instanceof ControllableActivity)) {
245            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
246                    + "create it. Cannot proceed.");
247        }
248        if (activity == null || activity.isFinishing()) {
249            // Activity is finishing, just bail.
250            return;
251        }
252        mActivity = (ControllableActivity) activity;
253        mContext = activity.getApplicationContext();
254        mWebViewClient.setActivity(activity);
255        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
256        mWebViewClient.setAccount(mAccount);
257    }
258
259    @Override
260    public ConversationUpdater getListController() {
261        final ControllableActivity activity = (ControllableActivity) getActivity();
262        return activity != null ? activity.getConversationUpdater() : null;
263    }
264
265    public Context getContext() {
266        return mContext;
267    }
268
269    @Override
270    public Conversation getConversation() {
271        return mConversation;
272    }
273
274    @Override
275    public MessageCursor getMessageCursor() {
276        return mCursor;
277    }
278
279    public Handler getHandler() {
280        return mHandler;
281    }
282
283    public MessageLoaderCallbacks getMessageLoaderCallbacks() {
284        return mMessageLoaderCallbacks;
285    }
286
287    public ContactLoaderCallbacks getContactInfoSource() {
288        if (mContactLoaderCallbacks == null) {
289            mContactLoaderCallbacks = new ContactLoaderCallbacks(mActivity.getActivityContext());
290        }
291        return mContactLoaderCallbacks;
292    }
293
294    @Override
295    public Account getAccount() {
296        return mAccount;
297    }
298
299    @Override
300    public AccountPreferences getAccountPreferences() {
301        return AccountPreferences.get(getContext(), mAccount.name);
302    }
303
304    @Override
305    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
306        super.onCreateOptionsMenu(menu, inflater);
307        mChangeFoldersMenuItem = menu.findItem(R.id.change_folders);
308    }
309
310    @Override
311    public boolean onOptionsItemSelected(MenuItem item) {
312        if (!isUserVisible()) {
313            // Unclear how this is happening. Current theory is that this fragment was scheduled
314            // to be removed, but the remove transaction failed. When the Activity is later
315            // restored, the FragmentManager restores this fragment, but Fragment.mMenuVisible is
316            // stuck at its initial value (true), which makes this zombie fragment eligible for
317            // menu item clicks.
318            //
319            // Work around this by relying on the (properly restored) extra user visible hint.
320            LogUtils.e(LOG_TAG,
321                    "ACVF ignoring onOptionsItemSelected b/c userVisibleHint is false. f=%s", this);
322            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
323                LogUtils.e(LOG_TAG, Utils.dumpFragment(this));  // the dump has '%' chars in it...
324            }
325            return false;
326        }
327
328        boolean handled = false;
329        final int itemId = item.getItemId();
330        if (itemId == R.id.inside_conversation_unread) {
331            markUnread();
332            handled = true;
333        } else if (itemId == R.id.show_original) {
334            showUntransformedConversation();
335            handled = true;
336        } else if (itemId == R.id.print) {
337            printConversation();
338            handled = true;
339        }
340        return handled;
341    }
342
343    @Override
344    public void onPrepareOptionsMenu(Menu menu) {
345        // Only show option if we support message transforms and message has been transformed.
346        Utils.setMenuItemVisibility(menu, R.id.show_original, supportsMessageTransforms() &&
347                mHasConversationBeenTransformed && !mHasConversationTransformBeenReverted);
348        Utils.setMenuItemVisibility(menu, R.id.print, Utils.isRunningKitkatOrLater());
349    }
350
351    abstract boolean supportsMessageTransforms();
352
353    // BEGIN conversation header callbacks
354    @Override
355    public void onFoldersClicked() {
356        if (mChangeFoldersMenuItem == null) {
357            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
358            return;
359        }
360        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
361    }
362    // END conversation header callbacks
363
364    @Override
365    public void onStart() {
366        super.onStart();
367
368        Analytics.getInstance().sendView(getClass().getName());
369    }
370
371    @Override
372    public void onSaveInstanceState(Bundle outState) {
373        if (mViewState != null) {
374            outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
375        }
376        outState.putBoolean(BUNDLE_USER_VISIBLE, mUserVisible);
377        outState.putBoolean(BUNDLE_DETACHED, mIsDetached);
378        outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED,
379                mHasConversationBeenTransformed);
380        outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED,
381                mHasConversationTransformBeenReverted);
382    }
383
384    @Override
385    public void onDestroyView() {
386        super.onDestroyView();
387        mAccountObserver.unregisterAndDestroy();
388    }
389
390    /**
391     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
392     * reliability on older platforms.
393     */
394    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
395        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
396        if (mUserVisible != isVisibleToUser) {
397            mUserVisible = isVisibleToUser;
398            MessageCursor cursor = getMessageCursor();
399            if (mUserVisible && (cursor != null && cursor.isLoaded() && cursor.getCount() == 0)) {
400                // Pop back to conversation list and show error.
401                onError();
402                return;
403            }
404            onUserVisibleHintChanged();
405        }
406    }
407
408    public boolean isUserVisible() {
409        return mUserVisible;
410    }
411
412    protected void timerMark(String msg) {
413        if (isUserVisible()) {
414            Utils.sConvLoadTimer.mark(msg);
415        }
416    }
417
418    private class MessageLoaderCallbacks
419            implements LoaderManager.LoaderCallbacks<ObjectCursor<ConversationMessage>> {
420
421        @Override
422        public Loader<ObjectCursor<ConversationMessage>> onCreateLoader(int id, Bundle args) {
423            return new MessageLoader(mActivity.getActivityContext(), mConversation.messageListUri);
424        }
425
426        @Override
427        public void onLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader,
428                    ObjectCursor<ConversationMessage> data) {
429            // ignore truly duplicate results
430            // this can happen when restoring after rotation
431            if (mCursor == data) {
432                return;
433            } else {
434                final MessageCursor messageCursor = (MessageCursor) data;
435
436                // bind the cursor to this fragment so it can access to the current list controller
437                messageCursor.setController(AbstractConversationViewFragment.this);
438
439                if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
440                    LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
441                }
442
443                // We have no messages: exit conversation view.
444                if (messageCursor.getCount() == 0
445                        && (!CursorStatus.isWaitingForResults(messageCursor.getStatus())
446                                || mIsDetached)) {
447                    if (mUserVisible) {
448                        onError();
449                    } else {
450                        // we expect that the pager adapter will remove this
451                        // conversation fragment on its own due to a separate
452                        // conversation cursor update (we might get here if the
453                        // message list update fires first. nothing to do
454                        // because we expect to be torn down soon.)
455                        LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update"
456                                + " in anticipation of conv cursor update. c=%s",
457                                mConversation.uri);
458                    }
459                    // existing mCursor will imminently be closed, must stop referencing it
460                    // since we expect to be kicked out soon, it doesn't matter what mCursor
461                    // becomes
462                    mCursor = null;
463                    return;
464                }
465
466                // ignore cursors that are still loading results
467                if (!messageCursor.isLoaded()) {
468                    // existing mCursor will imminently be closed, must stop referencing it
469                    // in this case, the new cursor is also no good, and since don't expect to get
470                    // here except in initial load situations, it's safest to just ensure the
471                    // reference is null
472                    mCursor = null;
473                    return;
474                }
475                final MessageCursor oldCursor = mCursor;
476                mCursor = messageCursor;
477                onMessageCursorLoadFinished(loader, mCursor, oldCursor);
478            }
479        }
480
481        @Override
482        public void onLoaderReset(Loader<ObjectCursor<ConversationMessage>>  loader) {
483            mCursor = null;
484        }
485
486    }
487
488    private void onError() {
489        // need to exit this view- conversation may have been
490        // deleted, or for whatever reason is now invalid (e.g.
491        // discard single draft)
492        //
493        // N.B. this may involve a fragment transaction, which
494        // FragmentManager will refuse to execute directly
495        // within onLoadFinished. Make sure the controller knows.
496        LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode");
497        // TODO(mindyp): handle ERROR status by showing an error
498        // message to the user that there are no messages in
499        // this conversation
500        popOut();
501    }
502
503    private void popOut() {
504        mHandler.post(new FragmentRunnable("popOut", this) {
505            @Override
506            public void go() {
507                if (mActivity != null) {
508                    mActivity.getListHandler()
509                            .onConversationSelected(null, true /* inLoaderCallbacks */);
510                }
511            }
512        });
513    }
514
515    protected void onConversationSeen() {
516        LogUtils.d(LOG_TAG, "AbstractConversationViewFragment#onConversationSeen()");
517
518        // Ignore unsafe calls made after a fragment is detached from an activity
519        final ControllableActivity activity = (ControllableActivity) getActivity();
520        if (activity == null) {
521            LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id);
522            return;
523        }
524
525        mViewState.setInfoForConversation(mConversation);
526
527        LogUtils.d(LOG_TAG, "onConversationSeen() - mSuppressMarkingViewed = %b",
528                mSuppressMarkingViewed);
529        // In most circumstances we want to mark the conversation as viewed and read, since the
530        // user has read it.  However, if the user has already marked the conversation unread, we
531        // do not want a  later mark-read operation to undo this.  So we check this variable which
532        // is set in #markUnread() which suppresses automatic mark-read.
533        if (!mSuppressMarkingViewed) {
534            // mark viewed/read if not previously marked viewed by this conversation view,
535            // or if unread messages still exist in the message list cursor
536            // we don't want to keep marking viewed on rotation or restore
537            // but we do want future re-renders to mark read (e.g. "New message from X" case)
538            final MessageCursor cursor = getMessageCursor();
539            LogUtils.d(LOG_TAG, "onConversationSeen() - mConversation.isViewed() = %b, "
540                    + "cursor null = %b, cursor.isConversationRead() = %b",
541                    mConversation.isViewed(), cursor == null,
542                    cursor != null && cursor.isConversationRead());
543            if (!mConversation.isViewed() || (cursor != null && !cursor.isConversationRead())) {
544                // Mark the conversation viewed and read.
545                activity.getConversationUpdater()
546                        .markConversationsRead(Arrays.asList(mConversation), true, true);
547
548                // and update the Message objects in the cursor so the next time a cursor update
549                // happens with these messages marked read, we know to ignore it
550                if (cursor != null && !cursor.isClosed()) {
551                    cursor.markMessagesRead();
552                }
553            }
554        }
555        activity.getListHandler().onConversationSeen();
556    }
557
558    protected ConversationViewState getNewViewState() {
559        return new ConversationViewState();
560    }
561
562    private static class MessageLoader extends ObjectCursorLoader<ConversationMessage> {
563        private boolean mDeliveredFirstResults = false;
564
565        public MessageLoader(Context c, Uri messageListUri) {
566            super(c, messageListUri, UIProvider.MESSAGE_PROJECTION, ConversationMessage.FACTORY);
567        }
568
569        @Override
570        public void deliverResult(ObjectCursor<ConversationMessage> result) {
571            // We want to deliver these results, and then we want to make sure
572            // that any subsequent
573            // queries do not hit the network
574            super.deliverResult(result);
575
576            if (!mDeliveredFirstResults) {
577                mDeliveredFirstResults = true;
578                Uri uri = getUri();
579
580                // Create a ListParams that tells the provider to not hit the
581                // network
582                final ListParams listParams = new ListParams(ListParams.NO_LIMIT,
583                        false /* useNetwork */);
584
585                // Build the new uri with this additional parameter
586                uri = uri
587                        .buildUpon()
588                        .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER,
589                                listParams.serialize()).build();
590                setUri(uri);
591            }
592        }
593
594        @Override
595        protected ObjectCursor<ConversationMessage> getObjectCursor(Cursor inner) {
596            return new MessageCursor(inner);
597        }
598    }
599
600    public abstract void onConversationUpdated(Conversation conversation);
601
602    public void onDetachedModeEntered() {
603        // If we have no messages, then we have nothing to display, so leave this view.
604        // Otherwise, just set the detached flag.
605        final Cursor messageCursor = getMessageCursor();
606
607        if (messageCursor == null || messageCursor.getCount() == 0) {
608            popOut();
609        } else {
610            mIsDetached = true;
611        }
612    }
613
614    /**
615     * Called when the JavaScript reports that it transformed a message.
616     * Sets a flag to true and invalidates the options menu so it will
617     * include the "Revert auto-sizing" menu option.
618     */
619    public void onConversationTransformed() {
620        mHasConversationBeenTransformed = true;
621        mHandler.post(new FragmentRunnable("invalidateOptionsMenu", this) {
622            @Override
623            public void go() {
624                mActivity.invalidateOptionsMenu();
625            }
626        });
627    }
628
629    /**
630     * Called when the "Revert auto-sizing" option is selected. Default
631     * implementation simply sets a value on whether transforms should be
632     * applied. Derived classes should override this class and force a
633     * re-render so that the conversation renders without
634     */
635    public void showUntransformedConversation() {
636        // must set the value to true so we don't show the options menu item again
637        mHasConversationTransformBeenReverted = true;
638    }
639
640    /**
641     * Returns {@code true} if the conversation should be transformed. {@code false}, otherwise.
642     * @return {@code true} if the conversation should be transformed. {@code false}, otherwise.
643     */
644    public boolean shouldApplyTransforms() {
645        return (mAccount.enableMessageTransforms > 0) &&
646                !mHasConversationTransformBeenReverted;
647    }
648
649    protected abstract void printConversation();
650}
651