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