blob: 6fb683f948b1d1078baabe23244a37c28e4609ed [file] [log] [blame]
/*
* Copyright (C) 2012 Google Inc.
* Licensed to The Android Open Source Project.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.mail.ui;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Loader;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.SystemClock;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewGroup;
import android.webkit.ConsoleMessage;
import android.webkit.CookieManager;
import android.webkit.CookieSyncManager;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.TextView;
import com.android.mail.FormattedDateBuilder;
import com.android.mail.R;
import com.android.mail.browse.ConversationContainer;
import com.android.mail.browse.ConversationContainer.OverlayPosition;
import com.android.mail.browse.ConversationOverlayItem;
import com.android.mail.browse.ConversationViewAdapter;
import com.android.mail.browse.ConversationViewAdapter.MessageFooterItem;
import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
import com.android.mail.browse.ConversationViewHeader;
import com.android.mail.browse.ConversationWebView;
import com.android.mail.browse.ConversationWebView.ContentSizeChangeListener;
import com.android.mail.browse.MessageCursor;
import com.android.mail.browse.MessageCursor.ConversationController;
import com.android.mail.browse.MessageCursor.ConversationMessage;
import com.android.mail.browse.MessageHeaderView;
import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
import com.android.mail.browse.ScrollIndicatorsView;
import com.android.mail.browse.SuperCollapsedBlock;
import com.android.mail.browse.WebViewContextMenu;
import com.android.mail.providers.Account;
import com.android.mail.providers.Address;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.Message;
import com.android.mail.providers.UIProvider;
import com.android.mail.ui.ConversationViewState.ExpansionState;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.Utils;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.util.List;
import java.util.Set;
/**
* The conversation view UI component.
*/
public final class ConversationViewFragment extends AbstractConversationViewFragment implements
SuperCollapsedBlock.OnClickListener,
OnLayoutChangeListener {
private static final String LOG_TAG = LogTag.getLogTag();
public static final String LAYOUT_TAG = "ConvLayout";
/**
* Difference in the height of the message header whose details have been expanded/collapsed
*/
private int mDiff = 0;
/**
* Default value for {@link #mLoadWaitReason}. Conversation load will happen immediately.
*/
private final int LOAD_NOW = 0;
/**
* Value for {@link #mLoadWaitReason} that means we are offscreen and waiting for the visible
* conversation to finish loading before beginning our load.
* <p>
* When this value is set, the fragment should register with {@link ConversationListCallbacks}
* to know when the visible conversation is loaded. When it is unset, it should unregister.
*/
private final int LOAD_WAIT_FOR_INITIAL_CONVERSATION = 1;
/**
* Value for {@link #mLoadWaitReason} used when a conversation is too heavyweight to load at
* all when not visible (e.g. requires network fetch, or too complex). Conversation load will
* wait until this fragment is visible.
*/
private final int LOAD_WAIT_UNTIL_VISIBLE = 2;
private ConversationContainer mConversationContainer;
private ConversationWebView mWebView;
private ScrollIndicatorsView mScrollIndicators;
private View mNewMessageBar;
private HtmlConversationTemplates mTemplates;
private final MailJsBridge mJsBridge = new MailJsBridge();
private final WebViewClient mWebViewClient = new ConversationWebViewClient();
private ConversationViewAdapter mAdapter;
private boolean mViewsCreated;
// True if we attempted to render before the views were laid out
// We will render immediately once layout is done
private boolean mNeedRender;
/**
* Temporary string containing the message bodies of the messages within a super-collapsed
* block, for one-time use during block expansion. We cannot easily pass the body HTML
* into JS without problematic escaping, so hold onto it momentarily and signal JS to fetch it
* using {@link MailJsBridge}.
*/
private String mTempBodiesHtml;
private int mMaxAutoLoadMessages;
/**
* If this conversation fragment is not visible, and it's inappropriate to load up front,
* this is the reason we are waiting. This flag should be cleared once it's okay to load
* the conversation.
*/
private int mLoadWaitReason = LOAD_NOW;
private boolean mEnableContentReadySignal;
private ContentSizeChangeListener mWebViewSizeChangeListener;
private float mWebViewYPercent;
/**
* Has loadData been called on the WebView yet?
*/
private boolean mWebViewLoadedData;
private long mWebViewLoadStartMs;
private final DataSetObserver mLoadedObserver = new DataSetObserver() {
@Override
public void onChanged() {
getHandler().post(new FragmentRunnable("delayedConversationLoad") {
@Override
public void go() {
LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s",
ConversationViewFragment.this);
handleDelayedConversationLoad();
}
});
}
};
private final Runnable mOnProgressDismiss = new FragmentRunnable("onProgressDismiss") {
@Override
public void go() {
if (isUserVisible()) {
onConversationSeen();
}
mWebView.onRenderComplete();
}
};
private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
private static final boolean DISABLE_OFFSCREEN_LOADING = false;
private static final boolean DEBUG_DUMP_CURSOR_CONTENTS = false;
private static final String BUNDLE_KEY_WEBVIEW_Y_PERCENT =
ConversationViewFragment.class.getName() + "webview-y-percent";
/**
* Constructor needs to be public to handle orientation changes and activity lifecycle events.
*/
public ConversationViewFragment() {
super();
}
/**
* Creates a new instance of {@link ConversationViewFragment}, initialized
* to display a conversation with other parameters inherited/copied from an existing bundle,
* typically one created using {@link #makeBasicArgs}.
*/
public static ConversationViewFragment newInstance(Bundle existingArgs,
Conversation conversation) {
ConversationViewFragment f = new ConversationViewFragment();
Bundle args = new Bundle(existingArgs);
args.putParcelable(ARG_CONVERSATION, conversation);
f.setArguments(args);
return f;
}
@Override
public void onAccountChanged(Account newAccount, Account oldAccount) {
// if overview mode has changed, re-render completely (no need to also update headers)
if (isOverviewMode(newAccount) != isOverviewMode(oldAccount)) {
setupOverviewMode();
final MessageCursor c = getMessageCursor();
if (c != null) {
renderConversation(c);
} else {
// Null cursor means this fragment is either waiting to load or in the middle of
// loading. Either way, a future render will happen anyway, and the new setting
// will take effect when that happens.
}
return;
}
// settings may have been updated; refresh views that are known to
// depend on settings
mAdapter.notifyDataSetChanged();
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s visible=%s", this, isUserVisible());
super.onActivityCreated(savedInstanceState);
if (mActivity == null || mActivity.isFinishing()) {
// Activity is finishing, just bail.
return;
}
Context context = getContext();
mTemplates = new HtmlConversationTemplates(context);
final FormattedDateBuilder dateBuilder = new FormattedDateBuilder(context);
mAdapter = new ConversationViewAdapter(mActivity, this,
getLoaderManager(), this, getContactInfoSource(), this,
this, mAddressCache, dateBuilder);
mConversationContainer.setOverlayAdapter(mAdapter);
// set up snap header (the adapter usually does this with the other ones)
final MessageHeaderView snapHeader = mConversationContainer.getSnapHeader();
snapHeader.initialize(dateBuilder, this, mAddressCache);
snapHeader.setCallbacks(this);
snapHeader.setContactInfoSource(getContactInfoSource());
mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages);
mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(getActivity()));
// set this up here instead of onCreateView to ensure the latest Account is loaded
setupOverviewMode();
// Defer the call to initLoader with a Handler.
// We want to wait until we know which fragments are present and their final visibility
// states before going off and doing work. This prevents extraneous loading from occurring
// as the ViewPager shifts about before the initial position is set.
//
// e.g. click on item #10
// ViewPager.setAdapter() actually first loads #0 and #1 under the assumption that #0 is
// the initial primary item
// Then CPC immediately sets the primary item to #10, which tears down #0/#1 and sets up
// #9/#10/#11.
getHandler().post(new FragmentRunnable("showConversation") {
@Override
public void go() {
showConversation();
}
});
if (mConversation.conversationBaseUri != null &&
!Utils.isEmpty(mAccount.accoutCookieQueryUri)) {
// Set the cookie for this base url
new SetCookieTask(getContext(), mConversation.conversationBaseUri,
mAccount.accoutCookieQueryUri).execute();
}
}
@Override
public void onCreate(Bundle savedState) {
super.onCreate(savedState);
if (savedState != null) {
mWebViewYPercent = savedState.getFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT);
}
}
@Override
public View onCreateView(LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.conversation_view, container, false);
mConversationContainer = (ConversationContainer) rootView
.findViewById(R.id.conversation_container);
mConversationContainer.setAccountController(this);
mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar);
mNewMessageBar.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
onNewMessageBarClick();
}
});
instantiateProgressIndicators(rootView);
mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
mWebView.addJavascriptInterface(mJsBridge, "mail");
// On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete
// Below JB, try to speed up initial render by having the webview do supplemental draws to
// custom a software canvas.
// TODO(mindyp):
//PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
// Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
// animation that immediately runs on page load. The app uses this as a signal that the
// content is loaded and ready to draw, since WebView delays firing this event until the
// layers are composited and everything is ready to draw.
// This signal does not seem to be reliable, so just use the old method for now.
mEnableContentReadySignal = Utils.isRunningJellybeanOrLater();
mWebView.setUseSoftwareLayer(!mEnableContentReadySignal);
mWebView.onUserVisibilityChanged(isUserVisible());
mWebView.setWebViewClient(mWebViewClient);
mWebView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
consoleMessage.sourceId(), consoleMessage.lineNumber());
return true;
}
});
final WebSettings settings = mWebView.getSettings();
mScrollIndicators = (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators);
mScrollIndicators.setSourceView(mWebView);
settings.setJavaScriptEnabled(true);
final float fontScale = getResources().getConfiguration().fontScale;
final int desiredFontSizePx = getResources()
.getInteger(R.integer.conversation_desired_font_size_px);
final int unstyledFontSizePx = getResources()
.getInteger(R.integer.conversation_unstyled_font_size_px);
int textZoom = settings.getTextZoom();
// apply a correction to the default body text style to get regular text to the size we want
textZoom = textZoom * desiredFontSizePx / unstyledFontSizePx;
// then apply any system font scaling
textZoom = (int) (textZoom * fontScale);
settings.setTextZoom(textZoom);
mViewsCreated = true;
mWebViewLoadedData = false;
return rootView;
}
@Override
public void onDestroyView() {
super.onDestroyView();
mConversationContainer.setOverlayAdapter(null);
mAdapter = null;
resetLoadWaiting(); // be sure to unregister any active load observer
mViewsCreated = false;
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT, calculateScrollYPercent());
}
private float calculateScrollYPercent() {
float p;
int scrollY = mWebView.getScrollY();
int viewH = mWebView.getHeight();
int webH = (int) (mWebView.getContentHeight() * mWebView.getScale());
if (webH == 0 || webH <= viewH) {
p = 0;
} else if (scrollY + viewH >= webH) {
// The very bottom is a special case, it acts as a stronger anchor than the scroll top
// at that point.
p = 1.0f;
} else {
p = (float) scrollY / webH;
}
return p;
}
private void resetLoadWaiting() {
if (mLoadWaitReason == LOAD_WAIT_FOR_INITIAL_CONVERSATION) {
getListController().unregisterConversationLoadedObserver(mLoadedObserver);
}
mLoadWaitReason = LOAD_NOW;
}
@Override
protected void markUnread() {
// Ignore unsafe calls made after a fragment is detached from an activity
final ControllableActivity activity = (ControllableActivity) getActivity();
if (activity == null) {
LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
return;
}
if (mViewState == null) {
LogUtils.i(LOG_TAG, "ignoring markUnread for conv with no view state (%d)",
mConversation.id);
return;
}
activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
mViewState.getUnreadMessageUris(), mViewState.getConversationInfo());
}
@Override
public void onUserVisibleHintChanged() {
final boolean userVisible = isUserVisible();
if (!userVisible) {
dismissLoadingStatus();
} else if (mViewsCreated) {
if (getMessageCursor() != null) {
LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this);
onConversationSeen();
} else if (isLoadWaiting()) {
LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this);
handleDelayedConversationLoad();
}
}
if (mWebView != null) {
mWebView.onUserVisibilityChanged(userVisible);
}
}
/**
* Will either call initLoader now to begin loading, or set {@link #mLoadWaitReason} and do
* nothing (in which case you should later call {@link #handleDelayedConversationLoad()}).
*/
private void showConversation() {
final int reason;
if (isUserVisible()) {
LogUtils.i(LOG_TAG,
"SHOWCONV: CVF is user-visible, immediately loading conversation (%s)", this);
reason = LOAD_NOW;
} else {
final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
|| (mConversation.isRemote
|| mConversation.getNumMessages() > mMaxAutoLoadMessages);
// When not visible, we should not immediately load if either this conversation is
// too heavyweight, or if the main/initial conversation is busy loading.
if (disableOffscreenLoading) {
reason = LOAD_WAIT_UNTIL_VISIBLE;
LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting until visible to load (%s)", this);
} else if (getListController().isInitialConversationLoading()) {
reason = LOAD_WAIT_FOR_INITIAL_CONVERSATION;
LogUtils.i(LOG_TAG, "SHOWCONV: CVF waiting for initial to finish (%s)", this);
getListController().registerConversationLoadedObserver(mLoadedObserver);
} else {
LogUtils.i(LOG_TAG,
"SHOWCONV: CVF is not visible, but no reason to wait. loading now. (%s)",
this);
reason = LOAD_NOW;
}
}
mLoadWaitReason = reason;
if (mLoadWaitReason == LOAD_NOW) {
startConversationLoad();
}
}
private void handleDelayedConversationLoad() {
resetLoadWaiting();
startConversationLoad();
}
private void startConversationLoad() {
mWebView.setVisibility(View.VISIBLE);
getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks());
if (isUserVisible()) {
final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger();
if (sdc != null) {
sdc.setSubject(mConversation.subject);
}
}
// TODO(mindyp): don't show loading status for a previously rendered
// conversation. Ielieve this is better done by making sure don't show loading status
// until XX ms have passed without loading completed.
showLoadingStatus();
}
private void revealConversation() {
dismissLoadingStatus(mOnProgressDismiss);
}
private boolean isLoadWaiting() {
return mLoadWaitReason != LOAD_NOW;
}
private void renderConversation(MessageCursor messageCursor) {
final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal);
if (DEBUG_DUMP_CONVERSATION_HTML) {
java.io.FileWriter fw = null;
try {
fw = new java.io.FileWriter("/sdcard/conv" + mConversation.id
+ ".html");
fw.write(convHtml);
} catch (java.io.IOException e) {
e.printStackTrace();
} finally {
if (fw != null) {
try {
fw.close();
} catch (java.io.IOException e) {
e.printStackTrace();
}
}
}
}
// save off existing scroll position before re-rendering
if (mWebViewLoadedData) {
mWebViewYPercent = calculateScrollYPercent();
}
mWebView.loadDataWithBaseURL(mBaseUri, convHtml, "text/html", "utf-8", null);
mWebViewLoadedData = true;
mWebViewLoadStartMs = SystemClock.uptimeMillis();
}
/**
* Populate the adapter with overlay views (message headers, super-collapsed blocks, a
* conversation header), and return an HTML document with spacer divs inserted for all overlays.
*
*/
private String renderMessageBodies(MessageCursor messageCursor,
boolean enableContentReadySignal) {
int pos = -1;
LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s", this);
boolean allowNetworkImages = false;
// TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
// Walk through the cursor and build up an overlay adapter as you go.
// Each overlay has an entry in the adapter for easy scroll handling in the container.
// Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
// When adding adapter items, also add their heights to help the container later determine
// overlay dimensions.
// When re-rendering, prevent ConversationContainer from laying out overlays until after
// the new spacers are positioned by WebView.
mConversationContainer.invalidateSpacerGeometry();
mAdapter.clear();
// re-evaluate the message parts of the view state, since the messages may have changed
// since the previous render
final ConversationViewState prevState = mViewState;
mViewState = new ConversationViewState(prevState);
// N.B. the units of height for spacers are actually dp and not px because WebView assumes
// a pixel is an mdpi pixel, unless you set device-dpi.
// add a single conversation header item
final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
final int convHeaderPx = measureOverlayHeight(convHeaderPos);
final int sideMarginPx = getResources().getDimensionPixelOffset(
R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset(
R.dimen.conversation_message_content_margin_side);
mTemplates.startConversation(mWebView.screenPxToWebPx(sideMarginPx),
mWebView.screenPxToWebPx(convHeaderPx));
int collapsedStart = -1;
ConversationMessage prevCollapsedMsg = null;
boolean prevSafeForImages = false;
while (messageCursor.moveToPosition(++pos)) {
final ConversationMessage msg = messageCursor.getMessage();
// TODO: save/restore 'show pics' state
final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
allowNetworkImages |= safeForImages;
final Integer savedExpanded = prevState.getExpansionState(msg);
final int expandedState;
if (savedExpanded != null) {
if (ExpansionState.isSuperCollapsed(savedExpanded) && messageCursor.isLast()) {
// override saved state when this is now the new last message
// this happens to the second-to-last message when you discard a draft
expandedState = ExpansionState.EXPANDED;
} else {
expandedState = savedExpanded;
}
} else {
// new messages that are not expanded default to being eligible for super-collapse
expandedState = (!msg.read || msg.starred || messageCursor.isLast()) ?
ExpansionState.EXPANDED : ExpansionState.SUPER_COLLAPSED;
}
mViewState.setExpansionState(msg, expandedState);
// save off "read" state from the cursor
// later, the view may not match the cursor (e.g. conversation marked read on open)
// however, if a previous state indicated this message was unread, trust that instead
// so "mark unread" marks all originally unread messages
mViewState.setReadState(msg, msg.read && !prevState.isUnread(msg));
// We only want to consider this for inclusion in the super collapsed block if
// 1) The we don't have previous state about this message (The first time that the
// user opens a conversation)
// 2) The previously saved state for this message indicates that this message is
// in the super collapsed block.
if (ExpansionState.isSuperCollapsed(expandedState)) {
// contribute to a super-collapsed block that will be emitted just before the
// next expanded header
if (collapsedStart < 0) {
collapsedStart = pos;
}
prevCollapsedMsg = msg;
prevSafeForImages = safeForImages;
continue;
}
// resolve any deferred decisions on previous collapsed items
if (collapsedStart >= 0) {
if (pos - collapsedStart == 1) {
// special-case for a single collapsed message: no need to super-collapse it
renderMessage(prevCollapsedMsg, false /* expanded */,
prevSafeForImages);
} else {
renderSuperCollapsedBlock(collapsedStart, pos - 1);
}
prevCollapsedMsg = null;
collapsedStart = -1;
}
renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages);
}
mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages);
// If the conversation has specified a base uri, use it here, use mBaseUri
final String conversationBaseUri = mConversation.conversationBaseUri != null ?
mConversation.conversationBaseUri.toString() : mBaseUri;
return mTemplates.endConversation(mBaseUri, conversationBaseUri, 320,
mWebView.getViewportWidth(), enableContentReadySignal, isOverviewMode(mAccount));
}
private void renderSuperCollapsedBlock(int start, int end) {
final int blockPos = mAdapter.addSuperCollapsedBlock(start, end);
final int blockPx = measureOverlayHeight(blockPos);
mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
}
private void renderMessage(ConversationMessage msg, boolean expanded,
boolean safeForImages) {
final int headerPos = mAdapter.addMessageHeader(msg, expanded);
final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
final int footerPos = mAdapter.addMessageFooter(headerItem);
// Measure item header and footer heights to allocate spacers in HTML
// But since the views themselves don't exist yet, render each item temporarily into
// a host view for measurement.
final int headerPx = measureOverlayHeight(headerPos);
final int footerPx = measureOverlayHeight(footerPos);
mTemplates.appendMessageHtml(msg, expanded, safeForImages,
mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
}
private String renderCollapsedHeaders(MessageCursor cursor,
SuperCollapsedBlockItem blockToReplace) {
final List<ConversationOverlayItem> replacements = Lists.newArrayList();
mTemplates.reset();
// In devices with non-integral density multiplier, screen pixels translate to non-integral
// web pixels. Keep track of the error that occurs when we cast all heights to int
float error = 0f;
for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
cursor.moveToPosition(i);
final ConversationMessage msg = cursor.getMessage();
final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg,
false /* expanded */);
final MessageFooterItem footer = mAdapter.newMessageFooterItem(header);
final int headerPx = measureOverlayHeight(header);
final int footerPx = measureOverlayHeight(footer);
error += mWebView.screenPxToWebPxError(headerPx)
+ mWebView.screenPxToWebPxError(footerPx);
// When the error becomes greater than 1 pixel, make the next header 1 pixel taller
int correction = 0;
if (error >= 1) {
correction = 1;
error -= 1;
}
mTemplates.appendMessageHtml(msg, false /* expanded */, msg.alwaysShowImages,
mWebView.screenPxToWebPx(headerPx) + correction,
mWebView.screenPxToWebPx(footerPx));
replacements.add(header);
replacements.add(footer);
mViewState.setExpansionState(msg, ExpansionState.COLLAPSED);
}
mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
mAdapter.notifyDataSetChanged();
return mTemplates.emit();
}
private int measureOverlayHeight(int position) {
return measureOverlayHeight(mAdapter.getItem(position));
}
/**
* Measure the height of an adapter view by rendering an adapter item into a temporary
* host view, and asking the view to immediately measure itself. This method will reuse
* a previous adapter view from {@link ConversationContainer}'s scrap views if one was generated
* earlier.
* <p>
* After measuring the height, this method also saves the height in the
* {@link ConversationOverlayItem} for later use in overlay positioning.
*
* @param convItem adapter item with data to render and measure
* @return height of the rendered view in screen px
*/
private int measureOverlayHeight(ConversationOverlayItem convItem) {
final int type = convItem.getType();
final View convertView = mConversationContainer.getScrapView(type);
final View hostView = mAdapter.getView(convItem, convertView, mConversationContainer,
true /* measureOnly */);
if (convertView == null) {
mConversationContainer.addScrapView(type, hostView);
}
final int heightPx = mConversationContainer.measureOverlay(hostView);
convItem.setHeight(heightPx);
convItem.markMeasurementValid();
return heightPx;
}
@Override
public void onConversationViewHeaderHeightChange(int newHeight) {
final int h = mWebView.screenPxToWebPx(newHeight);
mWebView.loadUrl(String.format("javascript:setConversationHeaderSpacerHeight(%s);", h));
}
// END conversation header callbacks
// START message header callbacks
@Override
public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
mConversationContainer.invalidateSpacerGeometry();
// update message HTML spacer height
final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
LogUtils.i(LAYOUT_TAG, "setting HTML spacer h=%dwebPx (%dscreenPx)", h,
newSpacerHeightPx);
mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %s);",
mTemplates.getMessageDomId(item.getMessage()), h));
}
@Override
public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
mConversationContainer.invalidateSpacerGeometry();
// show/hide the HTML message body and update the spacer height
final int h = mWebView.screenPxToWebPx(newSpacerHeightPx);
LogUtils.i(LAYOUT_TAG, "setting HTML spacer expanded=%s h=%dwebPx (%dscreenPx)",
item.isExpanded(), h, newSpacerHeightPx);
mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %s);",
mTemplates.getMessageDomId(item.getMessage()), item.isExpanded(), h));
mViewState.setExpansionState(item.getMessage(),
item.isExpanded() ? ExpansionState.EXPANDED : ExpansionState.COLLAPSED);
}
@Override
public void showExternalResources(Message msg) {
mWebView.getSettings().setBlockNetworkImage(false);
mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');");
}
// END message header callbacks
@Override
public void onSuperCollapsedClick(SuperCollapsedBlockItem item) {
MessageCursor cursor = getMessageCursor();
if (cursor == null || !mViewsCreated) {
return;
}
mTempBodiesHtml = renderCollapsedHeaders(cursor, item);
mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
}
private void showNewMessageNotification(NewMessagesInfo info) {
final TextView descriptionView = (TextView) mNewMessageBar.findViewById(
R.id.new_message_description);
descriptionView.setText(info.getNotificationText());
mNewMessageBar.setVisibility(View.VISIBLE);
}
private void onNewMessageBarClick() {
mNewMessageBar.setVisibility(View.GONE);
renderConversation(getMessageCursor()); // mCursor is already up-to-date
// per onLoadFinished()
}
private static OverlayPosition[] parsePositions(final String[] topArray,
final String[] bottomArray) {
final int len = topArray.length;
final OverlayPosition[] positions = new OverlayPosition[len];
for (int i = 0; i < len; i++) {
positions[i] = new OverlayPosition(
Integer.parseInt(topArray[i]), Integer.parseInt(bottomArray[i]));
}
return positions;
}
@Override
public String toString() {
// log extra info at DEBUG level or finer
final String s = super.toString();
if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
return s;
}
return "(" + s + " subj=" + mConversation.subject + ")";
}
private Address getAddress(String rawFrom) {
Address addr = mAddressCache.get(rawFrom);
if (addr == null) {
addr = Address.getEmailAddress(rawFrom);
mAddressCache.put(rawFrom, addr);
}
return addr;
}
private void ensureContentSizeChangeListener() {
if (mWebViewSizeChangeListener == null) {
mWebViewSizeChangeListener = new ConversationWebView.ContentSizeChangeListener() {
@Override
public void onHeightChange(int h) {
// When WebKit says the DOM height has changed, re-measure
// bodies and re-position their headers.
// This is separate from the typical JavaScript DOM change
// listeners because cases like NARROW_COLUMNS text reflow do not trigger DOM
// events.
mWebView.loadUrl("javascript:measurePositions();");
}
};
}
mWebView.setContentSizeChangeListener(mWebViewSizeChangeListener);
}
private static boolean isOverviewMode(Account acct) {
return acct.settings.conversationViewMode == UIProvider.ConversationViewMode.OVERVIEW;
}
private void setupOverviewMode() {
final boolean overviewMode = isOverviewMode(mAccount);
final WebSettings settings = mWebView.getSettings();
settings.setUseWideViewPort(overviewMode);
settings.setSupportZoom(overviewMode);
if (overviewMode) {
settings.setBuiltInZoomControls(true);
settings.setDisplayZoomControls(false);
}
}
private class ConversationWebViewClient extends AbstractConversationWebViewClient {
@Override
public void onPageFinished(WebView view, String url) {
// Ignore unsafe calls made after a fragment is detached from an activity.
// This method needs to, for example, get at the loader manager, which needs
// the fragment to be added.
final ControllableActivity activity = (ControllableActivity) getActivity();
if (!isAdded() || !mViewsCreated) {
LogUtils.i(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url,
ConversationViewFragment.this);
return;
}
LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s wv=%s t=%sms", url,
ConversationViewFragment.this, view,
(SystemClock.uptimeMillis() - mWebViewLoadStartMs));
ensureContentSizeChangeListener();
if (!mEnableContentReadySignal) {
revealConversation();
}
final Set<String> emailAddresses = Sets.newHashSet();
for (Address addr : mAddressCache.values()) {
emailAddresses.add(addr.getAddress());
}
ContactLoaderCallbacks callbacks = getContactInfoSource();
getContactInfoSource().setSenders(emailAddresses);
getLoaderManager().restartLoader(CONTACT_LOADER, Bundle.EMPTY, callbacks);
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
return mViewsCreated && super.shouldOverrideUrlLoading(view, url);
}
}
/**
* NOTE: all public methods must be listed in the proguard flags so that they can be accessed
* via reflection and not stripped.
*
*/
private class MailJsBridge {
@SuppressWarnings("unused")
@JavascriptInterface
public void onWebContentGeometryChange(final String[] overlayTopStrs,
final String[] overlayBottomStrs) {
getHandler().post(new FragmentRunnable("onWebContentGeometryChange") {
@Override
public void go() {
try {
if (!mViewsCreated) {
LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views"
+ " are gone, %s", ConversationViewFragment.this);
return;
}
mConversationContainer.onGeometryChange(
parsePositions(overlayTopStrs, overlayBottomStrs));
if (mDiff != 0) {
// SCROLL!
int scale = (int) (mWebView.getScale() / mWebView.getInitialScale());
if (scale > 1) {
mWebView.scrollBy(0, (mDiff * (scale - 1)));
}
mDiff = 0;
}
} catch (Throwable t) {
LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onWebContentGeometryChange");
}
}
});
}
@SuppressWarnings("unused")
@JavascriptInterface
public String getTempMessageBodies() {
try {
if (!mViewsCreated) {
return "";
}
final String s = mTempBodiesHtml;
mTempBodiesHtml = null;
return s;
} catch (Throwable t) {
LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getTempMessageBodies");
return "";
}
}
@SuppressWarnings("unused")
@JavascriptInterface
public String getMessageBody(String domId) {
try {
final MessageCursor cursor = getMessageCursor();
if (!mViewsCreated || cursor == null) {
return "";
}
int pos = -1;
while (cursor.moveToPosition(++pos)) {
final ConversationMessage msg = cursor.getMessage();
if (TextUtils.equals(domId, mTemplates.getMessageDomId(msg))) {
return msg.getBodyAsHtml();
}
}
return "";
} catch (Throwable t) {
LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody");
return "";
}
}
@SuppressWarnings("unused")
@JavascriptInterface
public void onContentReady() {
getHandler().post(new FragmentRunnable("onContentReady") {
@Override
public void go() {
try {
if (mWebViewLoadStartMs != 0) {
LogUtils.i(LOG_TAG, "IN CVF.onContentReady, f=%s vis=%s t=%sms",
ConversationViewFragment.this,
isUserVisible(),
(SystemClock.uptimeMillis() - mWebViewLoadStartMs));
}
revealConversation();
} catch (Throwable t) {
LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady");
// Still try to show the conversation.
revealConversation();
}
}
});
}
@SuppressWarnings("unused")
@JavascriptInterface
public float getScrollYPercent() {
try {
return mWebViewYPercent;
} catch (Throwable t) {
LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent");
return 0f;
}
}
}
private class NewMessagesInfo {
int count;
int countFromSelf;
String senderAddress;
/**
* Return the display text for the new message notification overlay. It will be formatted
* appropriately for a single new message vs. multiple new messages.
*
* @return display text
*/
public String getNotificationText() {
Resources res = getResources();
if (count > 1) {
return res.getString(R.string.new_incoming_messages_many, count);
} else {
final Address addr = getAddress(senderAddress);
return res.getString(R.string.new_incoming_messages_one,
TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName());
}
}
}
@Override
public void onMessageCursorLoadFinished(Loader<Cursor> loader, MessageCursor newCursor,
MessageCursor oldCursor) {
/*
* what kind of changes affect the MessageCursor? 1. new message(s) 2.
* read/unread state change 3. deleted message, either regular or draft
* 4. updated message, either from self or from others, updated in
* content or state or sender 5. star/unstar of message (technically
* similar to #1) 6. other label change Use MessageCursor.hashCode() to
* sort out interesting vs. no-op cursor updates.
*/
if (oldCursor != null && !oldCursor.isClosed()) {
final NewMessagesInfo info = getNewIncomingMessagesInfo(newCursor);
if (info.count > 0) {
// don't immediately render new incoming messages from other
// senders
// (to avoid a new message from losing the user's focus)
LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
+ ", holding cursor for new incoming message (%s)", this);
showNewMessageNotification(info);
return;
}
final int oldState = oldCursor.getStateHashCode();
final boolean changed = newCursor.getStateHashCode() != oldState;
if (!changed) {
final boolean processedInPlace = processInPlaceUpdates(newCursor, oldCursor);
if (processedInPlace) {
LogUtils.i(LOG_TAG, "CONV RENDER: processed update(s) in place (%s)", this);
} else {
LogUtils.i(LOG_TAG, "CONV RENDER: uninteresting update"
+ ", ignoring this conversation update (%s)", this);
}
return;
} else if (info.countFromSelf == 1) {
// Special-case the very common case of a new cursor that is the same as the old
// one, except that there is a new message from yourself. This happens upon send.
final boolean sameExceptNewLast = newCursor.getStateHashCode(1) == oldState;
if (sameExceptNewLast) {
LogUtils.i(LOG_TAG, "CONV RENDER: update is a single new message from self"
+ " (%s)", this);
newCursor.moveToLast();
processNewOutgoingMessage(newCursor.getMessage());
return;
}
}
// cursors are different, and not due to an incoming message. fall
// through and render.
LogUtils.i(LOG_TAG, "CONV RENDER: conversation updated"
+ ", but not due to incoming message. rendering. (%s)", this);
if (DEBUG_DUMP_CURSOR_CONTENTS) {
LogUtils.i(LOG_TAG, "old cursor: %s", oldCursor.getDebugDump());
LogUtils.i(LOG_TAG, "new cursor: %s", newCursor.getDebugDump());
}
} else {
LogUtils.i(LOG_TAG, "CONV RENDER: initial render. (%s)", this);
}
// if layout hasn't happened, delay render
// This is needed in addition to the showConversation() delay to speed
// up rotation and restoration.
if (mConversationContainer.getWidth() == 0) {
mNeedRender = true;
mConversationContainer.addOnLayoutChangeListener(this);
} else {
renderConversation(newCursor);
}
}
private NewMessagesInfo getNewIncomingMessagesInfo(MessageCursor newCursor) {
final NewMessagesInfo info = new NewMessagesInfo();
int pos = -1;
while (newCursor.moveToPosition(++pos)) {
final Message m = newCursor.getMessage();
if (!mViewState.contains(m)) {
LogUtils.i(LOG_TAG, "conversation diff: found new msg: %s", m.uri);
final Address from = getAddress(m.getFrom());
// distinguish ours from theirs
// new messages from the account owner should not trigger a
// notification
if (mAccount.ownsFromAddress(from.getAddress())) {
LogUtils.i(LOG_TAG, "found message from self: %s", m.uri);
info.countFromSelf++;
continue;
}
info.count++;
info.senderAddress = m.getFrom();
}
}
return info;
}
private boolean processInPlaceUpdates(MessageCursor newCursor, MessageCursor oldCursor) {
final Set<String> idsOfChangedBodies = Sets.newHashSet();
final List<Integer> changedOverlayPositions = Lists.newArrayList();
boolean changed = false;
int pos = 0;
while (true) {
if (!newCursor.moveToPosition(pos) || !oldCursor.moveToPosition(pos)) {
break;
}
final ConversationMessage newMsg = newCursor.getMessage();
final ConversationMessage oldMsg = oldCursor.getMessage();
if (!TextUtils.equals(newMsg.getFrom(), oldMsg.getFrom()) ||
newMsg.isSending != oldMsg.isSending) {
mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions);
LogUtils.i(LOG_TAG, "msg #%d (%d): detected from/sending change. isSending=%s",
pos, newMsg.id, newMsg.isSending);
}
// update changed message bodies in-place
if (!TextUtils.equals(newMsg.bodyHtml, oldMsg.bodyHtml) ||
!TextUtils.equals(newMsg.bodyText, oldMsg.bodyText)) {
// maybe just set a flag to notify JS to re-request changed bodies
idsOfChangedBodies.add('"' + mTemplates.getMessageDomId(newMsg) + '"');
LogUtils.i(LOG_TAG, "msg #%d (%d): detected body change", pos, newMsg.id);
}
pos++;
}
if (!changedOverlayPositions.isEmpty()) {
// notify once after the entire adapter is updated
mConversationContainer.onOverlayModelUpdate(changedOverlayPositions);
changed = true;
}
if (!idsOfChangedBodies.isEmpty()) {
mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);",
TextUtils.join(",", idsOfChangedBodies)));
changed = true;
}
return changed;
}
private void processNewOutgoingMessage(ConversationMessage msg) {
mTemplates.reset();
// this method will add some items to mAdapter, but we deliberately want to avoid notifying
// adapter listeners (i.e. ConversationContainer) until onWebContentGeometryChange is next
// called, to prevent N+1 headers rendering with N message bodies.
renderMessage(msg, true /* expanded */, msg.alwaysShowImages);
mTempBodiesHtml = mTemplates.emit();
mViewState.setExpansionState(msg, ExpansionState.EXPANDED);
// FIXME: should the provider set this as initial state?
mViewState.setReadState(msg, false /* read */);
// From now until the updated spacer geometry is returned, the adapter items are mismatched
// with the existing spacers. Do not let them layout.
mConversationContainer.invalidateSpacerGeometry();
mWebView.loadUrl("javascript:appendMessageHtml();");
}
private class SetCookieTask extends AsyncTask<Void, Void, Void> {
final String mUri;
final Uri mAccountCookieQueryUri;
final ContentResolver mResolver;
SetCookieTask(Context context, Uri baseUri, Uri accountCookieQueryUri) {
mUri = baseUri.toString();
mAccountCookieQueryUri = accountCookieQueryUri;
mResolver = context.getContentResolver();
}
@Override
public Void doInBackground(Void... args) {
// First query for the coookie string from the UI provider
final Cursor cookieCursor = mResolver.query(mAccountCookieQueryUri,
UIProvider.ACCOUNT_COOKIE_PROJECTION, null, null, null);
if (cookieCursor == null) {
return null;
}
try {
if (cookieCursor.moveToFirst()) {
final String cookie = cookieCursor.getString(
cookieCursor.getColumnIndex(UIProvider.AccountCookieColumns.COOKIE));
if (cookie != null) {
final CookieSyncManager csm =
CookieSyncManager.createInstance(getContext());
CookieManager.getInstance().setCookie(mUri, cookie);
csm.sync();
}
}
} finally {
cookieCursor.close();
}
return null;
}
}
@Override
public void onConversationUpdated(Conversation conv) {
final ConversationViewHeader headerView = (ConversationViewHeader) mConversationContainer
.findViewById(R.id.conversation_header);
mConversation = conv;
if (headerView != null) {
headerView.onConversationUpdated(conv);
}
}
@Override
public void onLayoutChange(View v, int left, int top, int right,
int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
boolean sizeChanged = mNeedRender
&& mConversationContainer.getWidth() != 0;
if (sizeChanged) {
mNeedRender = false;
mConversationContainer.removeOnLayoutChangeListener(this);
renderConversation(getMessageCursor());
}
}
@Override
public void setMessageDetailsExpanded(MessageHeaderItem i, boolean expanded,
int heightBefore) {
mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore);
}
}