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