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