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