• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.content.ContentResolver;
21 import android.content.Context;
22 import android.content.Loader;
23 import android.content.res.Resources;
24 import android.database.Cursor;
25 import android.database.DataSetObserver;
26 import android.graphics.Rect;
27 import android.net.Uri;
28 import android.os.AsyncTask;
29 import android.os.Bundle;
30 import android.os.SystemClock;
31 import android.support.v4.text.BidiFormatter;
32 import android.support.v4.util.ArrayMap;
33 import android.text.TextUtils;
34 import android.view.KeyEvent;
35 import android.view.LayoutInflater;
36 import android.view.View;
37 import android.view.View.OnLayoutChangeListener;
38 import android.view.ViewGroup;
39 import android.webkit.ConsoleMessage;
40 import android.webkit.CookieManager;
41 import android.webkit.CookieSyncManager;
42 import android.webkit.JavascriptInterface;
43 import android.webkit.WebChromeClient;
44 import android.webkit.WebSettings;
45 import android.webkit.WebView;
46 import android.widget.Button;
47 
48 import com.android.emailcommon.mail.Address;
49 import com.android.mail.FormattedDateBuilder;
50 import com.android.mail.R;
51 import com.android.mail.analytics.Analytics;
52 import com.android.mail.analytics.AnalyticsTimer;
53 import com.android.mail.browse.ConversationContainer;
54 import com.android.mail.browse.ConversationContainer.OverlayPosition;
55 import com.android.mail.browse.ConversationFooterView.ConversationFooterCallbacks;
56 import com.android.mail.browse.ConversationMessage;
57 import com.android.mail.browse.ConversationOverlayItem;
58 import com.android.mail.browse.ConversationViewAdapter;
59 import com.android.mail.browse.ConversationViewAdapter.ConversationFooterItem;
60 import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem;
61 import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
62 import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
63 import com.android.mail.browse.ConversationViewHeader;
64 import com.android.mail.browse.ConversationWebView;
65 import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreator;
66 import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreatorHolder;
67 import com.android.mail.browse.MailWebView.ContentSizeChangeListener;
68 import com.android.mail.browse.MessageCursor;
69 import com.android.mail.browse.MessageFooterView;
70 import com.android.mail.browse.MessageHeaderView;
71 import com.android.mail.browse.ScrollIndicatorsView;
72 import com.android.mail.browse.SuperCollapsedBlock;
73 import com.android.mail.browse.WebViewContextMenu;
74 import com.android.mail.content.ObjectCursor;
75 import com.android.mail.print.PrintUtils;
76 import com.android.mail.providers.Account;
77 import com.android.mail.providers.Conversation;
78 import com.android.mail.providers.Message;
79 import com.android.mail.providers.Settings;
80 import com.android.mail.providers.UIProvider;
81 import com.android.mail.ui.ConversationViewState.ExpansionState;
82 import com.android.mail.utils.ConversationViewUtils;
83 import com.android.mail.utils.LogTag;
84 import com.android.mail.utils.LogUtils;
85 import com.android.mail.utils.Utils;
86 import com.google.common.collect.ImmutableList;
87 import com.google.common.collect.Lists;
88 import com.google.common.collect.Maps;
89 import com.google.common.collect.Sets;
90 
91 import java.util.ArrayList;
92 import java.util.List;
93 import java.util.Map;
94 import java.util.Set;
95 
96 /**
97  * The conversation view UI component.
98  */
99 public class ConversationViewFragment extends AbstractConversationViewFragment implements
100         SuperCollapsedBlock.OnClickListener, OnLayoutChangeListener,
101         MessageHeaderView.MessageHeaderViewCallbacks, MessageFooterView.MessageFooterCallbacks,
102         WebViewContextMenu.Callbacks, ConversationFooterCallbacks, View.OnKeyListener {
103 
104     private static final String LOG_TAG = LogTag.getLogTag();
105     public static final String LAYOUT_TAG = "ConvLayout";
106 
107     /**
108      * Difference in the height of the message header whose details have been expanded/collapsed
109      */
110     private int mDiff = 0;
111 
112     /**
113      * Default value for {@link #mLoadWaitReason}. Conversation load will happen immediately.
114      */
115     private final int LOAD_NOW = 0;
116     /**
117      * Value for {@link #mLoadWaitReason} that means we are offscreen and waiting for the visible
118      * conversation to finish loading before beginning our load.
119      * <p>
120      * When this value is set, the fragment should register with {@link ConversationListCallbacks}
121      * to know when the visible conversation is loaded. When it is unset, it should unregister.
122      */
123     private final int LOAD_WAIT_FOR_INITIAL_CONVERSATION = 1;
124     /**
125      * Value for {@link #mLoadWaitReason} used when a conversation is too heavyweight to load at
126      * all when not visible (e.g. requires network fetch, or too complex). Conversation load will
127      * wait until this fragment is visible.
128      */
129     private final int LOAD_WAIT_UNTIL_VISIBLE = 2;
130 
131     // Keyboard navigation
132     private KeyboardNavigationController mNavigationController;
133     // Since we manually control navigation for most of the conversation view due to problems
134     // with two-pane layout but still rely on the system for SOME navigation, we need to keep track
135     // of the view that had focus when KeyEvent.ACTION_DOWN was fired. This is because we only
136     // manually change focus on KeyEvent.ACTION_UP (to prevent holding down the DOWN button and
137     // lagging the app), however, the view in focus might have changed between ACTION_UP and
138     // ACTION_DOWN since the system might have handled the ACTION_DOWN and moved focus.
139     private View mOriginalKeyedView;
140     private int mMaxScreenHeight;
141     private int mTopOfVisibleScreen;
142 
143     protected ConversationContainer mConversationContainer;
144 
145     protected ConversationWebView mWebView;
146 
147     private ViewGroup mTopmostOverlay;
148 
149     private ConversationViewProgressController mProgressController;
150 
151     private Button mNewMessageBar;
152 
153     protected HtmlConversationTemplates mTemplates;
154 
155     private final MailJsBridge mJsBridge = new MailJsBridge();
156 
157     protected ConversationViewAdapter mAdapter;
158 
159     protected boolean mViewsCreated;
160     // True if we attempted to render before the views were laid out
161     // We will render immediately once layout is done
162     private boolean mNeedRender;
163 
164     /**
165      * Temporary string containing the message bodies of the messages within a super-collapsed
166      * block, for one-time use during block expansion. We cannot easily pass the body HTML
167      * into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it
168      * using {@link MailJsBridge}.
169      */
170     private String mTempBodiesHtml;
171 
172     private int  mMaxAutoLoadMessages;
173 
174     protected int mSideMarginPx;
175 
176     /**
177      * If this conversation fragment is not visible, and it's inappropriate to load up front,
178      * this is the reason we are waiting. This flag should be cleared once it's okay to load
179      * the conversation.
180      */
181     private int mLoadWaitReason = LOAD_NOW;
182 
183     private boolean mEnableContentReadySignal;
184 
185     private ContentSizeChangeListener mWebViewSizeChangeListener;
186 
187     private float mWebViewYPercent;
188 
189     /**
190      * Has loadData been called on the WebView yet?
191      */
192     private boolean mWebViewLoadedData;
193 
194     private long mWebViewLoadStartMs;
195 
196     private final Map<String, String> mMessageTransforms = Maps.newHashMap();
197 
198     private final DataSetObserver mLoadedObserver = new DataSetObserver() {
199         @Override
200         public void onChanged() {
201             getHandler().post(new FragmentRunnable("delayedConversationLoad",
202                     ConversationViewFragment.this) {
203                 @Override
204                 public void go() {
205                     LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s",
206                             ConversationViewFragment.this);
207                     handleDelayedConversationLoad();
208                 }
209             });
210         }
211     };
212 
213     private final Runnable mOnProgressDismiss = new FragmentRunnable("onProgressDismiss", this) {
214         @Override
215         public void go() {
216             LogUtils.d(LOG_TAG, "onProgressDismiss go() - isUserVisible() = %b", isUserVisible());
217             if (isUserVisible()) {
218                 onConversationSeen();
219             }
220             mWebView.onRenderComplete();
221         }
222     };
223 
224     private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
225     private static final boolean DISABLE_OFFSCREEN_LOADING = false;
226     private static final boolean DEBUG_DUMP_CURSOR_CONTENTS = false;
227 
228     private static final String BUNDLE_KEY_WEBVIEW_Y_PERCENT =
229             ConversationViewFragment.class.getName() + "webview-y-percent";
230 
231     private BidiFormatter mBidiFormatter;
232 
233     /**
234      * Contains a mapping between inline image attachments and their local message id.
235      */
236     private Map<String, String> mUrlToMessageIdMap;
237 
238     /**
239      * Constructor needs to be public to handle orientation changes and activity lifecycle events.
240      */
ConversationViewFragment()241     public ConversationViewFragment() {}
242 
243     /**
244      * Creates a new instance of {@link ConversationViewFragment}, initialized
245      * to display a conversation with other parameters inherited/copied from an existing bundle,
246      * typically one created using {@link #makeBasicArgs}.
247      */
newInstance(Bundle existingArgs, Conversation conversation)248     public static ConversationViewFragment newInstance(Bundle existingArgs,
249             Conversation conversation) {
250         ConversationViewFragment f = new ConversationViewFragment();
251         Bundle args = new Bundle(existingArgs);
252         args.putParcelable(ARG_CONVERSATION, conversation);
253         f.setArguments(args);
254         return f;
255     }
256 
257     @Override
onAccountChanged(Account newAccount, Account oldAccount)258     public void onAccountChanged(Account newAccount, Account oldAccount) {
259         // if overview mode has changed, re-render completely (no need to also update headers)
260         if (isOverviewMode(newAccount) != isOverviewMode(oldAccount)) {
261             setupOverviewMode();
262             final MessageCursor c = getMessageCursor();
263             if (c != null) {
264                 renderConversation(c);
265             } else {
266                 // Null cursor means this fragment is either waiting to load or in the middle of
267                 // loading. Either way, a future render will happen anyway, and the new setting
268                 // will take effect when that happens.
269             }
270             return;
271         }
272 
273         // settings may have been updated; refresh views that are known to
274         // depend on settings
275         mAdapter.notifyDataSetChanged();
276     }
277 
278     @Override
onActivityCreated(Bundle savedInstanceState)279     public void onActivityCreated(Bundle savedInstanceState) {
280         LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s visible=%s", this, isUserVisible());
281         super.onActivityCreated(savedInstanceState);
282 
283         if (mActivity == null || mActivity.isFinishing()) {
284             // Activity is finishing, just bail.
285             return;
286         }
287 
288         Context context = getContext();
289         mTemplates = new HtmlConversationTemplates(context);
290 
291         final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(context);
292 
293         mNavigationController = mActivity.getKeyboardNavigationController();
294 
295         mAdapter = new ConversationViewAdapter(mActivity, this,
296                 getLoaderManager(), this, this, getContactInfoSource(), this, this,
297                 getListController(), this, mAddressCache, dateBuilder, mBidiFormatter, this);
298         mConversationContainer.setOverlayAdapter(mAdapter);
299 
300         // set up snap header (the adapter usually does this with the other ones)
301         mConversationContainer.getSnapHeader().initialize(
302                 this, mAddressCache, this, getContactInfoSource(),
303                 mActivity.getAccountController().getVeiledAddressMatcher());
304 
305         final Resources resources = getResources();
306         mMaxAutoLoadMessages = resources.getInteger(R.integer.max_auto_load_messages);
307 
308         mSideMarginPx = resources.getDimensionPixelOffset(
309                 R.dimen.conversation_message_content_margin_side);
310 
311         mUrlToMessageIdMap = new ArrayMap<String, String>();
312         final InlineAttachmentViewIntentBuilderCreator creator =
313                 InlineAttachmentViewIntentBuilderCreatorHolder.
314                 getInlineAttachmentViewIntentCreator();
315         final WebViewContextMenu contextMenu = new WebViewContextMenu(getActivity(),
316                 creator.createInlineAttachmentViewIntentBuilder(mAccount,
317                 mConversation != null ? mConversation.id : -1));
318         contextMenu.setCallbacks(this);
319         mWebView.setOnCreateContextMenuListener(contextMenu);
320 
321         // set this up here instead of onCreateView to ensure the latest Account is loaded
322         setupOverviewMode();
323 
324         // Defer the call to initLoader with a Handler.
325         // We want to wait until we know which fragments are present and their final visibility
326         // states before going off and doing work. This prevents extraneous loading from occurring
327         // as the ViewPager shifts about before the initial position is set.
328         //
329         // e.g. click on item #10
330         // ViewPager.setAdapter() actually first loads #0 and #1 under the assumption that #0 is
331         // the initial primary item
332         // Then CPC immediately sets the primary item to #10, which tears down #0/#1 and sets up
333         // #9/#10/#11.
334         getHandler().post(new FragmentRunnable("showConversation", this) {
335             @Override
336             public void go() {
337                 showConversation();
338             }
339         });
340 
341         if (mConversation != null && mConversation.conversationBaseUri != null &&
342                 !Utils.isEmpty(mAccount.accountCookieQueryUri)) {
343             // Set the cookie for this base url
344             new SetCookieTask(getContext(), mConversation.conversationBaseUri.toString(),
345                     mAccount.accountCookieQueryUri).execute();
346         }
347 
348         // Find the height of the screen for manually scrolling the webview via keyboard.
349         final Rect screen = new Rect();
350         mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(screen);
351         mMaxScreenHeight = screen.bottom;
352         mTopOfVisibleScreen = screen.top + mActivity.getSupportActionBar().getHeight();
353     }
354 
355     @Override
onCreate(Bundle savedState)356     public void onCreate(Bundle savedState) {
357         super.onCreate(savedState);
358 
359         mWebViewClient = createConversationWebViewClient();
360 
361         if (savedState != null) {
362             mWebViewYPercent = savedState.getFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT);
363         }
364 
365         mBidiFormatter = BidiFormatter.getInstance();
366     }
367 
createConversationWebViewClient()368     protected ConversationWebViewClient createConversationWebViewClient() {
369         return new ConversationWebViewClient(mAccount);
370     }
371 
372     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)373     public View onCreateView(LayoutInflater inflater,
374             ViewGroup container, Bundle savedInstanceState) {
375 
376         View rootView = inflater.inflate(R.layout.conversation_view, container, false);
377         mConversationContainer = (ConversationContainer) rootView
378                 .findViewById(R.id.conversation_container);
379         mConversationContainer.setAccountController(this);
380 
381         mTopmostOverlay =
382                 (ViewGroup) mConversationContainer.findViewById(R.id.conversation_topmost_overlay);
383         mTopmostOverlay.setOnKeyListener(this);
384         inflateSnapHeader(mTopmostOverlay, inflater);
385         mConversationContainer.setupSnapHeader();
386 
387         setupNewMessageBar();
388 
389         mProgressController = new ConversationViewProgressController(this, getHandler());
390         mProgressController.instantiateProgressIndicators(rootView);
391 
392         mWebView = (ConversationWebView)
393                 mConversationContainer.findViewById(R.id.conversation_webview);
394 
395         mWebView.addJavascriptInterface(mJsBridge, "mail");
396         // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
397         // Below JB, try to speed up initial render by having the webview do supplemental draws to
398         // custom a software canvas.
399         // TODO(mindyp):
400         //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
401         // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
402         // animation that immediately runs on page load. The app uses this as a signal that the
403         // content is loaded and ready to draw, since WebView delays firing this event until the
404         // layers are composited and everything is ready to draw.
405         // This signal does not seem to be reliable, so just use the old method for now.
406         final boolean isJBOrLater = Utils.isRunningJellybeanOrLater();
407         final boolean isUserVisible = isUserVisible();
408         mWebView.setUseSoftwareLayer(!isJBOrLater);
409         mEnableContentReadySignal = isJBOrLater && isUserVisible;
410         mWebView.onUserVisibilityChanged(isUserVisible);
411         mWebView.setWebViewClient(mWebViewClient);
412         final WebChromeClient wcc = new WebChromeClient() {
413             @Override
414             public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
415                 if (consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
416                     LogUtils.wtf(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(),
417                             consoleMessage.sourceId(), consoleMessage.lineNumber(),
418                             ConversationViewFragment.this);
419                 } else {
420                     LogUtils.i(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(),
421                             consoleMessage.sourceId(), consoleMessage.lineNumber(),
422                             ConversationViewFragment.this);
423                 }
424                 return true;
425             }
426         };
427         mWebView.setWebChromeClient(wcc);
428 
429         final WebSettings settings = mWebView.getSettings();
430 
431         final ScrollIndicatorsView scrollIndicators =
432                 (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators);
433         scrollIndicators.setSourceView(mWebView);
434 
435         settings.setJavaScriptEnabled(true);
436 
437         ConversationViewUtils.setTextZoom(getResources(), settings);
438 
439         mViewsCreated = true;
440         mWebViewLoadedData = false;
441 
442         return rootView;
443     }
444 
inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater)445     protected void inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater) {
446         inflater.inflate(R.layout.conversation_topmost_overlay_items, topmostOverlay, true);
447     }
448 
setupNewMessageBar()449     protected void setupNewMessageBar() {
450         mNewMessageBar = (Button) mConversationContainer.findViewById(
451                 R.id.new_message_notification_bar);
452         mNewMessageBar.setOnClickListener(new View.OnClickListener() {
453             @Override
454             public void onClick(View v) {
455                 onNewMessageBarClick();
456             }
457         });
458     }
459 
460     @Override
onResume()461     public void onResume() {
462         super.onResume();
463         if (mWebView != null) {
464             mWebView.onResume();
465         }
466     }
467 
468     @Override
onPause()469     public void onPause() {
470         super.onPause();
471         if (mWebView != null) {
472             mWebView.onPause();
473         }
474     }
475 
476     @Override
onDestroyView()477     public void onDestroyView() {
478         super.onDestroyView();
479         mConversationContainer.setOverlayAdapter(null);
480         mAdapter = null;
481         resetLoadWaiting(); // be sure to unregister any active load observer
482         mViewsCreated = false;
483     }
484 
485     @Override
onSaveInstanceState(Bundle outState)486     public void onSaveInstanceState(Bundle outState) {
487         super.onSaveInstanceState(outState);
488 
489         outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent());
490     }
491 
calculateScrollYPercent()492     private float calculateScrollYPercent() {
493         final float p;
494         if (mWebView == null) {
495             // onCreateView hasn't been called, return 0 as the user hasn't scrolled the view.
496             return 0;
497         }
498 
499         final int scrollY = mWebView.getScrollY();
500         final int viewH = mWebView.getHeight();
501         final int webH = (int) (mWebView.getContentHeight() * mWebView.getScale());
502 
503         if (webH == 0 || webH <= viewH) {
504             p = 0;
505         } else if (scrollY + viewH >= webH) {
506             // The very bottom is a special case, it acts as a stronger anchor than the scroll top
507             // at that point.
508             p = 1.0f;
509         } else {
510             p = (float) scrollY / webH;
511         }
512         return p;
513     }
514 
resetLoadWaiting()515     private void resetLoadWaiting() {
516         if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) {
517             getListController().unregisterConversationLoadedObserver(mLoadedObserver);
518         }
519         mLoadWaitReason = LOAD_NOW;
520     }
521 
522     @Override
markUnread()523     protected void markUnread() {
524         super.markUnread();
525         // Ignore unsafe calls made after a fragment is detached from an activity
526         final ControllableActivity activity = (ControllableActivity) getActivity();
527         if (activity == null) {
528             LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
529             return;
530         }
531 
532         if (mViewState == null) {
533             LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
534                     mConversation.id);
535             return;
536         }
537         activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
538                 mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
539     }
540 
541     @Override
onUserVisibleHintChanged()542     public void onUserVisibleHintChanged() {
543         final boolean userVisible = isUserVisible();
544         LogUtils.d(LOG_TAG, "ConversationViewFragment#onUserVisibleHintChanged(), userVisible = %b",
545                 userVisible);
546 
547         if (!userVisible) {
548             mProgressController.dismissLoadingStatus();
549         } else if (mViewsCreated) {
550             String loadTag = null;
551             final boolean isInitialLoading;
552             if (mActivity != null) {
553                 isInitialLoading = mActivity.getConversationUpdater()
554                     .isInitialConversationLoading();
555             } else {
556                 isInitialLoading = true;
557             }
558 
559             if (getMessageCursor() != null) {
560                 LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this);
561                 if (!isInitialLoading) {
562                     loadTag = "preloaded";
563                 }
564                 onConversationSeen();
565             } else if (isLoadWaiting()) {
566                 LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this);
567                 if (!isInitialLoading) {
568                     loadTag = "load_deferred";
569                 }
570                 handleDelayedConversationLoad();
571             }
572 
573             if (loadTag != null) {
574                 // pager swipes are visibility transitions to 'visible' except during initial
575                 // pager load on A) enter conversation mode B) rotate C) 2-pane conv-mode list-tap
576               Analytics.getInstance().sendEvent("pager_swipe", loadTag,
577                       getCurrentFolderTypeDesc(), 0);
578             }
579         }
580 
581         if (mWebView != null) {
582             mWebView.onUserVisibilityChanged(userVisible);
583         }
584     }
585 
586     /**
587      * Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do
588      * nothing (in which case you should later call {@link #handleDelayedConversationLoad()}).
589      */
showConversation()590     private void showConversation() {
591         final int reason;
592 
593         if (isUserVisible()) {
594             LogUtils.i(LOG_TAG,
595                     "SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this);
596             reason = LOAD_NOW;
597             timerMark("CVF.showConversation");
598         } else {
599             final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
600                     || Utils.isLowRamDevice(getContext())
601                     || (mConversation != null && (mConversation.isRemote
602                             || mConversation.getNumMessages() > mMaxAutoLoadMessages));
603 
604             // When not visible, we should not immediately load if either this conversation is
605             // too heavyweight, or if the main/initial conversation is busy loading.
606             if (disableOffscreenLoading) {
607                 reason = LOAD_WAIT_UNTIL_VISIBLE;
608                 LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this);
609             } else if (getListController().isInitialConversationLoading()) {
610                 reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION;
611                 LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this);
612                 getListController().registerConversationLoadedObserver(mLoadedObserver);
613             } else {
614                 LogUtils.i(LOG_TAG,
615                         "SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)",
616                         this);
617                 reason = LOAD_NOW;
618             }
619         }
620 
621         mLoadWaitReason = reason;
622         if (mLoadWaitReason == LOAD_NOW) {
623             startConversationLoad();
624         }
625     }
626 
handleDelayedConversationLoad()627     private void handleDelayedConversationLoad() {
628         resetLoadWaiting();
629         startConversationLoad();
630     }
631 
startConversationLoad()632     private void startConversationLoad() {
633         mWebView.setVisibility(View.VISIBLE);
634         loadContent();
635         // TODO(mindyp): don't show loading status for a previously rendered
636         // conversation. Ielieve this is better done by making sure don't show loading status
637         // until XX ms have passed without loading completed.
638         mProgressController.showLoadingStatus(isUserVisible());
639     }
640 
641     /**
642      * Can be overridden in case a subclass needs to load something other than
643      * the messages of a conversation.
644      */
loadContent()645     protected void loadContent() {
646         getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks());
647     }
648 
revealConversation()649     private void revealConversation() {
650         timerMark("revealing conversation");
651         mProgressController.dismissLoadingStatus(mOnProgressDismiss);
652         if (isUserVisible()) {
653             AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST,
654                     true /* isDestructive */, "open_conversation", "from_list", null);
655         }
656     }
657 
isLoadWaiting()658     private boolean isLoadWaiting() {
659         return mLoadWaitReason != LOAD_NOW;
660     }
661 
renderConversation(MessageCursor messageCursor)662     private void renderConversation(MessageCursor messageCursor) {
663         final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal);
664         timerMark("rendered conversation");
665 
666         if (DEBUG_DUMP_CONVERSATION_HTML) {
667             java.io.FileWriter fw = null;
668             try {
669                 fw = new java.io.FileWriter(getSdCardFilePath());
670                 fw.write(convHtml);
671             } catch (java.io.IOException e) {
672                 e.printStackTrace();
673             } finally {
674                 if (fw != null) {
675                     try {
676                         fw.close();
677                     } catch (java.io.IOException e) {
678                         e.printStackTrace();
679                     }
680                 }
681             }
682         }
683 
684         // save off existing scroll position before re-rendering
685         if (mWebViewLoadedData) {
686             mWebViewYPercent = calculateScrollYPercent();
687         }
688 
689         mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
690         mWebViewLoadedData = true;
691         mWebViewLoadStartMs = SystemClock.uptimeMillis();
692     }
693 
getSdCardFilePath()694     protected String getSdCardFilePath() {
695         return "/sdcard/conv" + mConversation.id + ".html";
696     }
697 
698     /**
699      * Populate the adapter with overlay views (message headers, super-collapsed blocks, a
700      * conversation header), and return an HTML document with spacer divs inserted for all overlays.
701      *
702      */
renderMessageBodies(MessageCursor messageCursor, boolean enableContentReadySignal)703     protected String renderMessageBodies(MessageCursor messageCursor,
704             boolean enableContentReadySignal) {
705         int pos = -1;
706 
707         LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this);
708         boolean allowNetworkImages = false;
709 
710         // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
711 
712         // Walk through the cursor and build up an overlay adapter as you go.
713         // Each overlay has an entry in the adapter for easy scroll handling in the container.
714         // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
715         // When adding adapter items, also add their heights to help the container later determine
716         // overlay dimensions.
717 
718         // When re-rendering, prevent ConversationContainer from laying out overlays until after
719         // the new spacers are positioned by WebView.
720         mConversationContainer.invalidateSpacerGeometry();
721 
722         mAdapter.clear();
723 
724         // re-evaluate the message parts of the view state, since the messages may have changed
725         // since the previous render
726         final ConversationViewState prevState = mViewState;
727         mViewState = new ConversationViewState(prevState);
728 
729         // N.B. the units of height for spacers are actually dp and not px because WebView assumes
730         // a pixel is an mdpi pixel, unless you set device-dpi.
731 
732         // add a single conversation header item
733         final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
734         final int convHeaderPx = measureOverlayHeight(convHeaderPos);
735 
736         mTemplates.startConversation(mWebView.getViewportWidth(),
737                 mWebView.screenPxToWebPx(mSideMarginPx), mWebView.screenPxToWebPx(convHeaderPx));
738 
739         int collapsedStart = -1;
740         ConversationMessage prevCollapsedMsg = null;
741 
742         final boolean alwaysShowImages = shouldAlwaysShowImages();
743 
744         boolean prevSafeForImages = alwaysShowImages;
745 
746         boolean hasDraft = false;
747         while (messageCursor.moveToPosition(++pos)) {
748             final ConversationMessage msg = messageCursor.getMessage();
749 
750             final boolean safeForImages = alwaysShowImages ||
751                     msg.alwaysShowImages || prevState.getShouldShowImages(msg);
752             allowNetworkImages |= safeForImages;
753 
754             final Integer savedExpanded = prevState.getExpansionState(msg);
755             final int expandedState;
756             if (savedExpanded != null) {
757                 if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) {
758                     // override saved state when this is now the new last message
759                     // this happens to the second-to-last message when you discard a draft
760                     expandedState = ExpansionState.EXPANDED;
761                 } else {
762                     expandedState = savedExpanded;
763                 }
764             } else {
765                 // new messages that are not expanded default to being eligible for super-collapse
766                 if (!msg.read || messageCursor.isLast()) {
767                     expandedState = ExpansionState.EXPANDED;
768                 } else if (messageCursor.isFirst()) {
769                     expandedState = ExpansionState.COLLAPSED;
770                 } else {
771                     expandedState = ExpansionState.SUPER_COLLAPSED;
772                     hasDraft |= msg.isDraft();
773                 }
774             }
775             mViewState.setShouldShowImages(msg, prevState.getShouldShowImages(msg));
776             mViewState.setExpansionState(msg, expandedState);
777 
778             // save off "read" state from the cursor
779             // later, the view may not match the cursor (e.g. conversation marked read on open)
780             // however, if a previous state indicated this message was unread, trust that instead
781             // so "mark unread" marks all originally unread messages
782             mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg));
783 
784             // We only want to consider this for inclusion in the super collapsed block if
785             // 1) The we don't have previous state about this message  (The first time that the
786             //    user opens a conversation)
787             // 2) The previously saved state for this message indicates that this message is
788             //    in the super collapsed block.
789             if (ExpansionState.isSuperCollapsed(expandedState)) {
790                 // contribute to a super-collapsed block that will be emitted just before the
791                 // next expanded header
792                 if (collapsedStart < 0) {
793                     collapsedStart = pos;
794                 }
795                 prevCollapsedMsg = msg;
796                 prevSafeForImages = safeForImages;
797 
798                 // This line puts the from address in the address cache so that
799                 // we get the sender image for it if it's in a super-collapsed block.
800                 getAddress(msg.getFrom());
801                 continue;
802             }
803 
804             // resolve any deferred decisions on previous collapsed items
805             if (collapsedStart >= 0) {
806                 if (pos - collapsedStart == 1) {
807                     // Special-case for a single collapsed message: no need to super-collapse it.
808                     renderMessage(prevCollapsedMsg, false /* expanded */, prevSafeForImages);
809                 } else {
810                     renderSuperCollapsedBlock(collapsedStart, pos - 1, hasDraft);
811                 }
812                 hasDraft = false; // reset hasDraft
813                 prevCollapsedMsg = null;
814                 collapsedStart = -1;
815             }
816 
817             renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages);
818         }
819 
820         final MessageHeaderItem lastHeaderItem = getLastMessageHeaderItem();
821         final int convFooterPos = mAdapter.addConversationFooter(lastHeaderItem);
822         final int convFooterPx = measureOverlayHeight(convFooterPos);
823 
824         mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
825 
826         final boolean applyTransforms = shouldApplyTransforms();
827 
828         // If the conversation has specified a base uri, use it here, otherwise use mBaseUri
829         return mTemplates.endConversation(convFooterPx, mBaseUri,
830                 mConversation.getBaseUri(mBaseUri),
831                 mWebView.getViewportWidth(), mWebView.getWidthInDp(mSideMarginPx),
832                 enableContentReadySignal, isOverviewMode(mAccount), applyTransforms,
833                 applyTransforms);
834     }
835 
getLastMessageHeaderItem()836     private MessageHeaderItem getLastMessageHeaderItem() {
837         final int count = mAdapter.getCount();
838         if (count < 3) {
839             LogUtils.wtf(LOG_TAG, "not enough items in the adapter. count: %s", count);
840             return null;
841         }
842         return (MessageHeaderItem) mAdapter.getItem(count - 2);
843     }
844 
renderSuperCollapsedBlock(int start, int end, boolean hasDraft)845     private void renderSuperCollapsedBlock(int start, int end, boolean hasDraft) {
846         final int blockPos = mAdapter.addSuperCollapsedBlock(start, end, hasDraft);
847         final int blockPx = measureOverlayHeight(blockPos);
848         mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
849     }
850 
renderMessage(ConversationMessage msg, boolean expanded, boolean safeForImages)851     private void renderMessage(ConversationMessage msg, boolean expanded, boolean safeForImages) {
852 
853         final int headerPos = mAdapter.addMessageHeader(msg, expanded,
854                 mViewState.getShouldShowImages(msg));
855         final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
856 
857         final int footerPos = mAdapter.addMessageFooter(headerItem);
858 
859         // Measure item header and footer heights to allocate spacers in HTML
860         // But since the views themselves don't exist yet, render each item temporarily into
861         // a host view for measurement.
862         final int headerPx = measureOverlayHeight(headerPos);
863         final int footerPx = measureOverlayHeight(footerPos);
864 
865         mTemplates.appendMessageHtml(msg, expanded, safeForImages,
866                 mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
867         timerMark("rendered message");
868     }
869 
renderCollapsedHeaders(MessageCursor cursor, SuperCollapsedBlockItem blockToReplace)870     private String renderCollapsedHeaders(MessageCursor cursor,
871             SuperCollapsedBlockItem blockToReplace) {
872         final List<ConversationOverlayItem> replacements = Lists.newArrayList();
873 
874         mTemplates.reset();
875 
876         final boolean alwaysShowImages = (mAccount != null) &&
877                 (mAccount.settings.showImages == Settings.ShowImages.ALWAYS);
878 
879         // In devices with non-integral density multiplier, screen pixels translate to non-integral
880         // web pixels. Keep track of the error that occurs when we cast all heights to int
881         float error = 0f;
882         boolean first = true;
883         for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
884             cursor.moveToPosition(i);
885             final ConversationMessage msg = cursor.getMessage();
886 
887             final MessageHeaderItem header = ConversationViewAdapter.newMessageHeaderItem(
888                     mAdapter, mAdapter.getDateBuilder(), msg, false /* expanded */,
889                     alwaysShowImages || mViewState.getShouldShowImages(msg));
890             final MessageFooterItem footer = mAdapter.newMessageFooterItem(mAdapter, header);
891 
892             final int headerPx = measureOverlayHeight(header);
893             final int footerPx = measureOverlayHeight(footer);
894             error += mWebView.screenPxToWebPxError(headerPx)
895                     + mWebView.screenPxToWebPxError(footerPx);
896 
897             // When the error becomes greater than 1 pixel, make the next header 1 pixel taller
898             int correction = 0;
899             if (error >= 1) {
900                 correction = 1;
901                 error -= 1;
902             }
903 
904             mTemplates.appendMessageHtml(msg, false /* expanded */,
905                     alwaysShowImages || msg.alwaysShowImages,
906                     mWebView.screenPxToWebPx(headerPx) + correction,
907                     mWebView.screenPxToWebPx(footerPx));
908             replacements.add(header);
909             replacements.add(footer);
910 
911             mViewState.setExpansionState(msg, ExpansionState.COLLAPSED);
912         }
913 
914         mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
915         mAdapter.notifyDataSetChanged();
916 
917         return mTemplates.emit();
918     }
919 
measureOverlayHeight(int position)920     protected int measureOverlayHeight(int position) {
921         return measureOverlayHeight(mAdapter.getItem(position));
922     }
923 
924     /**
925      * Measure the height of an adapter view by rendering an adapter item into a temporary
926      * host view, and asking the view to immediately measure itself. This method will reuse
927      * a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
928      * earlier.
929      * <p>
930      * After measuring the height, this method also saves the height in the
931      * {@link ConversationOverlayItem} for later use in overlay positioning.
932      *
933      * @param convItem adapter item with data to render and measure
934      * @return height of the rendered view in screen px
935      */
measureOverlayHeight(ConversationOverlayItem convItem)936     private int measureOverlayHeight(ConversationOverlayItem convItem) {
937         final int type = convItem.getType();
938 
939         final View convertView = mConversationContainer.getScrapView(type);
940         final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
941                 true /* measureOnly */);
942         if (convertView == null) {
943             mConversationContainer.addScrapView(type, hostView);
944         }
945 
946         final int heightPx = mConversationContainer.measureOverlay(hostView);
947         convItem.setHeight(heightPx);
948         convItem.markMeasurementValid();
949 
950         return heightPx;
951     }
952 
953     @Override
onConversationViewHeaderHeightChange(int newHeight)954     public void onConversationViewHeaderHeightChange(int newHeight) {
955         final int h = mWebView.screenPxToWebPx(newHeight);
956 
957         mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h));
958     }
959 
960     // END conversation header callbacks
961 
962     // START conversation footer callbacks
963 
964     @Override
onConversationFooterHeightChange(int newHeight)965     public void onConversationFooterHeightChange(int newHeight) {
966         final int h = mWebView.screenPxToWebPx(newHeight);
967 
968         mWebView.loadUrl(String.format("javascript:setConversationFooterSpacerHeight(%s);", h));
969     }
970 
971     // END conversation footer callbacks
972 
973     // START message header callbacks
974     @Override
setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx)975     public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
976         mConversationContainer.invalidateSpacerGeometry();
977 
978         // update message HTML spacer height
979         final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
980         LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
981                 newSpacerHeightPx);
982         mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);",
983                 mTemplates.getMessageDomId(item.getMessage()), h));
984     }
985 
986     @Override
setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx)987     public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
988         mConversationContainer.invalidateSpacerGeometry();
989 
990         // show/hide the HTML message body and update the spacer height
991         final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
992         LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
993                 item.isExpanded(), h, newSpacerHeightPx);
994         mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);",
995                 mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h));
996 
997         mViewState.setExpansionState(item.getMessage(),
998                 item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED);
999     }
1000 
1001     @Override
showExternalResources(final Message msg)1002     public void showExternalResources(final Message msg) {
1003         mViewState.setShouldShowImages(msg, true);
1004         mWebView.getSettings().setBlockNetworkImage(false);
1005         mWebView.loadUrl("javascript:unblockImages(['" + mTemplates.getMessageDomId(msg) + "']);");
1006     }
1007 
1008     @Override
showExternalResources(final String senderRawAddress)1009     public void showExternalResources(final String senderRawAddress) {
1010         mWebView.getSettings().setBlockNetworkImage(false);
1011 
1012         final Address sender = getAddress(senderRawAddress);
1013         final MessageCursor cursor = getMessageCursor();
1014 
1015         final List<String> messageDomIds = new ArrayList<String>();
1016 
1017         int pos = -1;
1018         while (cursor.moveToPosition(++pos)) {
1019             final ConversationMessage message = cursor.getMessage();
1020             if (sender.equals(getAddress(message.getFrom()))) {
1021                 message.alwaysShowImages = true;
1022 
1023                 mViewState.setShouldShowImages(message, true);
1024                 messageDomIds.add(mTemplates.getMessageDomId(message));
1025             }
1026         }
1027 
1028         final String url = String.format(
1029                 "javascript:unblockImages(['%s']);", TextUtils.join("','", messageDomIds));
1030         mWebView.loadUrl(url);
1031     }
1032 
1033     @Override
supportsMessageTransforms()1034     public boolean supportsMessageTransforms() {
1035         return true;
1036     }
1037 
1038     @Override
getMessageTransforms(final Message msg)1039     public String getMessageTransforms(final Message msg) {
1040         final String domId = mTemplates.getMessageDomId(msg);
1041         return (domId == null) ? null : mMessageTransforms.get(domId);
1042     }
1043 
1044     @Override
isSecure()1045     public boolean isSecure() {
1046         return false;
1047     }
1048 
1049     // END message header callbacks
1050 
1051     @Override
showUntransformedConversation()1052     public void showUntransformedConversation() {
1053         super.showUntransformedConversation();
1054         renderConversation(getMessageCursor());
1055     }
1056 
1057     @Override
onSuperCollapsedClick(SuperCollapsedBlockItem item)1058     public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
1059         MessageCursor cursor = getMessageCursor();
1060         if (cursor == null || !mViewsCreated) {
1061             return;
1062         }
1063 
1064         mTempBodiesHtml = renderCollapsedHeaders(cursor, item);
1065         mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
1066         mConversationContainer.focusFirstMessageHeader();
1067     }
1068 
showNewMessageNotification(NewMessagesInfo info)1069     private void showNewMessageNotification(NewMessagesInfo info) {
1070         mNewMessageBar.setText(info.getNotificationText());
1071         mNewMessageBar.setVisibility(View.VISIBLE);
1072     }
1073 
onNewMessageBarClick()1074     private void onNewMessageBarClick() {
1075         mNewMessageBar.setVisibility(View.GONE);
1076 
1077         renderConversation(getMessageCursor()); // mCursor is already up-to-date
1078                                                 // per onLoadFinished()
1079     }
1080 
parsePositions(final int[] topArray, final int[] bottomArray)1081     private static OverlayPosition[] parsePositions(final int[] topArray, final int[] bottomArray) {
1082         final int len = topArray.length;
1083         final OverlayPosition[] positions = new OverlayPosition[len];
1084         for (int i = 0; i < len; i++) {
1085             positions[i] = new OverlayPosition(topArray[i], bottomArray[i]);
1086         }
1087         return positions;
1088     }
1089 
getAddress(String rawFrom)1090     protected Address getAddress(String rawFrom) {
1091         return Utils.getAddress(mAddressCache, rawFrom);
1092     }
1093 
ensureContentSizeChangeListener()1094     private void ensureContentSizeChangeListener() {
1095         if (mWebViewSizeChangeListener == null) {
1096             mWebViewSizeChangeListener = new ContentSizeChangeListener() {
1097                 @Override
1098                 public void onHeightChange(int h) {
1099                     // When WebKit says the DOM height has changed, re-measure
1100                     // bodies and re-position their headers.
1101                     // This is separate from the typical JavaScript DOM change
1102                     // listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM
1103                     // events.
1104                     mWebView.loadUrl("javascript:measurePositions();");
1105                 }
1106             };
1107         }
1108         mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener);
1109     }
1110 
isOverviewMode(Account acct)1111     public static boolean isOverviewMode(Account acct) {
1112         return acct.settings.isOverviewMode();
1113     }
1114 
setupOverviewMode()1115     private void setupOverviewMode() {
1116         // for now, overview mode means use the built-in WebView zoom and disable custom scale
1117         // gesture handling
1118         final boolean overviewMode = isOverviewMode(mAccount);
1119         final WebSettings settings = mWebView.getSettings();
1120         final WebSettings.LayoutAlgorithm layout;
1121         settings.setUseWideViewPort(overviewMode);
1122         settings.setSupportZoom(overviewMode);
1123         settings.setBuiltInZoomControls(overviewMode);
1124         settings.setLoadWithOverviewMode(overviewMode);
1125         if (overviewMode) {
1126             settings.setDisplayZoomControls(false);
1127             layout = WebSettings.LayoutAlgorithm.NORMAL;
1128         } else {
1129             layout = WebSettings.LayoutAlgorithm.NARROW_COLUMNS;
1130         }
1131         settings.setLayoutAlgorithm(layout);
1132     }
1133 
1134     @Override
getMessageForClickedUrl(String url)1135     public ConversationMessage getMessageForClickedUrl(String url) {
1136         final String domMessageId = mUrlToMessageIdMap.get(url);
1137         if (domMessageId == null) {
1138             return null;
1139         }
1140         final String messageId = mTemplates.getMessageIdForDomId(domMessageId);
1141         return getMessageCursor().getMessageForId(Long.parseLong(messageId));
1142     }
1143 
1144     @Override
onKey(View view, int keyCode, KeyEvent keyEvent)1145     public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
1146         if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) {
1147             mOriginalKeyedView = view;
1148         }
1149 
1150         if (mOriginalKeyedView != null) {
1151             final int id = mOriginalKeyedView.getId();
1152             final boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP;
1153             final boolean isLeft = keyCode == KeyEvent.KEYCODE_DPAD_LEFT;
1154             final boolean isRight = keyCode == KeyEvent.KEYCODE_DPAD_RIGHT;
1155             final boolean isUp = keyCode == KeyEvent.KEYCODE_DPAD_UP;
1156             final boolean isDown = keyCode == KeyEvent.KEYCODE_DPAD_DOWN;
1157 
1158             // First we run the event by the controller
1159             // We manually check if the view+direction combination should shift focus away from the
1160             // conversation view to the thread list in two-pane landscape mode.
1161             final boolean isTwoPaneLand = mNavigationController.isTwoPaneLandscape();
1162             final boolean navigateAway = mConversationContainer.shouldNavigateAway(id, isLeft,
1163                     isTwoPaneLand);
1164             if (mNavigationController.onInterceptKeyFromCV(keyCode, keyEvent, navigateAway)) {
1165                 return true;
1166             }
1167 
1168             // If controller didn't handle the event, check directional interception.
1169             if ((isLeft || isRight) && mConversationContainer.shouldInterceptLeftRightEvents(
1170                     id, isLeft, isRight, isTwoPaneLand)) {
1171                 return true;
1172             } else if (isUp || isDown) {
1173                 // We don't do anything on up/down for overlay
1174                 if (id == R.id.conversation_topmost_overlay) {
1175                     return true;
1176                 }
1177 
1178                 // We manually handle up/down navigation through the overlay items because the
1179                 // system's default isn't optimal for two-pane landscape since it's not a real list.
1180                 final int position = mConversationContainer.getViewPosition(mOriginalKeyedView);
1181                 final View next = mConversationContainer.getNextOverlayView(position, isDown);
1182                 if (next != null) {
1183                     if (isActionUp) {
1184                         next.requestFocus();
1185 
1186                         // Make sure that v is in view
1187                         final int[] coords = new int[2];
1188                         next.getLocationOnScreen(coords);
1189                         final int bottom = coords[1] + next.getHeight();
1190                         if (bottom > mMaxScreenHeight) {
1191                             mWebView.scrollBy(0, bottom - mMaxScreenHeight);
1192                         } else if (coords[1] < mTopOfVisibleScreen) {
1193                             mWebView.scrollBy(0, coords[1] - mTopOfVisibleScreen);
1194                         }
1195                     }
1196                     return true;
1197                 } else {
1198                     // Special case two end points
1199                     // Start is marked as index 1 because we are currently not allowing focus on
1200                     // conversation view header.
1201                     if ((position == mConversationContainer.getOverlayCount() - 1 && isDown) ||
1202                             (position == 1 && isUp)) {
1203                         mTopmostOverlay.requestFocus();
1204                         // Scroll to the the top if we hit the first item
1205                         if (isUp) {
1206                             mWebView.scrollTo(0, 0);
1207                         }
1208                         return true;
1209                     }
1210                 }
1211             }
1212 
1213             // Finally we handle the special keys
1214             if (keyCode == KeyEvent.KEYCODE_BACK && id != R.id.conversation_topmost_overlay) {
1215                 if (isActionUp) {
1216                     mTopmostOverlay.requestFocus();
1217                 }
1218                 return true;
1219             } else if (keyCode == KeyEvent.KEYCODE_ENTER &&
1220                     id == R.id.conversation_topmost_overlay) {
1221                 if (isActionUp) {
1222                     mConversationContainer.focusFirstMessageHeader();
1223                     mWebView.scrollTo(0, 0);
1224                 }
1225                 return true;
1226             }
1227         }
1228         return false;
1229     }
1230 
1231     public class ConversationWebViewClient extends AbstractConversationWebViewClient {
ConversationWebViewClient(Account account)1232         public ConversationWebViewClient(Account account) {
1233             super(account);
1234         }
1235 
1236         @Override
onPageFinished(WebView view, String url)1237         public void onPageFinished(WebView view, String url) {
1238             // Ignore unsafe calls made after a fragment is detached from an activity.
1239             // This method needs to, for example, get at the loader manager, which needs
1240             // the fragment to be added.
1241             if (!isAdded() || !mViewsCreated) {
1242                 LogUtils.d(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
1243                         ConversationViewFragment.this);
1244                 return;
1245             }
1246 
1247             LogUtils.d(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s wv=%s t=%sms", url,
1248                     ConversationViewFragment.this, view,
1249                     (SystemClock.uptimeMillis() - mWebViewLoadStartMs));
1250 
1251             ensureContentSizeChangeListener();
1252 
1253             if (!mEnableContentReadySignal) {
1254                 revealConversation();
1255             }
1256 
1257             final Set<String> emailAddresses = Sets.newHashSet();
1258             final List<Address> cacheCopy;
1259             synchronized (mAddressCache) {
1260                 cacheCopy = ImmutableList.copyOf(mAddressCache.values());
1261             }
1262             for (Address addr : cacheCopy) {
1263                 emailAddresses.add(addr.getAddress());
1264             }
1265             final ContactLoaderCallbacks callbacks = getContactInfoSource();
1266             callbacks.setSenders(emailAddresses);
1267             getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks);
1268         }
1269 
1270         @Override
shouldOverrideUrlLoading(WebView view, String url)1271         public boolean shouldOverrideUrlLoading(WebView view, String url) {
1272             return mViewsCreated && super.shouldOverrideUrlLoading(view, url);
1273         }
1274     }
1275 
1276     /**
1277      * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
1278      * via reflection and not stripped.
1279      *
1280      */
1281     private class MailJsBridge {
1282         @JavascriptInterface
onWebContentGeometryChange(final int[] overlayTopStrs, final int[] overlayBottomStrs)1283         public void onWebContentGeometryChange(final int[] overlayTopStrs,
1284                 final int[] overlayBottomStrs) {
1285             try {
1286                 getHandler().post(new FragmentRunnable("onWebContentGeometryChange",
1287                         ConversationViewFragment.this) {
1288                     @Override
1289                     public void go() {
1290                         if (!mViewsCreated) {
1291                             LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views"
1292                                     + " are gone, %s", ConversationViewFragment.this);
1293                             return;
1294                         }
1295                         mConversationContainer.onGeometryChange(
1296                                 parsePositions(overlayTopStrs, overlayBottomStrs));
1297                         if (mDiff != 0) {
1298                             // SCROLL!
1299                             int scale = (int) (mWebView.getScale() / mWebView.getInitialScale());
1300                             if (scale > 1) {
1301                                 mWebView.scrollBy(0, (mDiff * (scale - 1)));
1302                             }
1303                             mDiff = 0;
1304                         }
1305                     }
1306                 });
1307             } catch (Throwable t) {
1308                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
1309             }
1310         }
1311 
1312         @JavascriptInterface
getTempMessageBodies()1313         public String getTempMessageBodies() {
1314             try {
1315                 if (!mViewsCreated) {
1316                     return "";
1317                 }
1318 
1319                 final String s = mTempBodiesHtml;
1320                 mTempBodiesHtml = null;
1321                 return s;
1322             } catch (Throwable t) {
1323                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
1324                 return "";
1325             }
1326         }
1327 
1328         @JavascriptInterface
getMessageBody(String domId)1329         public String getMessageBody(String domId) {
1330             try {
1331                 final MessageCursor cursor = getMessageCursor();
1332                 if (!mViewsCreated || cursor == null) {
1333                     return "";
1334                 }
1335 
1336                 int pos = -1;
1337                 while (cursor.moveToPosition(++pos)) {
1338                     final ConversationMessage msg = cursor.getMessage();
1339                     if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
1340                         return HtmlConversationTemplates.wrapMessageBody(msg.getBodyAsHtml());
1341                     }
1342                 }
1343 
1344                 return "";
1345 
1346             } catch (Throwable t) {
1347                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody");
1348                 return "";
1349             }
1350         }
1351 
1352         @JavascriptInterface
getMessageSender(String domId)1353         public String getMessageSender(String domId) {
1354             try {
1355                 final MessageCursor cursor = getMessageCursor();
1356                 if (!mViewsCreated || cursor == null) {
1357                     return "";
1358                 }
1359 
1360                 int pos = -1;
1361                 while (cursor.moveToPosition(++pos)) {
1362                     final ConversationMessage msg = cursor.getMessage();
1363                     if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
1364                         return getAddress(msg.getFrom()).getAddress();
1365                     }
1366                 }
1367 
1368                 return "";
1369 
1370             } catch (Throwable t) {
1371                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageSender");
1372                 return "";
1373             }
1374         }
1375 
1376         @JavascriptInterface
onContentReady()1377         public void onContentReady() {
1378             try {
1379                 getHandler().post(new FragmentRunnable("onContentReady",
1380                         ConversationViewFragment.this) {
1381                     @Override
1382                     public void go() {
1383                         try {
1384                             if (mWebViewLoadStartMs != 0) {
1385                                 LogUtils.i(LOG_TAG, "IN CVF.onContentReady, f=%s vis=%s t=%sms",
1386                                         ConversationViewFragment.this,
1387                                         isUserVisible(),
1388                                         (SystemClock.uptimeMillis() - mWebViewLoadStartMs));
1389                             }
1390                             revealConversation();
1391                         } catch (Throwable t) {
1392                             LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
1393                             // Still try to show the conversation.
1394                             revealConversation();
1395                         }
1396                     }
1397                 });
1398             } catch (Throwable t) {
1399                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
1400             }
1401         }
1402 
1403         @JavascriptInterface
getScrollYPercent()1404         public float getScrollYPercent() {
1405             try {
1406                 return mWebViewYPercent;
1407             } catch (Throwable t) {
1408                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent");
1409                 return 0f;
1410             }
1411         }
1412 
1413         @JavascriptInterface
onMessageTransform(String messageDomId, String transformText)1414         public void onMessageTransform(String messageDomId, String transformText) {
1415             try {
1416                 LogUtils.i(LOG_TAG, "TRANSFORM: (%s) %s", messageDomId, transformText);
1417                 mMessageTransforms.put(messageDomId, transformText);
1418                 onConversationTransformed();
1419             } catch (Throwable t) {
1420                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onMessageTransform");
1421             }
1422         }
1423 
1424         @JavascriptInterface
onInlineAttachmentsParsed(final String[] urls, final String[] messageIds)1425         public void onInlineAttachmentsParsed(final String[] urls, final String[] messageIds) {
1426             try {
1427                 getHandler().post(new FragmentRunnable("onInlineAttachmentsParsed",
1428                         ConversationViewFragment.this) {
1429                     @Override
1430                     public void go() {
1431                         try {
1432                             for (int i = 0, size = urls.length; i < size; i++) {
1433                                 mUrlToMessageIdMap.put(urls[i], messageIds[i]);
1434                             }
1435                         } catch (ArrayIndexOutOfBoundsException e) {
1436                             LogUtils.e(LOG_TAG, e,
1437                                     "Number of urls does not match number of message ids - %s:%s",
1438                                     urls.length, messageIds.length);
1439                         }
1440                     }
1441                 });
1442             } catch (Throwable t) {
1443                 LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onInlineAttachmentsParsed");
1444             }
1445         }
1446     }
1447 
1448     private class NewMessagesInfo {
1449         int count;
1450         int countFromSelf;
1451         String senderAddress;
1452 
1453         /**
1454          * Return the display text for the new message notification overlay. It will be formatted
1455          * appropriately for a single new message vs. multiple new messages.
1456          *
1457          * @return display text
1458          */
getNotificationText()1459         public String getNotificationText() {
1460             Resources res = getResources();
1461             if (count > 1) {
1462                 return res.getQuantityString(R.plurals.new_incoming_messages_many, count, count);
1463             } else {
1464                 final Address addr = getAddress(senderAddress);
1465                 return res.getString(R.string.new_incoming_messages_one,
1466                         mBidiFormatter.unicodeWrap(TextUtils.isEmpty(addr.getPersonal())
1467                                 ? addr.getAddress() : addr.getPersonal()));
1468             }
1469         }
1470     }
1471 
1472     @Override
onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader, MessageCursor newCursor, MessageCursor oldCursor)1473     public void onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader,
1474             MessageCursor newCursor, MessageCursor oldCursor) {
1475         /*
1476          * what kind of changes affect the MessageCursor? 1. new message(s) 2.
1477          * read/unread state change 3. deleted message, either regular or draft
1478          * 4. updated message, either from self or from others, updated in
1479          * content or state or sender 5. star/unstar of message (technically
1480          * similar to #1) 6. other label change Use MessageCursor.hashCode() to
1481          * sort out interesting vs. no-op cursor updates.
1482          */
1483 
1484         if (oldCursor != null && !oldCursor.isClosed()) {
1485             final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor);
1486 
1487             if (info.count > 0) {
1488                 // don't immediately render new incoming messages from other
1489                 // senders
1490                 // (to avoid a new message from losing the user's focus)
1491                 LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1492                         + ", holding cursor for new incoming message (%s)", this);
1493                 showNewMessageNotification(info);
1494                 return;
1495             }
1496 
1497             final int oldState = oldCursor.getStateHashCode();
1498             final boolean changed = newCursor.getStateHashCode() != oldState;
1499 
1500             if (!changed) {
1501                 final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor);
1502                 if (processedInPlace) {
1503                     LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this);
1504                 } else {
1505                     LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update"
1506                             + ", ignoring this conversation update (%s)", this);
1507                 }
1508                 return;
1509             } else if (info.countFromSelf == 1) {
1510                 // Special-case the very common case of a new cursor that is the same as the old
1511                 // one, except that there is a new message from yourself. This happens upon send.
1512                 final boolean sameExceptNewLast = newCursor.getStateHashCode(1) == oldState;
1513                 if (sameExceptNewLast) {
1514                     LogUtils.i(LOG_TAG, "CONV RENDER: update is a single new message from self"
1515                             + " (%s)", this);
1516                     newCursor.moveToLast();
1517                     processNewOutgoingMessage(newCursor.getMessage());
1518                     return;
1519                 }
1520             }
1521             // cursors are different, and not due to an incoming message. fall
1522             // through and render.
1523             LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
1524                     + ", but not due to incoming message. rendering. (%s)", this);
1525 
1526             if (DEBUG_DUMP_CURSOR_CONTENTS) {
1527                 LogUtils.i(LOG_TAG, "old cursor: %s", oldCursor.getDebugDump());
1528                 LogUtils.i(LOG_TAG, "new cursor: %s", newCursor.getDebugDump());
1529             }
1530         } else {
1531             LogUtils.i(LOG_TAG, "CONV RENDER: initial render. (%s)", this);
1532             timerMark("message cursor load finished");
1533         }
1534 
1535         renderContent(newCursor);
1536     }
1537 
renderContent(MessageCursor messageCursor)1538     protected void renderContent(MessageCursor messageCursor) {
1539         // if layout hasn't happened, delay render
1540         // This is needed in addition to the showConversation() delay to speed
1541         // up rotation and restoration.
1542         if (mConversationContainer.getWidth() == 0) {
1543             mNeedRender = true;
1544             mConversationContainer.addOnLayoutChangeListener(this);
1545         } else {
1546             renderConversation(messageCursor);
1547         }
1548     }
1549 
getNewIncomingMessagesInfo(MessageCursor newCursor)1550     private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
1551         final NewMessagesInfo info = new NewMessagesInfo();
1552 
1553         int pos = -1;
1554         while (newCursor.moveToPosition(++pos)) {
1555             final Message m = newCursor.getMessage();
1556             if (!mViewState.contains(m)) {
1557                 LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
1558 
1559                 final Address from = getAddress(m.getFrom());
1560                 // distinguish ours from theirs
1561                 // new messages from the account owner should not trigger a
1562                 // notification
1563                 if (from == null || mAccount.ownsFromAddress(from.getAddress())) {
1564                     LogUtils.i(LOG_TAG, "found message from self: %s", m.uri);
1565                     info.countFromSelf++;
1566                     continue;
1567                 }
1568 
1569                 info.count++;
1570                 info.senderAddress = m.getFrom();
1571             }
1572         }
1573         return info;
1574     }
1575 
processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor)1576     private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) {
1577         final Set<String> idsOfChangedBodies = Sets.newHashSet();
1578         final List<Integer> changedOverlayPositions = Lists.newArrayList();
1579 
1580         boolean changed = false;
1581 
1582         int pos = 0;
1583         while (true) {
1584             if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) {
1585                 break;
1586             }
1587 
1588             final ConversationMessage newMsg = newCursor.getMessage();
1589             final ConversationMessage oldMsg = oldCursor.getMessage();
1590 
1591             // We are going to update the data in the adapter whenever any input fields change.
1592             // This ensures that the Message object that ComposeActivity uses will be correctly
1593             // aligned with the most up-to-date data.
1594             if (!newMsg.isEqual(oldMsg)) {
1595                 mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions);
1596                 LogUtils.i(LOG_TAG, "msg #%d (%d): detected field(s) change. sendingState=%s",
1597                         pos, newMsg.id, newMsg.sendingState);
1598             }
1599 
1600             // update changed message bodies in-place
1601             if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) ||
1602                     !TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) {
1603                 // maybe just set a flag to notify JS to re-request changed bodies
1604                 idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"');
1605                 LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id);
1606             }
1607 
1608             pos++;
1609         }
1610 
1611 
1612         if (!changedOverlayPositions.isEmpty()) {
1613             // notify once after the entire adapter is updated
1614             mConversationContainer.onOverlayModelUpdate(changedOverlayPositions);
1615             changed = true;
1616         }
1617 
1618         final ConversationFooterItem footerItem = mAdapter.getFooterItem();
1619         if (footerItem != null) {
1620             footerItem.invalidateMeasurement();
1621         }
1622         if (!idsOfChangedBodies.isEmpty()) {
1623             mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);",
1624                     TextUtils.join(",", idsOfChangedBodies)));
1625             changed = true;
1626         }
1627 
1628         return changed;
1629     }
1630 
processNewOutgoingMessage(ConversationMessage msg)1631     private void processNewOutgoingMessage(ConversationMessage msg) {
1632         // Temporarily remove the ConversationFooterItem and its view.
1633         // It will get re-added right after the new message is added.
1634         final ConversationFooterItem footerItem = mAdapter.removeFooterItem();
1635         mConversationContainer.removeViewAtAdapterIndex(footerItem.getPosition());
1636         mTemplates.reset();
1637         // this method will add some items to mAdapter, but we deliberately want to avoid notifying
1638         // adapter listeners (i.e. ConversationContainer) until onWebContentGeometryChange is next
1639         // called, to prevent N+1 headers rendering with N message bodies.
1640         renderMessage(msg, true /* expanded */, msg.alwaysShowImages);
1641         mTempBodiesHtml = mTemplates.emit();
1642 
1643         if (footerItem != null) {
1644             footerItem.setLastMessageHeaderItem(getLastMessageHeaderItem());
1645             footerItem.invalidateMeasurement();
1646             mAdapter.addItem(footerItem);
1647         }
1648 
1649         mViewState.setExpansionState(msg, ExpansionState.EXPANDED);
1650         // FIXME: should the provider set this as initial state?
1651         mViewState.setReadState(msg, false /* read */);
1652 
1653         // From now until the updated spacer geometry is returned, the adapter items are mismatched
1654         // with the existing spacers. Do not let them layout.
1655         mConversationContainer.invalidateSpacerGeometry();
1656 
1657         mWebView.loadUrl("javascript:appendMessageHtml();");
1658     }
1659 
1660     private static class SetCookieTask extends AsyncTask<Void, Void, Void> {
1661         private final Context mContext;
1662         private final String mUri;
1663         private final Uri mAccountCookieQueryUri;
1664         private final ContentResolver mResolver;
1665 
SetCookieTask(Context context, String baseUri, Uri accountCookieQueryUri)1666         /* package */ SetCookieTask(Context context, String baseUri, Uri accountCookieQueryUri) {
1667             mContext = context;
1668             mUri = baseUri;
1669             mAccountCookieQueryUri = accountCookieQueryUri;
1670             mResolver = context.getContentResolver();
1671         }
1672 
1673         @Override
doInBackground(Void... args)1674         public Void doInBackground(Void... args) {
1675             // First query for the cookie string from the UI provider
1676             final Cursor cookieCursor = mResolver.query(mAccountCookieQueryUri,
1677                     UIProvider.ACCOUNT_COOKIE_PROJECTION, null, null, null);
1678             if (cookieCursor == null) {
1679                 return null;
1680             }
1681 
1682             try {
1683                 if (cookieCursor.moveToFirst()) {
1684                     final String cookie = cookieCursor.getString(
1685                             cookieCursor.getColumnIndex(UIProvider.AccountCookieColumns.COOKIE));
1686 
1687                     if (cookie != null) {
1688                         final CookieSyncManager csm =
1689                                 CookieSyncManager.createInstance(mContext);
1690                         CookieManager.getInstance().setCookie(mUri, cookie);
1691                         csm.sync();
1692                     }
1693                 }
1694 
1695             } finally {
1696                 cookieCursor.close();
1697             }
1698 
1699 
1700             return null;
1701         }
1702     }
1703 
1704     @Override
onConversationUpdated(Conversation conv)1705     public void onConversationUpdated(Conversation conv) {
1706         final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer
1707                 .findViewById(R.id.conversation_header);
1708         mConversation = conv;
1709         if (headerView != null) {
1710             headerView.onConversationUpdated(conv);
1711         }
1712     }
1713 
1714     @Override
onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)1715     public void onLayoutChange(View v, int left, int top, int right,
1716             int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
1717         boolean sizeChanged = mNeedRender
1718                 && mConversationContainer.getWidth() != 0;
1719         if (sizeChanged) {
1720             mNeedRender = false;
1721             mConversationContainer.removeOnLayoutChangeListener(this);
1722             renderConversation(getMessageCursor());
1723         }
1724     }
1725 
1726     @Override
setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded, int heightBefore)1727     public void setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded, int heightBefore) {
1728         mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore);
1729     }
1730 
1731     /**
1732      * @return {@code true} because either the Print or Print All menu item is shown in GMail
1733      */
1734     @Override
shouldShowPrintInOverflow()1735     protected boolean shouldShowPrintInOverflow() {
1736         return true;
1737     }
1738 
1739     @Override
printConversation()1740     protected void printConversation() {
1741         PrintUtils.printConversation(mActivity.getActivityContext(), getMessageCursor(),
1742                 mAddressCache, mConversation.getBaseUri(mBaseUri), true /* useJavascript */);
1743     }
1744 }
1745