Home | History | Annotate | Download | only in ui
      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      */
    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      */
    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
    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
    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 
    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
    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 
    328     protected ConversationWebViewClient createConversationWebViewClient() {
    329         return new ConversationWebViewClient(mAccount);
    330     }
    331 
    332     @Override
    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
    398     public void onResume() {
    399         super.onResume();
    400         if (mWebView != null) {
    401             mWebView.onResume();
    402         }
    403     }
    404 
    405     @Override
    406     public void onPause() {
    407         super.onPause();
    408         if (mWebView != null) {
    409             mWebView.onPause();
    410         }
    411     }
    412 
    413     @Override
    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
    423     public void onSaveInstanceState(Bundle outState) {
    424         super.onSaveInstanceState(outState);
    425 
    426         outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent());
    427     }
    428 
    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 
    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
    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
    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      */
    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 
    541     private void handleDelayedConversationLoad() {
    542         resetLoadWaiting();
    543         startConversationLoad();
    544     }
    545 
    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      */
    559     protected void loadContent() {
    560         getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks());
    561     }
    562 
    563     private void revealConversation() {
    564         timerMark("revealing conversation");
    565         mProgressController.dismissLoadingStatus(mOnProgressDismiss);
    566     }
    567 
    568     private boolean isLoadWaiting() {
    569         return mLoadWaitReason != LOAD_NOW;
    570     }
    571 
    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      */
    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 
    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 
    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 
    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 
    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 
    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 
    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      */
    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
    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
    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
    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
    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
    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
    955     public boolean supportsMessageTransforms() {
    956         return true;
    957     }
    958 
    959     @Override
    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
    968     public void showUntransformedConversation() {
    969         super.showUntransformedConversation();
    970         renderConversation(getMessageCursor());
    971     }
    972 
    973     @Override
    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 
    984     private void showNewMessageNotification(NewMessagesInfo info) {
    985         mNewMessageBar.setText(info.getNotificationText());
    986         mNewMessageBar.setVisibility(View.VISIBLE);
    987     }
    988 
    989     private void onNewMessageBarClick() {
    990         mNewMessageBar.setVisibility(View.GONE);
    991 
    992         renderConversation(getMessageCursor()); // mCursor is already up-to-date
    993                                                 // per onLoadFinished()
    994     }
    995 
    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 
   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 
   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 
   1036     public static boolean isOverviewMode(Account acct) {
   1037         return acct.settings.isOverviewMode();
   1038     }
   1039 
   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 {
   1060         public ConversationWebViewClient(Account account) {
   1061             super(account);
   1062         }
   1063 
   1064         @Override
   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
   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
   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
   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
   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
   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
   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
   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
   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          */
   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
   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 
   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 
   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 
   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 
   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 
   1475         SetCookieTask(Context context, Uri baseUri, Uri accountCookieQueryUri) {
   1476             mUri = baseUri.toString();
   1477             mAccountCookieQueryUri = accountCookieQueryUri;
   1478             mResolver = context.getContentResolver();
   1479         }
   1480 
   1481         @Override
   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
   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
   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
   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 
   1543         private float getFocusXWebPx(ScaleGestureDetector detector) {
   1544             return (detector.getFocusX() - mSideMarginPx) / mWebView.getInitialScale();
   1545         }
   1546 
   1547         private float getFocusYWebPx(ScaleGestureDetector detector) {
   1548             return detector.getFocusY() / mWebView.getInitialScale();
   1549         }
   1550 
   1551         @Override
   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
   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
   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