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 
18 package com.android.mail.ui;
19 
20 import android.app.Activity;
21 import android.app.Fragment;
22 import android.app.LoaderManager;
23 import android.content.Context;
24 import android.content.Loader;
25 import android.database.Cursor;
26 import android.net.Uri;
27 import android.os.Bundle;
28 import android.os.Handler;
29 import android.support.annotation.Nullable;
30 import android.view.Menu;
31 import android.view.MenuInflater;
32 import android.view.MenuItem;
33 
34 import com.android.emailcommon.mail.Address;
35 import com.android.mail.R;
36 import com.android.mail.analytics.Analytics;
37 import com.android.mail.browse.ConversationAccountController;
38 import com.android.mail.browse.ConversationMessage;
39 import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
40 import com.android.mail.browse.MessageCursor;
41 import com.android.mail.browse.MessageCursor.ConversationController;
42 import com.android.mail.content.ObjectCursor;
43 import com.android.mail.content.ObjectCursorLoader;
44 import com.android.mail.providers.Account;
45 import com.android.mail.providers.AccountObserver;
46 import com.android.mail.providers.Conversation;
47 import com.android.mail.providers.Folder;
48 import com.android.mail.providers.ListParams;
49 import com.android.mail.providers.Settings;
50 import com.android.mail.providers.UIProvider;
51 import com.android.mail.providers.UIProvider.CursorStatus;
52 import com.android.mail.utils.LogTag;
53 import com.android.mail.utils.LogUtils;
54 import com.android.mail.utils.Utils;
55 
56 import java.util.Arrays;
57 import java.util.Collections;
58 import java.util.HashMap;
59 import java.util.Map;
60 
61 public 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 
makeBasicArgs(Account account)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      */
AbstractConversationViewFragment()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      */
markUnread()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      */
markRead(boolean force)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      */
onMessageCursorLoadFinished( Loader<ObjectCursor<ConversationMessage>> loader, MessageCursor newCursor, MessageCursor oldCursor)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
onConversationViewHeaderHeightChange(int newHeight)223     public abstract void onConversationViewHeaderHeightChange(int newHeight);
224 
onUserVisibleHintChanged()225     public abstract void onUserVisibleHintChanged();
226 
227     /**
228      * Subclasses must override this.
229      */
onAccountChanged(Account newAccount, Account oldAccount)230     protected abstract void onAccountChanged(Account newAccount, Account oldAccount);
231 
232     @Override
onCreate(Bundle savedState)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      */
parseArguments()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      */
setBaseUri()271     protected void setBaseUri() {
272         mBaseUri = buildBaseUri(getContext(), mAccount, mConversation);
273     }
274 
buildBaseUri(Context context, Account account, Conversation conversation)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
toString()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
onActivityCreated(Bundle savedInstanceState)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
getListController()311     public ConversationUpdater getListController() {
312         final ControllableActivity activity = (ControllableActivity) getActivity();
313         return activity != null ? activity.getConversationUpdater() : null;
314     }
315 
getContext()316     public Context getContext() {
317         return mContext;
318     }
319 
320     @Override
getConversation()321     public Conversation getConversation() {
322         return mConversation;
323     }
324 
325     @Override
getMessageCursor()326     public @Nullable MessageCursor getMessageCursor() {
327         return mCursor;
328     }
329 
getHandler()330     public Handler getHandler() {
331         return mHandler;
332     }
333 
getMessageLoaderCallbacks()334     public MessageLoaderCallbacks getMessageLoaderCallbacks() {
335         return mMessageLoaderCallbacks;
336     }
337 
getContactInfoSource()338     public ContactLoaderCallbacks getContactInfoSource() {
339         if (mContactLoaderCallbacks == null) {
340             mContactLoaderCallbacks = mActivity.getContactLoaderCallbacks();
341         }
342         return mContactLoaderCallbacks;
343     }
344 
345     @Override
getAccount()346     public Account getAccount() {
347         return mAccount;
348     }
349 
350     @Override
onCreateOptionsMenu(Menu menu, MenuInflater inflater)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
onOptionsItemSelected(MenuItem item)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
onPrepareOptionsMenu(Menu menu)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 
supportsMessageTransforms()415     abstract boolean supportsMessageTransforms();
416 
417     // BEGIN conversation header callbacks
418     @Override
onFoldersClicked()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
onStart()429     public void onStart() {
430         super.onStart();
431 
432         Analytics.getInstance().sendView(getClass().getName());
433     }
434 
435     @Override
onSaveInstanceState(Bundle outState)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
onDestroyView()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      */
setExtraUserVisibleHint(boolean isVisibleToUser)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 
isUserVisible()472     public boolean isUserVisible() {
473         return mUserVisible;
474     }
475 
timerMark(String msg)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
onCreateLoader(int id, Bundle args)486         public Loader<ObjectCursor<ConversationMessage>> onCreateLoader(int id, Bundle args) {
487             return new MessageLoader(mActivity.getActivityContext(), mConversation.messageListUri);
488         }
489 
490         @Override
onLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader, ObjectCursor<ConversationMessage> data)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
onLoaderReset(Loader<ObjectCursor<ConversationMessage>> loader)546         public void onLoaderReset(Loader<ObjectCursor<ConversationMessage>>  loader) {
547             mCursor = null;
548         }
549 
550     }
551 
onError()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 
popOut()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      */
getCurrentFolderTypeDesc()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 
logConversationView()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 
onConversationSeen()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 
getNewViewState()637     protected ConversationViewState getNewViewState() {
638         return new ConversationViewState();
639     }
640 
641     private static class MessageLoader extends ObjectCursorLoader<ConversationMessage> {
642         private boolean mDeliveredFirstResults = false;
643 
MessageLoader(Context c, Uri messageListUri)644         public MessageLoader(Context c, Uri messageListUri) {
645             super(c, messageListUri, UIProvider.MESSAGE_PROJECTION, ConversationMessage.FACTORY);
646         }
647 
648         @Override
deliverResult(ObjectCursor<ConversationMessage> result)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
getObjectCursor(Cursor inner)674         protected ObjectCursor<ConversationMessage> getObjectCursor(Cursor inner) {
675             return new MessageCursor(inner);
676         }
677     }
678 
onConversationUpdated(Conversation conversation)679     public abstract void onConversationUpdated(Conversation conversation);
680 
onDetachedModeEntered()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      */
onConversationTransformed()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      */
showUntransformedConversation()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      */
shouldApplyTransforms()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      */
shouldShowPrintInOverflow()734     protected abstract boolean shouldShowPrintInOverflow();
735 
736     /**
737      * Prints all messages in the conversation.
738      */
printConversation()739     protected abstract void printConversation();
740 
741     // These methods should perform default reply/replyall action on the last message.
handleReply()742     protected abstract void handleReply();
handleReplyAll()743     protected abstract void handleReplyAll();
744 
shouldAlwaysShowImages()745     public boolean shouldAlwaysShowImages() {
746         return (mAccount != null) && (mAccount.settings.showImages == Settings.ShowImages.ALWAYS);
747     }
748 }
749