conversation view zooming
Zoom is enabled on the entire conversation using the built-in
WebView mechanism.
Wide messages are individually best-effort shrunken to
fit-width.
Start a custom ViewGroup to hold both message headers and the
single WebView. A common view parent makes input handling easy:
the WebView drives scroll position, and position is relayed
to the custom ViewGroup to cheaply shift around headers without
a full relayout. For now, all headers are inflated at once, but
soon they can be recycled depending on the virtual scroll
viewport.
Change-Id: I92555f9b79e10630457b17ca970ab9a2e9028e80
diff --git a/assets/script.js b/assets/script.js
new file mode 100644
index 0000000..acc5432
--- /dev/null
+++ b/assets/script.js
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+
+/**
+ * Returns the page offset of an element.
+ *
+ * @param {Element} element The element to return the page offset for.
+ * @return {left: number, top: number} A tuple including a left and top value representing
+ * the page offset of the element.
+ */
+function getTotalOffset(el) {
+ var result = {
+ left: 0,
+ top: 0
+ };
+ var parent = el;
+
+ while (parent) {
+ result.left += parent.offsetLeft;
+ result.top += parent.offsetTop;
+ parent = parent.offsetParent;
+ }
+
+ return result;
+}
+
+function toggleQuotedText(e) {
+ var toggleElement = e.target;
+ var elidedTextElement = toggleElement.nextSibling;
+ var isHidden = getComputedStyle(elidedTextElement).display == 'none';
+ toggleElement.innerHTML = isHidden ? MSG_HIDE_ELIDED : MSG_SHOW_ELIDED;
+ elidedTextElement.style.display = isHidden ? 'block' : 'none';
+}
+
+function collapseQuotedText() {
+ var i;
+ var elements = document.getElementsByClassName("elided-text");
+ var elidedElement, toggleElement;
+ for (i = 0; i < elements.length; i++) {
+ elidedElement = elements[i];
+ toggleElement = document.createElement("div");
+ toggleElement.display = "mail-elided-text";
+ toggleElement.innerHTML = MSG_SHOW_ELIDED;
+ toggleElement.onclick = toggleQuotedText;
+ elidedElement.parentNode.insertBefore(toggleElement, elidedElement);
+ }
+}
+
+function shrinkWideMessages() {
+ var i;
+ var elements = document.getElementsByClassName("mail-message-content");
+ var messageElement;
+ var documentWidth = document.documentElement.offsetWidth;
+ var scale;
+ for (i = 0; i < elements.length; i++) {
+ messageElement = elements[i];
+ if (messageElement.scrollWidth > documentWidth) {
+ scale = documentWidth / messageElement.scrollWidth;
+
+ // TODO: 'zoom' is nice because it does a proper layout, but WebView seems to clamp the
+ // minimum 'zoom' level.
+ if (false) {
+ // TODO: this alternative works well in Chrome but doesn't work in WebView.
+ messageElement.style.webkitTransformOrigin = "left top";
+ messageElement.style.webkitTransform = "scale(" + scale + ")";
+ messageElement.style.height = (messageElement.offsetHeight * scale) + "px";
+ messageElement.style.overflowX = "visible";
+ } else {
+ messageElement.style.zoom = documentWidth / messageElement.scrollWidth;
+ }
+ }
+ }
+}
+
+function measurePositions() {
+ var messageTops;
+ var i;
+ var len;
+
+ var headers = document.querySelectorAll(".mail-message");
+
+ messageTops = new Array(headers.length);
+ for (i = 0, len = headers.length; i < len; i++) {
+ // addJavascriptInterface handler only supports string arrays
+ messageTops[i] = "" + getTotalOffset(headers[i]).top;
+ }
+
+ window.mail.onWebContentGeometryChange(messageTops);
+}
+
+collapseQuotedText();
+shrinkWideMessages();
+measurePositions();
+
diff --git a/proguard.flags b/proguard.flags
index 61b14fe..6996693 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -31,3 +31,7 @@
-keepclasseswithmembers class com.android.mail.ui.AnimatingItemView {
*** setAnimatedHeight(...);
}
+
+-keepclasseswithmembers class com.android.mail.ui.ConversationViewFragment$MailJsBridge {
+ public <methods>;
+}
diff --git a/res/layout/conversation_view.xml b/res/layout/conversation_view.xml
index a133e3f..97ea061 100644
--- a/res/layout/conversation_view.xml
+++ b/res/layout/conversation_view.xml
@@ -24,9 +24,15 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"/>
- <!-- TODO: set layout_height to wrap_content once the rest
- of the conversation view is in place -->
- <ListView android:id="@+id/message_list"
+ <com.android.mail.ui.ConversationContainer
+ android:id="@+id/conversation_container"
android:layout_width="match_parent"
- android:layout_height="match_parent"/>
-</LinearLayout>
\ No newline at end of file
+ android:layout_height="match_parent">
+
+ <com.android.mail.ui.ConversationWebView
+ android:id="@+id/webview"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+
+ </com.android.mail.ui.ConversationContainer>
+</LinearLayout>
diff --git a/res/layout/message.xml b/res/layout/message.xml
deleted file mode 100644
index ae44337..0000000
--- a/res/layout/message.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- Copyright (C) 2011 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.
--->
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <include
- android:id="@+id/message_header"
- layout="@layout/conversation_message_header" />
- <!-- Disable hardware acceleration of each WebView due to b/4184270 -->
- <com.android.mail.browse.MessageWebView
- android:id="@+id/body"
- android:layout_below="@id/message_header"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginLeft="32dp"
- android:layout_marginRight="32dp"
- android:layerType="software" />
-</RelativeLayout>
diff --git a/res/raw/template_conversation_lower.html b/res/raw/template_conversation_lower.html
new file mode 100644
index 0000000..7c48447
--- /dev/null
+++ b/res/raw/template_conversation_lower.html
@@ -0,0 +1,10 @@
+</div>
+</body>
+<script type="text/javascript">
+ var MSG_HIDE_ELIDED = '%s';
+ var MSG_SHOW_ELIDED = '%s';
+ var ACCOUNT_URI = '%s';
+ var VIEW_WIDTH = %s;
+</script>
+<script type="text/javascript" src="file:///android_asset/script.js"></script>
+</html>
diff --git a/res/raw/template_conversation_upper.html b/res/raw/template_conversation_upper.html
new file mode 100644
index 0000000..01f420e
--- /dev/null
+++ b/res/raw/template_conversation_upper.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta name="viewport" content="width=device-width"/>
+<!-- <link rel="stylesheet" href="file:///android_res/raw/styles.css"> -->
+ <style>
+ .elided-text {
+ display: none;
+ }
+ .mail-message-content {
+ overflow-x: hidden;
+ }
+ </style>
+</head>
+<body style="margin: 0; background-color: #ccc;">
+<div id="conversation-header-spacer" style="height: %spx;"></div>
+<div id="message-container">
diff --git a/res/raw/template_message.html b/res/raw/template_message.html
new file mode 100644
index 0000000..9db4424
--- /dev/null
+++ b/res/raw/template_message.html
@@ -0,0 +1,3 @@
+<div id="%s" serverId="%s" class="mail-message" style="padding-top: %spx;">
+ <div class="mail-message-content %s" style="display: %s; zoom: %s; padding: 8px; background-color: white; -webkit-border-radius: 16px;">%s</div>
+</div>
diff --git a/res/raw/template_super_collapsed.html b/res/raw/template_super_collapsed.html
new file mode 100644
index 0000000..3209a85
--- /dev/null
+++ b/res/raw/template_super_collapsed.html
@@ -0,0 +1 @@
+<div class="gm-super-collapsed-spacer" index="%s" style="height: %spx;"></div>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f53d739..7a494d5 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -430,6 +430,10 @@
<string name="sending_message">Sending message\u2026</string>
<!-- Displayed for one second after trying to send with invalid recipients [CHAR LIMIT=50]-->
<string name="invalid_recipient">The address <xliff:g id="wrongemail" example="foo@@gmail..com">%s</xliff:g> is invalid.</string>
+ <!-- Shown in HTML to allow the user to see quoted text; should match Gmail web UI. 25B6 is Unicode for a right-pointing triangle. [CHAR LIMIT=50] -->
+ <string name="show_elided">\u25B6 Show quoted text</string>
+ <!-- Shown in HTML to allow the user to hide quoted text; should match Gmail web UI. 25BC is Unicode for a downward-pointing triangle. [CHAR LIMIT=50] -->
+ <string name="hide_elided">\u25BC Hide quoted text</string>
<!-- An enumeration comma for separating items in lists. [CHAR LIMIT=2] -->
<string name="enumeration_comma">,\u0020</string>
diff --git a/src/com/android/mail/browse/MessageHeaderView.java b/src/com/android/mail/browse/MessageHeaderView.java
index 3b3d305..1466bb0 100644
--- a/src/com/android/mail/browse/MessageHeaderView.java
+++ b/src/com/android/mail/browse/MessageHeaderView.java
@@ -17,7 +17,6 @@
package com.android.mail.browse;
import android.content.Context;
-import android.database.Cursor;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.provider.ContactsContract;
@@ -165,6 +164,9 @@
private Message mMessage;
+ private boolean mCollapsedDetailsValid;
+ private boolean mExpandedDetailsValid;
+
public MessageHeaderView(Context context) {
this(context, null);
}
@@ -321,46 +323,47 @@
mDefaultReplyAll = defaultReplyAll;
}
- public int bind(Cursor cursor) {
+ public int bind(Message message) {
Timer t = new Timer();
t.start(HEADER_RENDER_TAG);
- mMessage = new Message(cursor);
- mLocalMessageId = cursor.getLong(UIProvider.MESSAGE_ID_COLUMN);
- mServerMessageId = cursor.getLong(UIProvider.MESSAGE_SERVER_ID_COLUMN);
- mConversationId = cursor.getLong(UIProvider.MESSAGE_CONVERSATION_ID_COLUMN);
+ mCollapsedDetailsValid = false;
+ mExpandedDetailsValid = false;
+
+ mMessage = message;
+ mLocalMessageId = mMessage.id;
+ mServerMessageId = mMessage.serverId;
+ mConversationId = mMessage.conversationId;
if (mCallbacks != null) {
mCallbacks.onHeaderCreated(mLocalMessageId);
}
setTag(mLocalMessageId);
- mTimestampMs = cursor.getLong(UIProvider.MESSAGE_DATE_RECEIVED_MS_COLUMN);
+ mTimestampMs = mMessage.dateReceivedMs;
if (mDateBuilder != null) {
mTimestampShort = mDateBuilder.formatShortDate(mTimestampMs);
}
- mTo = Utils.splitCommaSeparatedString(cursor.getString(UIProvider.MESSAGE_TO_COLUMN));
- mCc = Utils.splitCommaSeparatedString(cursor.getString(UIProvider.MESSAGE_CC_COLUMN));
- mBcc = getBccAddresses(cursor);
- mReplyTo = Utils.splitCommaSeparatedString(cursor
- .getString(UIProvider.MESSAGE_REPLY_TO_COLUMN));
+ mTo = Utils.splitCommaSeparatedString(mMessage.to);
+ mCc = Utils.splitCommaSeparatedString(mMessage.cc);
+ mBcc = getBccAddresses(mMessage);
+ mReplyTo = Utils.splitCommaSeparatedString(mMessage.replyTo);
/**
* Turns draft mode on or off. Draft mode hides message operations other
* than "edit", hides contact photo, hides presence, and changes the
* sender name to "Draft".
*/
- mIsDraft = cursor.getInt(UIProvider.MESSAGE_DRAFT_TYPE_COLUMN) !=
- UIProvider.DraftType.NOT_A_DRAFT;
- mIsSending = isInOutbox(cursor);
+ mIsDraft = mMessage.draftType != UIProvider.DraftType.NOT_A_DRAFT;
+ mIsSending = isInOutbox();
updateChildVisibility();
- if (mIsDraft || isInOutbox(cursor)) {
- mSnippet = makeSnippet(cursor.getString(UIProvider.MESSAGE_SNIPPET_COLUMN));
+ if (mIsDraft || isInOutbox()) {
+ mSnippet = makeSnippet(mMessage.snippet);
} else {
- mSnippet = cursor.getString(UIProvider.MESSAGE_SNIPPET_COLUMN);
+ mSnippet = mMessage.snippet;
}
// If this was a sent message AND:
@@ -370,7 +373,7 @@
// 2. the account has no custom froms, fromAddress will be empty, and we
// can safely fall back and show the account name as sender since it's
// the only possible fromAddress.
- String from = cursor.getString(UIProvider.MESSAGE_FROM_COLUMN);
+ String from = mMessage.from;
if (TextUtils.isEmpty(from)) {
from = mAccount.name;
}
@@ -384,8 +387,7 @@
upperDateView.setText(mTimestampShort);
}
- mStarView.setSelected((cursor.getInt(UIProvider.MESSAGE_FLAGS_COLUMN)
- & UIProvider.MessageFlags.STARRED) == 1);
+ mStarView.setSelected((mMessage.messageFlags & UIProvider.MessageFlags.STARRED) == 1);
mStarView.setContentDescription(getResources().getString(
mStarView.isSelected() ? R.string.remove_star : R.string.add_star));
@@ -406,7 +408,7 @@
return h;
}
- private boolean isInOutbox(Cursor cursor) {
+ private boolean isInOutbox() {
// TODO: what should this read? Folder info?
return false;
}
@@ -697,8 +699,8 @@
*
* @param messageCursor Cursor to query for label objects with
*/
- private static String[] getBccAddresses(Cursor cursor) {
- return Utils.splitCommaSeparatedString(cursor.getString(UIProvider.MESSAGE_BCC_COLUMN));
+ private static String[] getBccAddresses(Message m) {
+ return Utils.splitCommaSeparatedString(m.bcc);
}
@Override
@@ -987,11 +989,14 @@
mCollapsedDetailsView = (ViewGroup) findViewById(R.id.details_collapsed_content);
mCollapsedDetailsView.setOnClickListener(this);
-
+ }
+ if (!mCollapsedDetailsValid) {
((TextView) findViewById(R.id.recipients_summary)).setText(getRecipientSummaryText(
getContext(), mAccount.name, mTo, mCc, mBcc));
((TextView) findViewById(R.id.date_summary)).setText(mTimestampShort);
+
+ mCollapsedDetailsValid = true;
}
mCollapsedDetailsView.setVisibility(VISIBLE);
}
@@ -1016,6 +1021,9 @@
addView(v, indexOfChild(mCollapsedDetailsView) + 1);
v.setOnClickListener(this);
+ mExpandedDetailsView = (ViewGroup) v;
+ }
+ if (!mExpandedDetailsValid) {
CharSequence longTimestamp = mDateBuilder != null ? mDateBuilder
.formatLongDateTime(mTimestampMs) : mTimestampMs + "";
((TextView) findViewById(R.id.date_value)).setText(longTimestamp);
@@ -1023,10 +1031,8 @@
renderEmailList(R.id.to_row, R.id.to_value, mTo);
renderEmailList(R.id.cc_row, R.id.cc_value, mCc);
renderEmailList(R.id.bcc_row, R.id.bcc_value, mBcc);
- // don't need these any more, release them
- mReplyTo = mTo = mCc = mBcc = null;
- mExpandedDetailsView = (ViewGroup) v;
+ mExpandedDetailsValid = true;
}
mExpandedDetailsView.setVisibility(VISIBLE);
}
diff --git a/src/com/android/mail/ui/ConversationContainer.java b/src/com/android/mail/ui/ConversationContainer.java
new file mode 100644
index 0000000..0f7ab3c
--- /dev/null
+++ b/src/com/android/mail/ui/ConversationContainer.java
@@ -0,0 +1,139 @@
+/*
+ * 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.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebView;
+import android.widget.Adapter;
+
+import com.android.mail.ui.ScrollNotifier.ScrollListener;
+import com.android.mail.utils.LogUtils;
+
+/**
+ * TODO
+ *
+ */
+public class ConversationContainer extends ViewGroup implements ScrollListener {
+
+ private Adapter mOverlayAdapter;
+ private int[] mOverlayTops;
+
+ private static final String TAG = new LogUtils().getLogTag();
+
+ private int mOffsetY;
+ private float mScale;
+
+ public ConversationContainer(Context c) {
+ this(c, null);
+ }
+
+ public ConversationContainer(Context c, AttributeSet attrs) {
+ super(c, attrs);
+ }
+
+ public void setOverlayAdapter(Adapter a) {
+ mOverlayAdapter = a;
+ }
+
+ private int getOverlayCount() {
+ return Math.max(0, getChildCount() - 1);
+ }
+
+ private View getOverlayAt(int i) {
+ return getChildAt(i + 1);
+ }
+
+ private WebView getBackgroundView() {
+ return (WebView) getChildAt(0);
+ }
+
+ @Override
+ public void onNotifierScroll(int x, int y) {
+ mOffsetY = y;
+ mScale = getBackgroundView().getScale();
+ LogUtils.v(TAG, "*** IN on scroll, x/y=%d/%d zoom=%f", x, y, mScale);
+ layoutOverlays();
+
+ // TODO: recycle scrolled-off views and add newly visible views
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ LogUtils.d(TAG, "*** IN header container onMeasure");
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ measureChildren(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ LogUtils.d(TAG, "*** IN header container onLayout");
+ final View backgroundView = getBackgroundView();
+ backgroundView.layout(0, 0, backgroundView.getMeasuredWidth(),
+ backgroundView.getMeasuredHeight());
+
+ layoutOverlays();
+ }
+
+ private void layoutOverlays() {
+ final int count = getOverlayCount();
+
+ if (count > 0 && count != mOverlayTops.length) {
+ LogUtils.e(TAG,
+ "Header/body count mismatch. headers=%d, message bodies=%d",
+ count, mOverlayTops.length);
+ }
+
+ for (int i = 0; i < count; i++) {
+ View child = getOverlayAt(i);
+ // TODO: round or truncate?
+ final int top = (int) (mOverlayTops[i] * mScale) - mOffsetY;
+ final int bottom = top + child.getMeasuredHeight();
+ child.layout(0, top, child.getMeasuredWidth(), bottom);
+ }
+ }
+
+ // TODO: add margin support for children that want it (e.g. tablet headers)
+
+ public void onGeometryChange(int[] messageTops) {
+ LogUtils.d(TAG, "*** got message tops:");
+ for (int top : messageTops) {
+ LogUtils.d(TAG, "%d", top);
+ }
+
+ mOverlayTops = messageTops;
+
+ for (int i = 0; i < messageTops.length; i++) {
+ View overlayView = getOverlayAt(i);
+ if (overlayView == null) {
+ // TODO: dig through recycler instead of creating new views each time
+ overlayView = mOverlayAdapter.getView(i, null, this);
+ addView(overlayView, i + 1);
+ }
+ // TODO: inform header of its bottom (== top of the next header) so it can know where
+ // to position bottom-anchored content like attachments
+ }
+
+ mScale = getBackgroundView().getScale();
+
+ }
+
+}
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 7e47617..a64ce29 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -17,6 +17,8 @@
package com.android.mail.ui;
+import com.google.common.collect.Maps;
+
import android.app.Activity;
import android.app.Fragment;
import android.app.LoaderManager;
@@ -24,25 +26,31 @@
import android.content.CursorLoader;
import android.content.Loader;
import android.database.Cursor;
+import android.database.CursorWrapper;
import android.net.Uri;
import android.os.Bundle;
+import android.os.Handler;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.ListView;
-import android.widget.SimpleCursorAdapter;
+import android.webkit.ConsoleMessage;
+import android.webkit.WebChromeClient;
+import android.webkit.WebSettings;
+import android.widget.ResourceCursorAdapter;
import android.widget.TextView;
import com.android.mail.FormattedDateBuilder;
import com.android.mail.R;
import com.android.mail.browse.MessageHeaderView;
-import com.android.mail.browse.MessageWebView;
import com.android.mail.providers.Account;
import com.android.mail.providers.Conversation;
+import com.android.mail.providers.Message;
import com.android.mail.providers.UIProvider;
import com.android.mail.utils.LogUtils;
+import java.util.Map;
+
/**
* The conversation view UI component.
*/
@@ -55,32 +63,44 @@
private ControllableActivity mActivity;
- private final Conversation mConversation;
+ private Context mContext;
+
+ private Conversation mConversation;
private TextView mSubject;
- private ListView mMessageList;
+ private ConversationContainer mConversationContainer;
- private FormattedDateBuilder mDateBuilder;
+ private Account mAccount;
- private Cursor mMessageCursor;
+ private ConversationWebView mWebView;
- private final Account mAccount;
- /**
- * Hidden constructor.
- */
- private ConversationViewFragment(Account account, Conversation conversation) {
- super();
- mConversation = conversation;
- mAccount = account;
+ private HtmlConversationTemplates mTemplates;
+
+ private String mBaseUri;
+
+ private final Handler mHandler = new Handler();
+
+ private final MailJsBridge mJsBridge = new MailJsBridge();
+
+ private static final String ARG_ACCOUNT = "account";
+ private static final String ARG_CONVERSATION = "conversation";
+
+ public ConversationViewFragment() {
}
/**
* Creates a new instance of {@link ConversationViewFragment}, initialized
* to display conversation.
*/
- public static ConversationViewFragment newInstance(Account account, Conversation conversation) {
- return new ConversationViewFragment(account, conversation);
+ public static ConversationViewFragment newInstance(Account account,
+ Conversation conversation) {
+ ConversationViewFragment f = new ConversationViewFragment();
+ Bundle args = new Bundle();
+ args.putParcelable(ARG_ACCOUNT, account);
+ args.putParcelable(ARG_CONVERSATION, conversation);
+ f.setArguments(args);
+ return f;
}
@Override
@@ -93,16 +113,17 @@
// ControllableActivity.
final Activity activity = getActivity();
if (! (activity instanceof ControllableActivity)){
- LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" +
- "create it. Cannot proceed.");
+ LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
+ + "create it. Cannot proceed.");
}
mActivity = (ControllableActivity) activity;
+ mContext = mActivity.getApplicationContext();
if (mActivity.isFinishing()) {
// Activity is finishing, just bail.
return;
}
mActivity.attachConversationView(this);
- mDateBuilder = new FormattedDateBuilder(mActivity.getActivityContext());
+ mTemplates = new HtmlConversationTemplates(mContext);
// Show conversation and start loading messages.
showConversation();
}
@@ -111,6 +132,11 @@
public void onCreate(Bundle savedState) {
LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this);
super.onCreate(savedState);
+
+ Bundle args = getArguments();
+ mAccount = args.getParcelable(ARG_ACCOUNT);
+ mConversation = args.getParcelable(ARG_CONVERSATION);
+ mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
}
@Override
@@ -118,7 +144,35 @@
ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.conversation_view, null);
mSubject = (TextView) rootView.findViewById(R.id.subject);
- mMessageList = (ListView) rootView.findViewById(R.id.message_list);
+ mConversationContainer = (ConversationContainer) rootView
+ .findViewById(R.id.conversation_container);
+ mWebView = (ConversationWebView) rootView.findViewById(R.id.webview);
+
+ mWebView.addScrollListener(mConversationContainer);
+
+ mWebView.addJavascriptInterface(mJsBridge, "mail");
+
+ 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;
+ }
+ });
+
+ WebSettings settings = mWebView.getSettings();
+
+ settings.setBlockNetworkImage(true);
+
+ settings.setJavaScriptEnabled(true);
+ settings.setUseWideViewPort(true);
+
+ settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
+
+ settings.setSupportZoom(true);
+ settings.setBuiltInZoomControls(true);
+ settings.setDisplayZoomControls(false);
return rootView;
}
@@ -126,7 +180,7 @@
@Override
public void onDestroyView() {
// Clear the adapter.
- mMessageList.setAdapter(null);
+ mConversationContainer.setOverlayAdapter(null);
mActivity.attachConversationView(null);
super.onDestroyView();
@@ -143,16 +197,17 @@
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
- return new CursorLoader(mActivity.getActivityContext(),
- Uri.parse(mConversation.messageListUri), UIProvider.MESSAGE_PROJECTION, null, null,
- null);
+ return new CursorLoader(mContext, Uri.parse(mConversation.messageListUri),
+ UIProvider.MESSAGE_PROJECTION, null, null, null);
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
- mMessageCursor = data;
- mMessageList.setAdapter(new MessageListAdapter(mActivity.getActivityContext(),
- mMessageCursor));
+ MessageCursor messageCursor = new MessageCursor(data);
+ mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html",
+ "utf-8", null);
+ mConversationContainer.setOverlayAdapter(
+ new MessageListAdapter(mContext, messageCursor, mAccount));
}
@Override
@@ -160,26 +215,81 @@
// Do nothing.
}
- class MessageListAdapter extends SimpleCursorAdapter {
- public MessageListAdapter(Context context, Cursor cursor) {
- super(context, R.layout.message, cursor,
- UIProvider.MESSAGE_PROJECTION, new int[0], 0);
+ private String renderMessageBodies(MessageCursor messageCursor) {
+ int pos = -1;
+ mTemplates.startConversation(0);
+ while (messageCursor.moveToPosition(++pos)) {
+ mTemplates.appendMessageHtml(messageCursor.get(), true, false, 1.0f, 96);
}
-
- @Override
- public void bindView(View view, Context context, Cursor cursor) {
- super.bindView(view, context, cursor);
- MessageHeaderView header = (MessageHeaderView) view.findViewById(R.id.message_header);
- header.initialize(mDateBuilder, mAccount, true, true, false);
- header.bind(cursor);
- MessageWebView webView = (MessageWebView) view.findViewById(R.id.body);
- webView.loadData(cursor.getString(UIProvider.MESSAGE_BODY_HTML_COLUMN), "text/html",
- null);
- }
+ return mTemplates.endConversation(mBaseUri, 320);
}
public void onTouchEvent(MotionEvent event) {
// TODO: (mindyp) when there is an undo bar, check for event !in undo bar
// if its not in undo bar, dismiss the undo bar.
}
+
+ private static class MessageCursor extends CursorWrapper {
+
+ private Map<Long, Message> mCache = Maps.newHashMap();
+
+ public MessageCursor(Cursor inner) {
+ super(inner);
+ }
+
+ public Message get() {
+ long id = getWrappedCursor().getLong(0);
+ Message m = mCache.get(id);
+ if (m == null) {
+ m = new Message(this);
+ mCache.put(id, m);
+ }
+ return m;
+ }
+ }
+
+ private static class MessageListAdapter extends ResourceCursorAdapter {
+
+ private final FormattedDateBuilder mDateBuilder;
+ private final Account mAccount;
+
+ public MessageListAdapter(Context context, Cursor cursor, Account account) {
+ super(context, R.layout.conversation_message_header, cursor, 0);
+ mDateBuilder = new FormattedDateBuilder(context);
+ mAccount = account;
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ Message m = ((MessageCursor) cursor).get();
+ MessageHeaderView header = (MessageHeaderView) view;
+ header.initialize(mDateBuilder, mAccount, true, false, false);
+ header.bind(m);
+ }
+ }
+
+ /**
+ * 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")
+ public void onWebContentGeometryChange(final String[] messageTopStrs) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ final int len = messageTopStrs.length;
+ int[] messageTops = new int[len];
+ for (int i = 0; i < len; i++) {
+ messageTops[i] = Integer.parseInt(messageTopStrs[i]);
+ }
+ mConversationContainer.onGeometryChange(messageTops);
+ }
+ });
+ }
+
+ }
+
}
diff --git a/src/com/android/mail/ui/ConversationWebView.java b/src/com/android/mail/ui/ConversationWebView.java
new file mode 100644
index 0000000..edf337b
--- /dev/null
+++ b/src/com/android/mail/ui/ConversationWebView.java
@@ -0,0 +1,60 @@
+/*
+ * 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.Context;
+import android.util.AttributeSet;
+import android.webkit.WebView;
+
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+public class ConversationWebView extends WebView implements ScrollNotifier {
+
+ private final Set<ScrollListener> mScrollListeners =
+ new CopyOnWriteArraySet<ScrollListener>();
+
+ public ConversationWebView(Context c) {
+ this(c, null);
+ }
+
+ public ConversationWebView(Context c, AttributeSet attrs) {
+ super(c, attrs);
+ }
+
+ @Override
+ public void addScrollListener(ScrollListener l) {
+ mScrollListeners.add(l);
+ }
+
+ @Override
+ public void removeScrollListener(ScrollListener l) {
+ mScrollListeners.remove(l);
+ }
+
+ @Override
+ protected void onScrollChanged(int l, int t, int oldl, int oldt) {
+ super.onScrollChanged(l, t, oldl, oldt);
+
+ for (ScrollListener listener : mScrollListeners) {
+ listener.onNotifierScroll(l, t);
+ }
+ }
+
+
+}
diff --git a/src/com/android/mail/ui/HtmlConversationTemplates.java b/src/com/android/mail/ui/HtmlConversationTemplates.java
new file mode 100644
index 0000000..40466e4
--- /dev/null
+++ b/src/com/android/mail/ui/HtmlConversationTemplates.java
@@ -0,0 +1,233 @@
+/*
+ * 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 com.google.common.annotations.VisibleForTesting;
+
+import android.content.Context;
+import android.content.res.Resources.NotFoundException;
+
+import com.android.mail.R;
+import com.android.mail.providers.Message;
+import com.android.mail.utils.LogUtils;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Formatter;
+import java.util.regex.Pattern;
+
+/**
+ * Renders data into very simple string-substitution HTML templates for conversation view.
+ *
+ * Templates should be UTF-8 encoded HTML with '%s' placeholders to be substituted upon render.
+ * Plain-jane string substitution with '%s' is slightly faster than typed substitution.
+ *
+ */
+public class HtmlConversationTemplates {
+
+ /**
+ * Prefix applied to a message id for use as a div id
+ */
+ public static final String MESSAGE_PREFIX = "m";
+ public static final int MESSAGE_PREFIX_LENGTH = MESSAGE_PREFIX.length();
+
+ // TODO: refine. too expensive to iterate over cursor and pre-calculate total. so either
+ // estimate it, or defer assembly until the end when size is known (deferring increases
+ // working set size vs. estimation but is exact).
+ private static final int BUFFER_SIZE_CHARS = 64 * 1024;
+
+ private static final String TAG = new LogUtils().getLogTag();
+
+ /**
+ * Pattern for HTML img tags with a "src" attribute where the value is an absolutely-specified
+ * HTTP or HTTPS URL. In other words, these are images with valid URLs that we should munge to
+ * prevent WebView from firing bad onload handlers for them. Part of the workaround for
+ * b/5522414.
+ *
+ * Pattern documentation:
+ * There are 3 top-level parts of the pattern:
+ * 1. required preceding string
+ * 2. the literal string "src"
+ * 3. required trailing string
+ *
+ * The preceding string must be an img tag "<img " with intermediate spaces allowed. The
+ * trailing whitespace is required.
+ * Non-whitespace chars are allowed before "src", but if they are present, they must be followed
+ * by another whitespace char. The idea is to allow other attributes, and avoid matching on
+ * "src" in a later attribute value as much as possible.
+ *
+ * The following string must contain "=" and "http", with intermediate whitespace and single-
+ * and double-quote allowed in between. The idea is to avoid matching Gmail-hosted relative URLs
+ * for inline attachment images of the form "?view=KEYVALUES".
+ *
+ */
+ private static final Pattern sAbsoluteImgUrlPattern = Pattern.compile(
+ "(<\\s*img\\s+(?:[^>]*\\s+)?)src(\\s*=[\\s'\"]*http)", Pattern.CASE_INSENSITIVE
+ | Pattern.MULTILINE);
+ /**
+ * The text replacement for {@link #sAbsoluteImgUrlPattern}. The "src" attribute is set to
+ * something inert and not left unset to minimize interactions with existing JS.
+ */
+ private static final String IMG_URL_REPLACEMENT = "$1src='data:' gm-src$2";
+
+ private static boolean sLoadedTemplates;
+ private static String sSuperCollapsed;
+ private static String sMessage;
+ private static String sConversationUpper;
+ private static String sConversationLower;
+
+ private Context mContext;
+ private Formatter mFormatter;
+ private StringBuilder mBuilder;
+ private boolean mInProgress = false;
+
+ public HtmlConversationTemplates(Context context) {
+ mContext = context;
+
+ // The templates are small (~2KB total in ICS MR2), so it's okay to load them once and keep
+ // them in memory.
+ if (!sLoadedTemplates) {
+ sLoadedTemplates = true;
+ sSuperCollapsed = readTemplate(R.raw.template_super_collapsed);
+ sMessage = readTemplate(R.raw.template_message);
+ sConversationUpper = readTemplate(R.raw.template_conversation_upper);
+ sConversationLower = readTemplate(R.raw.template_conversation_lower);
+ }
+ }
+
+ public void appendSuperCollapsedHtml(int firstCollapsed, int blockHeight) {
+ if (!mInProgress) {
+ throw new IllegalStateException("must call startConversation first");
+ }
+
+ append(sSuperCollapsed, firstCollapsed, blockHeight);
+ }
+
+ @VisibleForTesting
+ static String replaceAbsoluteImgUrls(final String html) {
+ return sAbsoluteImgUrlPattern.matcher(html).replaceAll(IMG_URL_REPLACEMENT);
+ }
+
+ public void appendMessageHtml(Message message, boolean isExpanded,
+ boolean safeForImages, float zoomValue, int headerHeight) {
+
+ final String bodyDisplay = isExpanded ? "block" : "none";
+ final String showImagesClass = safeForImages ? "gm-show-images" : "";
+
+ String body = message.bodyHtml;
+
+ /* Work around a WebView bug (5522414) in setBlockNetworkImage that causes img onload event
+ * handlers to fire before an image is loaded.
+ * WebView will report bad dimensions when revealing inline images with absolute URLs, but
+ * we can prevent WebView from ever seeing those images by changing all img "src" attributes
+ * into "gm-src" before loading the HTML. Parsing the potentially dirty HTML input is
+ * prohibitively expensive with TagSoup, so use a little regular expression instead.
+ *
+ * To limit the scope of this workaround, only use it on messages that the server claims to
+ * have external resources, and even then, only use it on img tags where the src is absolute
+ * (i.e. url does not begin with "?"). The existing JavaScript implementation of this
+ * attribute swap will continue to handle inline image attachments (they have relative
+ * URLs) and any false negatives that the regex misses. This maintains overall security
+ * level by not relying solely on the regex.
+ */
+ if (!safeForImages && message.embedsExternalResources) {
+ body = replaceAbsoluteImgUrls(body);
+ }
+
+ append(sMessage,
+ MESSAGE_PREFIX + message.id,
+ MESSAGE_PREFIX + message.serverId,
+ headerHeight,
+ showImagesClass,
+ bodyDisplay,
+ zoomValue,
+ body
+ );
+ }
+
+ public void startConversation(int conversationHeaderHeight) {
+ if (mInProgress) {
+ throw new IllegalStateException("must call startConversation first");
+ }
+
+ reset();
+ append(sConversationUpper, conversationHeaderHeight);
+ mInProgress = true;
+ }
+
+ public String endConversation(String baseUri, int viewWidth) {
+ if (!mInProgress) {
+ throw new IllegalStateException("must call startConversation first");
+ }
+
+ append(sConversationLower, mContext.getString(R.string.hide_elided),
+ mContext.getString(R.string.show_elided), baseUri, viewWidth);
+
+ mInProgress = false;
+
+ LogUtils.d(TAG, "rendered conversation of %d bytes, buffer capacity=%d",
+ mBuilder.length() << 1, mBuilder.capacity() << 1);
+
+ return emit();
+ }
+
+ private String emit() {
+ String out = mFormatter.toString();
+ // release the builder memory ASAP
+ mFormatter = null;
+ mBuilder = null;
+ return out;
+ }
+
+ public void reset() {
+ mBuilder = new StringBuilder(BUFFER_SIZE_CHARS);
+ mFormatter = new Formatter(mBuilder, null /* no localization */);
+ }
+
+ private String readTemplate(int id) throws NotFoundException {
+ StringBuilder out = new StringBuilder();
+ InputStreamReader in = null;
+ try {
+ try {
+ in = new InputStreamReader(
+ mContext.getResources().openRawResource(id), "UTF-8");
+ char[] buf = new char[4096];
+ int chars;
+
+ while ((chars=in.read(buf)) > 0) {
+ out.append(buf, 0, chars);
+ }
+
+ return out.toString();
+
+ } finally {
+ if (in != null) {
+ in.close();
+ }
+ }
+ } catch (IOException e) {
+ throw new NotFoundException("Unable to open template id=" + Integer.toHexString(id)
+ + " exception=" + e.getMessage());
+ }
+ }
+
+ private void append(String template, Object... args) {
+ mFormatter.format(template, args);
+ }
+
+}
diff --git a/src/com/android/mail/ui/ScrollNotifier.java b/src/com/android/mail/ui/ScrollNotifier.java
new file mode 100644
index 0000000..b906e0e
--- /dev/null
+++ b/src/com/android/mail/ui/ScrollNotifier.java
@@ -0,0 +1,27 @@
+/*
+ * 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;
+
+public interface ScrollNotifier {
+ public interface ScrollListener {
+ void onNotifierScroll(int x, int y);
+ }
+
+ void addScrollListener(ScrollListener l);
+ void removeScrollListener(ScrollListener l);
+}
diff --git a/src/com/android/mail/utils/Utils.java b/src/com/android/mail/utils/Utils.java
index 2fe83e0..3648cf5 100644
--- a/src/com/android/mail/utils/Utils.java
+++ b/src/com/android/mail/utils/Utils.java
@@ -16,6 +16,8 @@
package com.android.mail.utils;
+import com.google.common.collect.Maps;
+
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
@@ -32,8 +34,8 @@
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.View;
-import android.view.ViewGroup;
import android.view.View.MeasureSpec;
+import android.view.ViewGroup;
import android.webkit.WebSettings;
import android.webkit.WebView;
@@ -41,7 +43,6 @@
import com.android.mail.providers.Account;
import com.android.mail.providers.Folder;
import com.android.mail.providers.UIProvider;
-import com.google.common.collect.Maps;
import java.util.Map;
@@ -214,7 +215,7 @@
* conversation and then describe the most important messages in order,
* indicating the priority of each message and whether the message is
* unread.
- *
+ *
* @param instructions instructions as described above
* @param senderBuilder the SpannableStringBuilder to append to for sender
* information
@@ -431,7 +432,7 @@
/**
* Adds a fragment with given style to a string builder.
- *
+ *
* @param builder the current string builder
* @param fragment the fragment to be added
* @param style the style of the fragment
@@ -479,7 +480,7 @@
* child has a ViewGroup parent and that it should be laid out within that
* parent with a matching width but variable height. Code largely lifted
* from AnimatedAdapter.measureChildHeight().
- *
+ *
* @param child a child view that has already been placed within its parent
* ViewGroup
* @param parent the parent ViewGroup of child