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