| /* |
| * 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.graphics.Rect; |
| import android.net.Uri; |
| import android.os.AsyncTask; |
| import android.os.Bundle; |
| import android.os.SystemClock; |
| import android.support.annotation.IdRes; |
| import android.support.annotation.Nullable; |
| import android.support.v4.text.BidiFormatter; |
| import android.support.v4.util.ArrayMap; |
| import android.text.TextUtils; |
| import android.view.KeyEvent; |
| 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.WebResourceResponse; |
| import android.webkit.WebSettings; |
| import android.webkit.WebView; |
| |
| import com.android.emailcommon.mail.Address; |
| import com.android.mail.FormattedDateBuilder; |
| import com.android.mail.R; |
| import com.android.mail.analytics.Analytics; |
| import com.android.mail.analytics.AnalyticsTimer; |
| import com.android.mail.browse.ConversationContainer; |
| import com.android.mail.browse.ConversationContainer.OverlayPosition; |
| import com.android.mail.browse.ConversationFooterView.ConversationFooterCallbacks; |
| import com.android.mail.browse.ConversationMessage; |
| import com.android.mail.browse.ConversationOverlayItem; |
| import com.android.mail.browse.ConversationViewAdapter; |
| import com.android.mail.browse.ConversationViewAdapter.ConversationFooterItem; |
| 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.InlineAttachmentViewIntentBuilderCreator; |
| import com.android.mail.browse.InlineAttachmentViewIntentBuilderCreatorHolder; |
| import com.android.mail.browse.MailWebView.ContentSizeChangeListener; |
| import com.android.mail.browse.MessageCursor; |
| import com.android.mail.browse.MessageFooterView; |
| import com.android.mail.browse.MessageHeaderView; |
| import com.android.mail.browse.ScrollIndicatorsView; |
| import com.android.mail.browse.SuperCollapsedBlock; |
| import com.android.mail.browse.WebViewContextMenu; |
| import com.android.mail.content.ObjectCursor; |
| import com.android.mail.print.PrintUtils; |
| import com.android.mail.providers.Account; |
| import com.android.mail.providers.Conversation; |
| import com.android.mail.providers.Message; |
| import com.android.mail.providers.Settings; |
| import com.android.mail.providers.UIProvider; |
| import com.android.mail.ui.ConversationViewState.ExpansionState; |
| import com.android.mail.utils.ConversationViewUtils; |
| import com.android.mail.utils.KeyboardUtils; |
| import com.android.mail.utils.LogTag; |
| import com.android.mail.utils.LogUtils; |
| import com.android.mail.utils.Utils; |
| import com.android.mail.utils.ViewUtils; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Maps; |
| import com.google.common.collect.Sets; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * The conversation view UI component. |
| */ |
| public class ConversationViewFragment extends AbstractConversationViewFragment implements |
| SuperCollapsedBlock.OnClickListener, OnLayoutChangeListener, |
| MessageHeaderView.MessageHeaderViewCallbacks, MessageFooterView.MessageFooterCallbacks, |
| WebViewContextMenu.Callbacks, ConversationFooterCallbacks, View.OnKeyListener { |
| |
| 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; |
| |
| // Default scroll distance when the user tries to scroll with up/down |
| private final int DEFAULT_VERTICAL_SCROLL_DISTANCE_PX = 50; |
| |
| // Keyboard navigation |
| private KeyboardNavigationController mNavigationController; |
| // Since we manually control navigation for most of the conversation view due to problems |
| // with two-pane layout but still rely on the system for SOME navigation, we need to keep track |
| // of the view that had focus when KeyEvent.ACTION_DOWN was fired. This is because we only |
| // manually change focus on KeyEvent.ACTION_UP (to prevent holding down the DOWN button and |
| // lagging the app), however, the view in focus might have changed between ACTION_UP and |
| // ACTION_DOWN since the system might have handled the ACTION_DOWN and moved focus. |
| private View mOriginalKeyedView; |
| private int mMaxScreenHeight; |
| private int mTopOfVisibleScreen; |
| |
| protected ConversationContainer mConversationContainer; |
| |
| protected ConversationWebView mWebView; |
| |
| private ViewGroup mTopmostOverlay; |
| |
| private ConversationViewProgressController mProgressController; |
| |
| private ActionableToastBar mNewMessageBar; |
| private ActionableToastBar.ActionClickedListener mNewMessageBarActionListener; |
| |
| protected HtmlConversationTemplates mTemplates; |
| |
| private final MailJsBridge mJsBridge = new MailJsBridge(); |
| |
| protected ConversationViewAdapter mAdapter; |
| |
| protected 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; |
| |
| protected int mSideMarginPx; |
| |
| /** |
| * 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 Map<String, String> mMessageTransforms = Maps.newHashMap(); |
| |
| private final DataSetObserver mLoadedObserver = new DataSetObserver() { |
| @Override |
| public void onChanged() { |
| getHandler().post(new FragmentRunnable("delayedConversationLoad", |
| ConversationViewFragment.this) { |
| @Override |
| public void go() { |
| LogUtils.d(LOG_TAG, "CVF load observer fired, this=%s", |
| ConversationViewFragment.this); |
| handleDelayedConversationLoad(); |
| } |
| }); |
| } |
| }; |
| |
| private final Runnable mOnProgressDismiss = new FragmentRunnable("onProgressDismiss", this) { |
| @Override |
| public void go() { |
| LogUtils.d(LOG_TAG, "onProgressDismiss go() - isUserVisible() = %b", isUserVisible()); |
| 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"; |
| |
| private BidiFormatter mBidiFormatter; |
| |
| /** |
| * Contains a mapping between inline image attachments and their local message id. |
| */ |
| private Map<String, String> mUrlToMessageIdMap; |
| |
| /** |
| * Constructor needs to be public to handle orientation changes and activity lifecycle events. |
| */ |
| public ConversationViewFragment() {} |
| |
| /** |
| * 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); |
| |
| mNavigationController = mActivity.getKeyboardNavigationController(); |
| |
| mAdapter = new ConversationViewAdapter(mActivity, this, |
| getLoaderManager(), this, this, getContactInfoSource(), this, this, |
| getListController(), this, mAddressCache, dateBuilder, mBidiFormatter, this); |
| mConversationContainer.setOverlayAdapter(mAdapter); |
| |
| // set up snap header (the adapter usually does this with the other ones) |
| mConversationContainer.getSnapHeader().initialize( |
| this, mAddressCache, this, getContactInfoSource(), |
| mActivity.getAccountController().getVeiledAddressMatcher()); |
| |
| final Resources resources = getResources(); |
| mMaxAutoLoadMessages = resources.getInteger(R.integer.max_auto_load_messages); |
| |
| mSideMarginPx = resources.getDimensionPixelOffset( |
| R.dimen.conversation_message_content_margin_side); |
| |
| mUrlToMessageIdMap = new ArrayMap<String, String>(); |
| final InlineAttachmentViewIntentBuilderCreator creator = |
| InlineAttachmentViewIntentBuilderCreatorHolder. |
| getInlineAttachmentViewIntentCreator(); |
| final WebViewContextMenu contextMenu = new WebViewContextMenu(getActivity(), |
| creator.createInlineAttachmentViewIntentBuilder(mAccount, |
| mConversation != null ? mConversation.id : -1)); |
| contextMenu.setCallbacks(this); |
| mWebView.setOnCreateContextMenuListener(contextMenu); |
| |
| // 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", this) { |
| @Override |
| public void go() { |
| showConversation(); |
| } |
| }); |
| |
| if (mConversation != null && mConversation.conversationBaseUri != null && |
| !Utils.isEmpty(mAccount.accountCookieQueryUri)) { |
| // Set the cookie for this base url |
| new SetCookieTask(getContext(), mConversation.conversationBaseUri.toString(), |
| mAccount.accountCookieQueryUri).execute(); |
| } |
| |
| // Find the height of the screen for manually scrolling the webview via keyboard. |
| final Rect screen = new Rect(); |
| mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(screen); |
| mMaxScreenHeight = screen.bottom; |
| mTopOfVisibleScreen = screen.top + mActivity.getSupportActionBar().getHeight(); |
| } |
| |
| @Override |
| public void onCreate(Bundle savedState) { |
| super.onCreate(savedState); |
| |
| mWebViewClient = createConversationWebViewClient(); |
| |
| if (savedState != null) { |
| mWebViewYPercent = savedState.getFloat(BUNDLE_KEY_WEBVIEW_Y_PERCENT); |
| } |
| |
| mBidiFormatter = BidiFormatter.getInstance(); |
| } |
| |
| protected ConversationWebViewClient createConversationWebViewClient() { |
| return new ConversationWebViewClient(mAccount); |
| } |
| |
| @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); |
| |
| mTopmostOverlay = |
| (ViewGroup) mConversationContainer.findViewById(R.id.conversation_topmost_overlay); |
| mTopmostOverlay.setOnKeyListener(this); |
| inflateSnapHeader(mTopmostOverlay, inflater); |
| mConversationContainer.setupSnapHeader(); |
| |
| setupNewMessageBar(); |
| |
| mProgressController = new ConversationViewProgressController(this, getHandler()); |
| mProgressController.instantiateProgressIndicators(rootView); |
| |
| mWebView = (ConversationWebView) |
| mConversationContainer.findViewById(R.id.conversation_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. |
| final boolean isJBOrLater = Utils.isRunningJellybeanOrLater(); |
| final boolean isUserVisible = isUserVisible(); |
| mWebView.setUseSoftwareLayer(!isJBOrLater); |
| mEnableContentReadySignal = isJBOrLater && isUserVisible; |
| mWebView.onUserVisibilityChanged(isUserVisible); |
| mWebView.setWebViewClient(mWebViewClient); |
| final WebChromeClient wcc = new WebChromeClient() { |
| @Override |
| public boolean onConsoleMessage(ConsoleMessage consoleMessage) { |
| if (consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { |
| LogUtils.e(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(), |
| consoleMessage.sourceId(), consoleMessage.lineNumber(), |
| ConversationViewFragment.this); |
| } else { |
| LogUtils.i(LOG_TAG, "JS: %s (%s:%d) f=%s", consoleMessage.message(), |
| consoleMessage.sourceId(), consoleMessage.lineNumber(), |
| ConversationViewFragment.this); |
| } |
| return true; |
| } |
| }; |
| mWebView.setWebChromeClient(wcc); |
| |
| final WebSettings settings = mWebView.getSettings(); |
| |
| final ScrollIndicatorsView scrollIndicators = |
| (ScrollIndicatorsView) rootView.findViewById(R.id.scroll_indicators); |
| scrollIndicators.setSourceView(mWebView); |
| |
| settings.setJavaScriptEnabled(true); |
| |
| ConversationViewUtils.setTextZoom(getResources(), settings); |
| |
| mViewsCreated = true; |
| mWebViewLoadedData = false; |
| |
| return rootView; |
| } |
| |
| protected void inflateSnapHeader(ViewGroup topmostOverlay, LayoutInflater inflater) { |
| inflater.inflate(R.layout.conversation_topmost_overlay_items, topmostOverlay, true); |
| } |
| |
| protected void setupNewMessageBar() { |
| mNewMessageBar = (ActionableToastBar) mConversationContainer.findViewById( |
| R.id.new_message_notification_bar); |
| mNewMessageBarActionListener = new ActionableToastBar.ActionClickedListener() { |
| @Override |
| public void onActionClicked(Context context) { |
| onNewMessageBarClick(); |
| } |
| }; |
| } |
| |
| @Override |
| public void onResume() { |
| super.onResume(); |
| if (mWebView != null) { |
| mWebView.onResume(); |
| } |
| } |
| |
| @Override |
| public void onPause() { |
| super.onPause(); |
| if (mWebView != null) { |
| mWebView.onPause(); |
| } |
| } |
| |
| @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() { |
| final float p; |
| if (mWebView == null) { |
| // onCreateView hasn't been called, return 0 as the user hasn't scrolled the view. |
| return 0; |
| } |
| |
| final int scrollY = mWebView.getScrollY(); |
| final int viewH = mWebView.getHeight(); |
| final 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() { |
| super.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(); |
| LogUtils.d(LOG_TAG, "ConversationViewFragment#onUserVisibleHintChanged(), userVisible = %b", |
| userVisible); |
| |
| if (!userVisible) { |
| mProgressController.dismissLoadingStatus(); |
| } else if (mViewsCreated) { |
| String loadTag = null; |
| final boolean isInitialLoading; |
| if (mActivity != null) { |
| isInitialLoading = mActivity.getConversationUpdater() |
| .isInitialConversationLoading(); |
| } else { |
| isInitialLoading = true; |
| } |
| |
| if (getMessageCursor() != null) { |
| LogUtils.d(LOG_TAG, "Fragment is now user-visible, onConversationSeen: %s", this); |
| if (!isInitialLoading) { |
| loadTag = "preloaded"; |
| } |
| onConversationSeen(); |
| } else if (isLoadWaiting()) { |
| LogUtils.d(LOG_TAG, "Fragment is now user-visible, showing conversation: %s", this); |
| if (!isInitialLoading) { |
| loadTag = "load_deferred"; |
| } |
| handleDelayedConversationLoad(); |
| } |
| |
| if (loadTag != null) { |
| // pager swipes are visibility transitions to 'visible' except during initial |
| // pager load on A) enter conversation mode B) rotate C) 2-pane conv-mode list-tap |
| Analytics.getInstance().sendEvent("pager_swipe", loadTag, |
| getCurrentFolderTypeDesc(), 0); |
| } |
| } |
| |
| 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; |
| timerMark("CVF.showConversation"); |
| } else { |
| final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING |
| || Utils.isLowRamDevice(getContext()) |
| || (mConversation != null && (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); |
| loadContent(); |
| // 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. |
| mProgressController.showLoadingStatus(isUserVisible()); |
| } |
| |
| /** |
| * Can be overridden in case a subclass needs to load something other than |
| * the messages of a conversation. |
| */ |
| protected void loadContent() { |
| getLoaderManager().initLoader(MESSAGE_LOADER, Bundle.EMPTY, getMessageLoaderCallbacks()); |
| } |
| |
| private void revealConversation() { |
| timerMark("revealing conversation"); |
| mProgressController.dismissLoadingStatus(mOnProgressDismiss); |
| if (isUserVisible()) { |
| AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST, |
| true /* isDestructive */, "open_conversation", "from_list", null); |
| } |
| } |
| |
| private boolean isLoadWaiting() { |
| return mLoadWaitReason != LOAD_NOW; |
| } |
| |
| private void renderConversation(MessageCursor messageCursor) { |
| final String convHtml = renderMessageBodies(messageCursor, mEnableContentReadySignal); |
| timerMark("rendered conversation"); |
| |
| if (DEBUG_DUMP_CONVERSATION_HTML) { |
| java.io.FileWriter fw = null; |
| try { |
| fw = new java.io.FileWriter(getSdCardFilePath()); |
| 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(); |
| } |
| |
| protected String getSdCardFilePath() { |
| return "/sdcard/conv" + mConversation.id + ".html"; |
| } |
| |
| /** |
| * 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. |
| * |
| */ |
| protected 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); |
| |
| mTemplates.startConversation(mWebView.getViewportWidth(), |
| mWebView.screenPxToWebPx(mSideMarginPx), mWebView.screenPxToWebPx(convHeaderPx)); |
| |
| int collapsedStart = -1; |
| ConversationMessage prevCollapsedMsg = null; |
| |
| final boolean alwaysShowImages = shouldAlwaysShowImages(); |
| |
| boolean prevSafeForImages = alwaysShowImages; |
| |
| boolean hasDraft = false; |
| while (messageCursor.moveToPosition(++pos)) { |
| final ConversationMessage msg = messageCursor.getMessage(); |
| |
| final boolean safeForImages = alwaysShowImages || |
| msg.alwaysShowImages || prevState.getShouldShowImages(msg); |
| 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 |
| if (msg.starred || !msg.read || messageCursor.isLast()) { |
| expandedState = ExpansionState.EXPANDED; |
| } else if (messageCursor.isFirst()) { |
| expandedState = ExpansionState.COLLAPSED; |
| } else { |
| expandedState = ExpansionState.SUPER_COLLAPSED; |
| hasDraft |= msg.isDraft(); |
| } |
| } |
| mViewState.setShouldShowImages(msg, prevState.getShouldShowImages(msg)); |
| 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; |
| |
| // This line puts the from address in the address cache so that |
| // we get the sender image for it if it's in a super-collapsed block. |
| getAddress(msg.getFrom()); |
| 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, hasDraft); |
| } |
| hasDraft = false; // reset hasDraft |
| prevCollapsedMsg = null; |
| collapsedStart = -1; |
| } |
| |
| renderMessage(msg, ExpansionState.isExpanded(expandedState), safeForImages); |
| } |
| |
| final MessageHeaderItem lastHeaderItem = getLastMessageHeaderItem(); |
| final int convFooterPos = mAdapter.addConversationFooter(lastHeaderItem); |
| final int convFooterPx = measureOverlayHeight(convFooterPos); |
| |
| mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages); |
| |
| final boolean applyTransforms = shouldApplyTransforms(); |
| |
| // If the conversation has specified a base uri, use it here, otherwise use mBaseUri |
| return mTemplates.endConversation(mWebView.screenPxToWebPx(convFooterPx), mBaseUri, |
| mConversation.getBaseUri(mBaseUri), |
| mWebView.getViewportWidth(), mWebView.getWidthInDp(mSideMarginPx), |
| enableContentReadySignal, isOverviewMode(mAccount), applyTransforms, |
| applyTransforms); |
| } |
| |
| private MessageHeaderItem getLastMessageHeaderItem() { |
| final int count = mAdapter.getCount(); |
| if (count < 3) { |
| LogUtils.wtf(LOG_TAG, "not enough items in the adapter. count: %s", count); |
| return null; |
| } |
| return (MessageHeaderItem) mAdapter.getItem(count - 2); |
| } |
| |
| private void renderSuperCollapsedBlock(int start, int end, boolean hasDraft) { |
| final int blockPos = mAdapter.addSuperCollapsedBlock(start, end, hasDraft); |
| 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, |
| mViewState.getShouldShowImages(msg)); |
| 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)); |
| timerMark("rendered message"); |
| } |
| |
| private String renderCollapsedHeaders(MessageCursor cursor, |
| SuperCollapsedBlockItem blockToReplace) { |
| final List<ConversationOverlayItem> replacements = Lists.newArrayList(); |
| |
| mTemplates.reset(); |
| |
| final boolean alwaysShowImages = (mAccount != null) && |
| (mAccount.settings.showImages == Settings.ShowImages.ALWAYS); |
| |
| // 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; |
| boolean first = true; |
| for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) { |
| cursor.moveToPosition(i); |
| final ConversationMessage msg = cursor.getMessage(); |
| |
| final MessageHeaderItem header = ConversationViewAdapter.newMessageHeaderItem( |
| mAdapter, mAdapter.getDateBuilder(), msg, false /* expanded */, |
| alwaysShowImages || mViewState.getShouldShowImages(msg)); |
| final MessageFooterItem footer = mAdapter.newMessageFooterItem(mAdapter, 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 */, |
| alwaysShowImages || 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(); |
| } |
| |
| protected 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 conversation footer callbacks |
| |
| @Override |
| public void onConversationFooterHeightChange(int newHeight) { |
| final int h = mWebView.screenPxToWebPx(newHeight); |
| |
| mWebView.loadUrl(String.format("javascript:setConversationFooterSpacerHeight(%s);", h)); |
| } |
| |
| // END conversation footer 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(final Message msg) { |
| mViewState.setShouldShowImages(msg, true); |
| mWebView.getSettings().setBlockNetworkImage(false); |
| mWebView.loadUrl("javascript:unblockImages(['" + mTemplates.getMessageDomId(msg) + "']);"); |
| } |
| |
| @Override |
| public void showExternalResources(final String senderRawAddress) { |
| mWebView.getSettings().setBlockNetworkImage(false); |
| |
| final Address sender = getAddress(senderRawAddress); |
| if (sender == null) { |
| // Don't need to unblock any images |
| return; |
| } |
| final MessageCursor cursor = getMessageCursor(); |
| |
| final List<String> messageDomIds = new ArrayList<>(); |
| |
| int pos = -1; |
| while (cursor.moveToPosition(++pos)) { |
| final ConversationMessage message = cursor.getMessage(); |
| if (sender.equals(getAddress(message.getFrom()))) { |
| message.alwaysShowImages = true; |
| |
| mViewState.setShouldShowImages(message, true); |
| messageDomIds.add(mTemplates.getMessageDomId(message)); |
| } |
| } |
| |
| final String url = String.format( |
| "javascript:unblockImages(['%s']);", TextUtils.join("','", messageDomIds)); |
| mWebView.loadUrl(url); |
| } |
| |
| @Override |
| public boolean supportsMessageTransforms() { |
| return true; |
| } |
| |
| @Override |
| public String getMessageTransforms(final Message msg) { |
| final String domId = mTemplates.getMessageDomId(msg); |
| return (domId == null) ? null : mMessageTransforms.get(domId); |
| } |
| |
| @Override |
| public boolean isSecure() { |
| return false; |
| } |
| |
| // END message header callbacks |
| |
| @Override |
| public void showUntransformedConversation() { |
| super.showUntransformedConversation(); |
| renderConversation(getMessageCursor()); |
| } |
| |
| @Override |
| public void onSuperCollapsedClick(SuperCollapsedBlockItem item) { |
| MessageCursor cursor = getMessageCursor(); |
| if (cursor == null || !mViewsCreated) { |
| return; |
| } |
| |
| mTempBodiesHtml = renderCollapsedHeaders(cursor, item); |
| mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")"); |
| mConversationContainer.focusFirstMessageHeader(); |
| } |
| |
| private void showNewMessageNotification(NewMessagesInfo info) { |
| mNewMessageBar.show(mNewMessageBarActionListener, info.getNotificationText(), R.string.show, |
| true /* replaceVisibleToast */, false /* autohide */, null /* ToastBarOperation */); |
| } |
| |
| private void onNewMessageBarClick() { |
| mNewMessageBar.hide(true, true); |
| |
| renderConversation(getMessageCursor()); // mCursor is already up-to-date |
| // per onLoadFinished() |
| } |
| |
| private static OverlayPosition[] parsePositions(final int[] topArray, final int[] bottomArray) { |
| final int len = topArray.length; |
| final OverlayPosition[] positions = new OverlayPosition[len]; |
| for (int i = 0; i < len; i++) { |
| positions[i] = new OverlayPosition(topArray[i], bottomArray[i]); |
| } |
| return positions; |
| } |
| |
| protected @Nullable Address getAddress(String rawFrom) { |
| return Utils.getAddress(mAddressCache, rawFrom); |
| } |
| |
| private void ensureContentSizeChangeListener() { |
| if (mWebViewSizeChangeListener == null) { |
| mWebViewSizeChangeListener = new 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); |
| } |
| |
| public static boolean isOverviewMode(Account acct) { |
| return acct.settings.isOverviewMode(); |
| } |
| |
| private void setupOverviewMode() { |
| // for now, overview mode means use the built-in WebView zoom and disable custom scale |
| // gesture handling |
| final boolean overviewMode = isOverviewMode(mAccount); |
| final WebSettings settings = mWebView.getSettings(); |
| final WebSettings.LayoutAlgorithm layout; |
| settings.setUseWideViewPort(overviewMode); |
| settings.setSupportZoom(overviewMode); |
| settings.setBuiltInZoomControls(overviewMode); |
| settings.setLoadWithOverviewMode(overviewMode); |
| if (overviewMode) { |
| settings.setDisplayZoomControls(false); |
| layout = WebSettings.LayoutAlgorithm.NORMAL; |
| } else { |
| layout = WebSettings.LayoutAlgorithm.NARROW_COLUMNS; |
| } |
| settings.setLayoutAlgorithm(layout); |
| } |
| |
| @Override |
| public ConversationMessage getMessageForClickedUrl(String url) { |
| final String domMessageId = mUrlToMessageIdMap.get(url); |
| if (domMessageId == null) { |
| return null; |
| } |
| final MessageCursor messageCursor = getMessageCursor(); |
| if (messageCursor == null) { |
| return null; |
| } |
| final String messageId = mTemplates.getMessageIdForDomId(domMessageId); |
| return messageCursor.getMessageForId(Long.parseLong(messageId)); |
| } |
| |
| /** |
| * Determines if we should intercept the left/right key event generated by the hardware |
| * keyboard so the framework won't handle directional navigation for us. |
| */ |
| private boolean shouldInterceptLeftRightEvents(@IdRes int id, boolean isLeft, boolean isRight, |
| boolean twoPaneLand) { |
| return twoPaneLand && (id == R.id.conversation_topmost_overlay || |
| id == R.id.upper_header || |
| id == R.id.super_collapsed_block || |
| id == R.id.message_footer || |
| (id == R.id.overflow && isRight) || |
| (id == R.id.reply_button && isLeft) || |
| (id == R.id.forward_button && isRight)); |
| } |
| |
| /** |
| * Indicates if the direction with the provided id should navigate away from the conversation |
| * view. Note that this is only applicable in two-pane landscape mode. |
| */ |
| private boolean shouldNavigateAway(@IdRes int id, boolean isLeft, boolean twoPaneLand) { |
| return twoPaneLand && isLeft && (id == R.id.conversation_topmost_overlay || |
| id == R.id.upper_header || |
| id == R.id.super_collapsed_block || |
| id == R.id.message_footer || |
| id == R.id.reply_button); |
| } |
| |
| @Override |
| public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { |
| if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { |
| mOriginalKeyedView = view; |
| } |
| |
| if (mOriginalKeyedView != null) { |
| final int id = mOriginalKeyedView.getId(); |
| final boolean isRtl = ViewUtils.isViewRtl(mOriginalKeyedView); |
| final boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; |
| final boolean isStart = KeyboardUtils.isKeycodeDirectionStart(keyCode, isRtl); |
| final boolean isEnd = KeyboardUtils.isKeycodeDirectionEnd(keyCode, isRtl); |
| final boolean isUp = keyCode == KeyEvent.KEYCODE_DPAD_UP; |
| final boolean isDown = keyCode == KeyEvent.KEYCODE_DPAD_DOWN; |
| |
| // First we run the event by the controller |
| // We manually check if the view+direction combination should shift focus away from the |
| // conversation view to the thread list in two-pane landscape mode. |
| final boolean isTwoPaneLand = mNavigationController.isTwoPaneLandscape(); |
| final boolean navigateAway = shouldNavigateAway(id, isStart, isTwoPaneLand); |
| if (mNavigationController.onInterceptKeyFromCV(keyCode, keyEvent, navigateAway)) { |
| return true; |
| } |
| |
| // If controller didn't handle the event, check directional interception. |
| if ((isStart || isEnd) && shouldInterceptLeftRightEvents( |
| id, isStart, isEnd, isTwoPaneLand)) { |
| return true; |
| } else if (isUp || isDown) { |
| // We don't do anything on up/down for overlay |
| if (id == R.id.conversation_topmost_overlay) { |
| return true; |
| } |
| |
| // We manually handle up/down navigation through the overlay items because the |
| // system's default isn't optimal for two-pane landscape since it's not a real list. |
| final View next = mConversationContainer.getNextOverlayView(mOriginalKeyedView, |
| isDown); |
| if (next != null) { |
| focusAndScrollToView(next); |
| } else if (!isActionUp) { |
| // Scroll in the direction of the arrow if next view isn't found. |
| final int currentY = mWebView.getScrollY(); |
| if (isUp && currentY > 0) { |
| mWebView.scrollBy(0, |
| -Math.min(currentY, DEFAULT_VERTICAL_SCROLL_DISTANCE_PX)); |
| } else if (isDown) { |
| final int webviewEnd = (int) (mWebView.getContentHeight() * |
| mWebView.getScale()); |
| final int currentEnd = currentY + mWebView.getHeight(); |
| if (currentEnd < webviewEnd) { |
| mWebView.scrollBy(0, Math.min(webviewEnd - currentEnd, |
| DEFAULT_VERTICAL_SCROLL_DISTANCE_PX)); |
| } |
| } |
| } |
| return true; |
| } |
| |
| // Finally we handle the special keys |
| if (keyCode == KeyEvent.KEYCODE_BACK && id != R.id.conversation_topmost_overlay) { |
| if (isActionUp) { |
| mTopmostOverlay.requestFocus(); |
| } |
| return true; |
| } else if (keyCode == KeyEvent.KEYCODE_ENTER && |
| id == R.id.conversation_topmost_overlay) { |
| if (isActionUp) { |
| mWebView.scrollTo(0, 0); |
| mConversationContainer.focusFirstMessageHeader(); |
| } |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private void focusAndScrollToView(View v) { |
| // Make sure that v is in view |
| final int[] coords = new int[2]; |
| v.getLocationOnScreen(coords); |
| final int bottom = coords[1] + v.getHeight(); |
| if (bottom > mMaxScreenHeight) { |
| mWebView.scrollBy(0, bottom - mMaxScreenHeight); |
| } else if (coords[1] < mTopOfVisibleScreen) { |
| mWebView.scrollBy(0, coords[1] - mTopOfVisibleScreen); |
| } |
| v.requestFocus(); |
| } |
| |
| public class ConversationWebViewClient extends AbstractConversationWebViewClient { |
| public ConversationWebViewClient(Account account) { |
| super(account); |
| } |
| |
| @Override |
| public WebResourceResponse shouldInterceptRequest(WebView view, String url) { |
| // try to locate the message associated with the url |
| final ConversationMessage cm = getMessageForClickedUrl(url); |
| if (cm != null) { |
| // try to load the url assuming it is a cid url |
| final Uri uri = Uri.parse(url); |
| final WebResourceResponse response = loadCIDUri(uri, cm); |
| if (response != null) { |
| return response; |
| } |
| } |
| |
| // otherwise, attempt the default handling |
| return super.shouldInterceptRequest(view, url); |
| } |
| |
| @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. |
| if (!isAdded() || !mViewsCreated) { |
| LogUtils.d(LOG_TAG, "ignoring CVF.onPageFinished, url=%s fragment=%s", url, |
| ConversationViewFragment.this); |
| return; |
| } |
| |
| LogUtils.d(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(); |
| final List<Address> cacheCopy; |
| synchronized (mAddressCache) { |
| cacheCopy = ImmutableList.copyOf(mAddressCache.values()); |
| } |
| for (Address addr : cacheCopy) { |
| emailAddresses.add(addr.getAddress()); |
| } |
| final ContactLoaderCallbacks callbacks = getContactInfoSource(); |
| callbacks.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 { |
| @JavascriptInterface |
| public void onWebContentGeometryChange(final int[] overlayTopStrs, |
| final int[] overlayBottomStrs) { |
| try { |
| getHandler().post(new FragmentRunnable("onWebContentGeometryChange", |
| ConversationViewFragment.this) { |
| @Override |
| public void go() { |
| 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"); |
| } |
| } |
| |
| @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 ""; |
| } |
| } |
| |
| @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 HtmlConversationTemplates.wrapMessageBody(msg.getBodyAsHtml()); |
| } |
| } |
| |
| return ""; |
| |
| } catch (Throwable t) { |
| LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageBody"); |
| return ""; |
| } |
| } |
| |
| @JavascriptInterface |
| public String getMessageSender(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))) { |
| final Address address = getAddress(msg.getFrom()); |
| if (address != null) { |
| return address.getAddress(); |
| } else { |
| // Fall through to return an empty string |
| break; |
| } |
| } |
| } |
| |
| return ""; |
| |
| } catch (Throwable t) { |
| LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getMessageSender"); |
| return ""; |
| } |
| } |
| |
| @JavascriptInterface |
| public void onContentReady() { |
| try { |
| getHandler().post(new FragmentRunnable("onContentReady", |
| ConversationViewFragment.this) { |
| @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(); |
| } |
| } |
| }); |
| } catch (Throwable t) { |
| LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onContentReady"); |
| } |
| } |
| |
| @JavascriptInterface |
| public float getScrollYPercent() { |
| try { |
| return mWebViewYPercent; |
| } catch (Throwable t) { |
| LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.getScrollYPercent"); |
| return 0f; |
| } |
| } |
| |
| @JavascriptInterface |
| public void onMessageTransform(String messageDomId, String transformText) { |
| try { |
| LogUtils.i(LOG_TAG, "TRANSFORM: (%s) %s", messageDomId, transformText); |
| mMessageTransforms.put(messageDomId, transformText); |
| onConversationTransformed(); |
| } catch (Throwable t) { |
| LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onMessageTransform"); |
| } |
| } |
| |
| @JavascriptInterface |
| public void onInlineAttachmentsParsed(final String[] urls, final String[] messageIds) { |
| try { |
| getHandler().post(new FragmentRunnable("onInlineAttachmentsParsed", |
| ConversationViewFragment.this) { |
| @Override |
| public void go() { |
| try { |
| for (int i = 0, size = urls.length; i < size; i++) { |
| mUrlToMessageIdMap.put(urls[i], messageIds[i]); |
| } |
| } catch (ArrayIndexOutOfBoundsException e) { |
| LogUtils.e(LOG_TAG, e, |
| "Number of urls does not match number of message ids - %s:%s", |
| urls.length, messageIds.length); |
| } |
| } |
| }); |
| } catch (Throwable t) { |
| LogUtils.e(LOG_TAG, t, "Error in MailJsBridge.onInlineAttachmentsParsed"); |
| } |
| } |
| } |
| |
| private class NewMessagesInfo { |
| int count; |
| int countFromSelf; |
| |
| /** |
| * 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() { |
| return getResources().getQuantityString(R.plurals.new_incoming_messages, count, count); |
| } |
| } |
| |
| @Override |
| public void onMessageCursorLoadFinished(Loader<ObjectCursor<ConversationMessage>> 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); |
| timerMark("message cursor load finished"); |
| } |
| |
| renderContent(newCursor); |
| } |
| |
| protected void renderContent(MessageCursor messageCursor) { |
| // 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(messageCursor); |
| } |
| } |
| |
| 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 (from == null || mAccount.ownsFromAddress(from.getAddress())) { |
| LogUtils.i(LOG_TAG, "found message from self: %s", m.uri); |
| info.countFromSelf++; |
| continue; |
| } |
| |
| info.count++; |
| } |
| } |
| 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(); |
| |
| // We are going to update the data in the adapter whenever any input fields change. |
| // This ensures that the Message object that ComposeActivity uses will be correctly |
| // aligned with the most up-to-date data. |
| if (!newMsg.isEqual(oldMsg)) { |
| mAdapter.updateItemsForMessage(newMsg, changedOverlayPositions); |
| LogUtils.i(LOG_TAG, "msg #%d (%d): detected field(s) change. sendingState=%s", |
| pos, newMsg.id, newMsg.sendingState); |
| } |
| |
| // 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; |
| } |
| |
| final ConversationFooterItem footerItem = mAdapter.getFooterItem(); |
| if (footerItem != null) { |
| footerItem.invalidateMeasurement(); |
| } |
| if (!idsOfChangedBodies.isEmpty()) { |
| mWebView.loadUrl(String.format("javascript:replaceMessageBodies([%s]);", |
| TextUtils.join(",", idsOfChangedBodies))); |
| changed = true; |
| } |
| |
| return changed; |
| } |
| |
| private void processNewOutgoingMessage(ConversationMessage msg) { |
| // Temporarily remove the ConversationFooterItem and its view. |
| // It will get re-added right after the new message is added. |
| final ConversationFooterItem footerItem = mAdapter.removeFooterItem(); |
| // if no footer, just skip the work for it. The rest should be fine to do. |
| if (footerItem != null) { |
| mConversationContainer.removeViewAtAdapterIndex(footerItem.getPosition()); |
| } |
| |
| 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(); |
| |
| if (footerItem != null) { |
| footerItem.setLastMessageHeaderItem(getLastMessageHeaderItem()); |
| footerItem.invalidateMeasurement(); |
| mAdapter.addItem(footerItem); |
| } |
| |
| 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 static class SetCookieTask extends AsyncTask<Void, Void, Void> { |
| private final Context mContext; |
| private final String mUri; |
| private final Uri mAccountCookieQueryUri; |
| private final ContentResolver mResolver; |
| |
| /* package */ SetCookieTask(Context context, String baseUri, Uri accountCookieQueryUri) { |
| mContext = context; |
| mUri = baseUri; |
| mAccountCookieQueryUri = accountCookieQueryUri; |
| mResolver = context.getContentResolver(); |
| } |
| |
| @Override |
| public Void doInBackground(Void... args) { |
| // First query for the cookie 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(mContext); |
| 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); |
| } |
| |
| /** |
| * @return {@code true} because either the Print or Print All menu item is shown in GMail |
| */ |
| @Override |
| protected boolean shouldShowPrintInOverflow() { |
| return true; |
| } |
| |
| @Override |
| protected void printConversation() { |
| PrintUtils.printConversation(mActivity.getActivityContext(), getMessageCursor(), |
| mAddressCache, mConversation.getBaseUri(mBaseUri), true /* useJavascript */); |
| } |
| } |