"new message from" in conversation view on update
* put the New Message bar in a new floaty layer in
ConversationContainer. snap header can also live here.
* add left/right margin support to ConversationContainer
Send/save of a new message also generates the 'new message'
notification for now.
Bug: 6384217
Change-Id: I10a40bbf87423194214e5ded08539abaaf7fd25c
diff --git a/src/com/android/mail/browse/ConversationContainer.java b/src/com/android/mail/browse/ConversationContainer.java
index 2d4fcf2..9ce1acc 100644
--- a/src/com/android/mail/browse/ConversationContainer.java
+++ b/src/com/android/mail/browse/ConversationContainer.java
@@ -35,6 +35,9 @@
import com.android.mail.ui.ConversationViewFragment;
import com.android.mail.utils.DequeMap;
import com.android.mail.utils.LogUtils;
+import com.google.common.collect.Lists;
+
+import java.util.List;
/**
* A specialized ViewGroup container for conversation view. It is designed to contain a single
@@ -61,10 +64,21 @@
private static final String TAG = ConversationViewFragment.LAYOUT_TAG;
+ private static final int[] BOTTOM_LAYER_VIEW_IDS = {
+ R.id.webview
+ };
+
+ private static final int[] TOP_LAYER_VIEW_IDS = {
+ R.id.conversation_topmost_overlay
+ };
+ private static final int TOP_LAYER_COUNT = TOP_LAYER_VIEW_IDS.length;
+
private ConversationViewAdapter mOverlayAdapter;
private int[] mOverlayBottoms;
private ConversationWebView mWebView;
+ private final List<View> mNonScrollingChildren = Lists.newArrayList();
+
/**
* Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual
* screen pixels to logical WebView HTML pixels. We use it to convert from one to the other.
@@ -178,6 +192,13 @@
mWebView = (ConversationWebView) findViewById(R.id.webview);
mWebView.addScrollListener(this);
+
+ for (int id : BOTTOM_LAYER_VIEW_IDS) {
+ mNonScrollingChildren.add(findViewById(id));
+ }
+ for (int id : TOP_LAYER_VIEW_IDS) {
+ mNonScrollingChildren.add(findViewById(id));
+ }
}
public void setOverlayAdapter(ConversationViewAdapter a) {
@@ -378,15 +399,13 @@
* Copied/stolen from {@link ListView}.
*/
private void measureOverlayView(View child) {
- ViewGroup.LayoutParams p = child.getLayoutParams();
+ MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams();
if (p == null) {
- p = new ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.WRAP_CONTENT);
+ p = (MarginLayoutParams) generateDefaultLayoutParams();
}
int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
- getPaddingLeft() + getPaddingRight(), p.width);
+ getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width);
int lpHeight = p.height;
int childHeightSpec;
if (lpHeight > 0) {
@@ -455,8 +474,11 @@
MeasureSpec.toString(heightMeasureSpec));
}
- if (mWebView.getVisibility() != GONE) {
- measureChild(mWebView, widthMeasureSpec, heightMeasureSpec);
+ for (View nonScrollingChild : mNonScrollingChildren) {
+ if (nonScrollingChild.getVisibility() != GONE) {
+ measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */,
+ heightMeasureSpec, 0 /* heightUsed */);
+ }
}
mWidthMeasureSpec = widthMeasureSpec;
@@ -468,7 +490,19 @@
protected void onLayout(boolean changed, int l, int t, int r, int b) {
LogUtils.d(TAG, "*** IN header container onLayout");
- mWebView.layout(0, 0, mWebView.getMeasuredWidth(), mWebView.getMeasuredHeight());
+ for (View nonScrollingChild : mNonScrollingChildren) {
+ if (nonScrollingChild.getVisibility() != GONE) {
+ final int w = nonScrollingChild.getMeasuredWidth();
+ final int h = nonScrollingChild.getMeasuredHeight();
+
+ final MarginLayoutParams lp =
+ (MarginLayoutParams) nonScrollingChild.getLayoutParams();
+
+ final int childLeft = lp.leftMargin;
+ final int childTop = lp.topMargin;
+ nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h);
+ }
+ }
if (mOverlayAdapter != null) {
// being in a layout pass means overlay children may require measurement,
@@ -481,6 +515,26 @@
positionOverlays(0, mOffsetY);
}
+ @Override
+ protected LayoutParams generateDefaultLayoutParams() {
+ return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+ }
+
+ @Override
+ public LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new MarginLayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
+ return new MarginLayoutParams(p);
+ }
+
+ @Override
+ protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
+ return p instanceof MarginLayoutParams;
+ }
+
private int getOverlayBottom(int spacerIndex) {
// TODO: round or truncate?
return (int) (mOverlayBottoms[spacerIndex] * mScale);
@@ -528,7 +582,11 @@
private void layoutOverlay(View child, int childTop, int childBottom) {
final int top = childTop - mOffsetY;
final int bottom = childBottom - mOffsetY;
- child.layout(0, top, child.getMeasuredWidth(), bottom);
+
+ final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
+ final int childLeft = getPaddingLeft() + lp.leftMargin;
+
+ child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom);
}
private View addOverlayView(int adapterIndex) {
@@ -538,15 +596,17 @@
View view = mOverlayAdapter.getView(adapterIndex, convertView, this);
mOverlayViews.put(adapterIndex, new OverlayView(view, itemType));
+ final int index = getChildCount() - TOP_LAYER_COUNT;
+
// Only re-attach if the view had previously been added to a view hierarchy.
// Since external components can contribute to the scrap heap (addScrapView), we can't
// assume scrap views had already been attached.
if (view.getRootView() != view) {
LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view);
- attachViewToParent(view, -1, view.getLayoutParams());
+ attachViewToParent(view, index, view.getLayoutParams());
} else {
LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view);
- addViewInLayout(view, -1, view.getLayoutParams(),
+ addViewInLayout(view, index, view.getLayoutParams(),
true /* preventRequestLayout */);
}
@@ -565,7 +625,6 @@
mOverlayBottoms = null;
}
- // TODO: add margin support for children that want it (e.g. tablet headers?)
public void onGeometryChange(int[] overlayBottoms) {
traceLayout("*** got overlay spacer bottoms:");
for (int offsetY : overlayBottoms) {
diff --git a/src/com/android/mail/browse/MessageCursor.java b/src/com/android/mail/browse/MessageCursor.java
index fa19271..6b14d20 100644
--- a/src/com/android/mail/browse/MessageCursor.java
+++ b/src/com/android/mail/browse/MessageCursor.java
@@ -19,11 +19,14 @@
import android.database.Cursor;
import android.database.CursorWrapper;
+import android.os.Bundle;
import android.os.Parcelable;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.Message;
import com.android.mail.providers.UIProvider;
+import com.android.mail.providers.UIProvider.CursorExtraKeys;
+import com.android.mail.providers.UIProvider.CursorStatus;
import com.android.mail.ui.ConversationUpdater;
import com.google.common.collect.Maps;
@@ -39,6 +42,8 @@
private final Conversation mConversation;
private final ConversationUpdater mListController;
+ private Integer mStatus;
+
/**
* A message created as part of a conversation view. Sometimes, like during star/unstar, it's
* handy to have the owning {@link MessageCursor} and {@link Conversation} for context.
@@ -100,4 +105,35 @@
return false;
}
+ public int getStatus() {
+ if (mStatus != null) {
+ return mStatus;
+ }
+
+ mStatus = CursorStatus.LOADED;
+ final Bundle extras = getExtras();
+ if (extras != null && extras.containsKey(CursorExtraKeys.EXTRA_STATUS)) {
+ mStatus = extras.getInt(CursorExtraKeys.EXTRA_STATUS);
+ }
+ return mStatus;
+ }
+
+ public boolean isLoaded() {
+ return getStatus() >= CursorStatus.LOADED || getCount() > 0; // FIXME: remove count hack
+ }
+
+ public String getDebugDump() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(String.format("conv subj='%s' status=%d messages:\n",
+ mConversation.subject, getStatus()));
+ int pos = -1;
+ while (moveToPosition(++pos)) {
+ final Message m = getMessage();
+ sb.append(String.format(
+ "[Message #%d uri=%s id=%d serverId=%d, from='%s' draftType=%d isSending=%s]\n",
+ pos, m.uri, m.id, m.serverId, m.from, m.draftType, m.isSending));
+ }
+ return sb.toString();
+ }
+
}
\ No newline at end of file
diff --git a/src/com/android/mail/providers/Message.java b/src/com/android/mail/providers/Message.java
index 39fc6aa..11889b4 100644
--- a/src/com/android/mail/providers/Message.java
+++ b/src/com/android/mail/providers/Message.java
@@ -169,6 +169,10 @@
* @see UIProvider.MessageColumns#VIA_DOMAIN
*/
public String viaDomain;
+ /**
+ * @see UIProvider.MessageColumns#IS_SENDING
+ */
+ public boolean isSending;
private transient String[] mFromAddresses = null;
private transient String[] mToAddresses = null;
@@ -234,6 +238,7 @@
dest.writeInt(spamWarningLevel);
dest.writeInt(spamLinkType);
dest.writeString(viaDomain);
+ dest.writeInt(isSending ? 1 : 0);
}
private Message(Parcel in) {
@@ -269,6 +274,7 @@
spamWarningLevel = in.readInt();
spamLinkType = in.readInt();
viaDomain = in.readString();
+ isSending = in.readInt() != 0;
}
public Message() {
@@ -342,6 +348,7 @@
spamWarningLevel = cursor.getInt(UIProvider.MESSAGE_SPAM_WARNING_LEVEL_COLUMN);
spamLinkType = cursor.getInt(UIProvider.MESSAGE_SPAM_WARNING_LINK_TYPE_COLUMN);
viaDomain = cursor.getString(UIProvider.MESSAGE_VIA_DOMAIN_COLUMN);
+ isSending = cursor.getInt(UIProvider.MESSAGE_IS_SENDING_COLUMN) != 0;
}
}
diff --git a/src/com/android/mail/providers/UIProvider.java b/src/com/android/mail/providers/UIProvider.java
index 7d5d506..eb1c7b5 100644
--- a/src/com/android/mail/providers/UIProvider.java
+++ b/src/com/android/mail/providers/UIProvider.java
@@ -1101,7 +1101,8 @@
MessageColumns.SPAM_WARNING_STRING,
MessageColumns.SPAM_WARNING_LEVEL,
MessageColumns.SPAM_WARNING_LINK_TYPE,
- MessageColumns.VIA_DOMAIN
+ MessageColumns.VIA_DOMAIN,
+ MessageColumns.IS_SENDING
};
/** Separates attachment info parts in strings in a message. */
@@ -1148,6 +1149,7 @@
public static final int MESSAGE_SPAM_WARNING_LEVEL_COLUMN = 33;
public static final int MESSAGE_SPAM_WARNING_LINK_TYPE_COLUMN = 34;
public static final int MESSAGE_VIA_DOMAIN_COLUMN = 35;
+ public static final int MESSAGE_IS_SENDING_COLUMN = 36;
public static final class CursorStatus {
// The cursor is actively loading more data
@@ -1194,9 +1196,9 @@
public static final class MessageFlags {
- public static final int REPLIED = 1 << 2;
- public static final int FORWARDED = 1 << 3;
- public static final int CALENDAR_INVITE = 1 << 4;
+ public static final int REPLIED = 1 << 2;
+ public static final int FORWARDED = 1 << 3;
+ public static final int CALENDAR_INVITE = 1 << 4;
}
public static final class MessageColumns {
@@ -1364,6 +1366,11 @@
* domain. This column should be null if no via domain exists.
*/
public static final String VIA_DOMAIN = "viaDomain";
+ /**
+ * This boolean column indicates whether the message is an outgoing message in the process
+ * of being sent (will be zero for incoming messages and messages that are already sent).
+ */
+ public static final String IS_SENDING = "isSending";
private MessageColumns() {}
}
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 70149c7..047c934 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -32,6 +32,7 @@
import android.os.Bundle;
import android.os.Handler;
import android.provider.Browser;
+import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -43,6 +44,7 @@
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
+import android.widget.TextView;
import com.android.mail.ContactInfo;
import com.android.mail.ContactInfoSource;
@@ -113,6 +115,8 @@
private ConversationWebView mWebView;
+ private View mNewMessageBar;
+
private HtmlConversationTemplates mTemplates;
private String mBaseUri;
@@ -125,6 +129,7 @@
private ConversationViewAdapter mAdapter;
private MessageCursor mCursor;
+ private MessageCursor mPendingCursor;
private boolean mViewsCreated;
@@ -173,6 +178,7 @@
private static final String BUNDLE_VIEW_STATE = "viewstate";
private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
+ private static final boolean DISABLE_OFFSCREEN_LOADING = false;
/**
* Constructor needs to be public to handle orientation changes and activity lifecycle events.
@@ -264,6 +270,15 @@
View rootView = inflater.inflate(R.layout.conversation_view, container, false);
mConversationContainer = (ConversationContainer) rootView
.findViewById(R.id.conversation_container);
+
+ mNewMessageBar = mConversationContainer.findViewById(R.id.new_message_notification_bar);
+ mNewMessageBar.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onNewMessageBarClick();
+ }
+ });
+
mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
mWebView.addJavascriptInterface(mJsBridge, "mail");
@@ -445,7 +460,9 @@
* a folder. This will initiate a data load, and hence must be called on the UI thread.
*/
private void showConversation() {
- if (!mUserVisible && mConversation.getNumMessages() > mMaxAutoLoadMessages) {
+ final boolean disableOffscreenLoading = DISABLE_OFFSCREEN_LOADING
+ || (mConversation.getNumMessages() > mMaxAutoLoadMessages);
+ if (!mUserVisible && disableOffscreenLoading) {
LogUtils.v(LOG_TAG, "Fragment not user-visible, not showing conversation: %s",
mConversation.uri);
mDeferredConversationLoad = true;
@@ -513,6 +530,11 @@
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.
@@ -533,10 +555,11 @@
final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
allowNetworkImages |= safeForImages;
- final Boolean savedExpanded = mViewState.getExpandedState(msg);
+ final Boolean savedExpanded = prevState.getExpandedState(msg);
final boolean expanded;
if (savedExpanded != null) {
expanded = savedExpanded;
+ mViewState.setExpandedState(msg, expanded);
} else {
expanded = !msg.read || msg.starred || messageCursor.isLast();
}
@@ -772,6 +795,20 @@
mWebView.loadUrl("javascript:replaceSuperCollapsedBlock(" + item.getStart() + ")");
}
+ private void showNewMessageNotification(NewMessagesInfo info) {
+ final TextView descriptionView = (TextView) mNewMessageBar.findViewById(
+ R.id.new_message_description);
+ descriptionView.setText(info.getNotificationText());
+ mNewMessageBar.setVisibility(View.VISIBLE);
+ }
+
+ private void onNewMessageBarClick() {
+ mNewMessageBar.setVisibility(View.GONE);
+
+ renderConversation(mPendingCursor);
+ mPendingCursor = null;
+ }
+
private static class MessageLoader extends CursorLoader {
private boolean mDeliveredFirstResults = false;
private final Conversation mConversation;
@@ -819,6 +856,16 @@
return ints;
}
+ @Override
+ public String toString() {
+ // log extra info at DEBUG level or finer
+ final String s = super.toString();
+ if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
+ return s;
+ }
+ return "(" + s + " subj=" + mConversation.subject + ")";
+ }
+
private class ConversationWebViewClient extends WebViewClient {
@Override
@@ -831,8 +878,8 @@
return;
}
- LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s", url,
- ConversationViewFragment.this);
+ LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s act=%s", url,
+ ConversationViewFragment.this, getActivity());
super.onPageFinished(view, url);
@@ -926,6 +973,32 @@
}
+ private class NewMessagesInfo {
+ int count;
+ String senderAddress;
+
+ /**
+ * Return the display text for the new message notification overlay. It will be formatted
+ * appropriately for a single new message vs. multiple new messages.
+ *
+ * @return display text
+ */
+ public String getNotificationText() {
+ final Object param;
+ if (count > 1) {
+ param = count;
+ } else {
+ Address addr = mAddressCache.get(senderAddress);
+ if (addr == null) {
+ addr = Address.getEmailAddress(senderAddress);
+ mAddressCache.put(senderAddress, addr);
+ }
+ param = TextUtils.isEmpty(addr.getName()) ? addr.getAddress() : addr.getName();
+ }
+ return getResources().getQuantityString(R.plurals.new_incoming_messages, count, param);
+ }
+ }
+
private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
@Override
@@ -943,22 +1016,54 @@
return;
}
- // TODO: handle Gmail loading states (like LOADING and ERROR)
- if (messageCursor.getCount() == 0) {
- if (mCursor != null) {
- // TODO: need to exit this view- conversation may have been deleted, or for
- // whatever reason is now invalid
- } else {
- // ignore zero-sized cursors during initial load
- }
+ if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
+ LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
+ }
+
+ // ignore cursors that are still loading results
+ if (!messageCursor.isLoaded()) {
return;
}
+ // TODO: handle ERROR status
+
+ if (messageCursor.getCount() == 0 && mCursor != null) {
+ // TODO: need to exit this view- conversation may have been deleted, or for
+ // whatever reason is now invalid (e.g. discard single draft)
+ return;
+ }
+
+ if (mCursor != null) {
+ final NewMessagesInfo info = getNewIncomingMessagesInfo(messageCursor);
+
+ 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)
+ //
+ // hold the new cursor as pending for later render
+ mPendingCursor = messageCursor;
+ LogUtils.i(LOG_TAG,
+ "conversation updated, holding cursor for new incoming message");
+
+ showNewMessageNotification(info);
+
+ return;
+ }
+ }
+
+ if (mCursor == null) {
+ LogUtils.i(LOG_TAG, "existing cursor is null, rendering from scratch");
+ } else {
+ // re-render?
+ // or render just those messages that changed?
+ LogUtils.i(LOG_TAG,
+ "conversation updated, but not due to incoming message. rendering.");
+ }
+ renderConversation(messageCursor);
+
// TODO: if this is not user-visible, delay render until user-visible fragment is done.
// This is needed in addition to the showConversation() delay to speed up rotation and
// restoration.
-
- renderConversation(messageCursor);
}
@Override
@@ -967,6 +1072,22 @@
// TODO: null out all Message.mMessageCursor references
}
+ 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);
+ // TODO: distinguish ours from theirs
+ info.count++;
+ info.senderAddress = m.from;
+ }
+ }
+ return info;
+ }
+
}
/**
diff --git a/src/com/android/mail/ui/ConversationViewState.java b/src/com/android/mail/ui/ConversationViewState.java
index 500d19c..f5795dc 100644
--- a/src/com/android/mail/ui/ConversationViewState.java
+++ b/src/com/android/mail/ui/ConversationViewState.java
@@ -47,6 +47,13 @@
public ConversationViewState() {}
+ /**
+ * Copy constructor that will copy overall conversation state, but NOT individual message state.
+ */
+ public ConversationViewState(ConversationViewState other) {
+ mConversationInfo = other.mConversationInfo;
+ }
+
public boolean isUnread(Message m) {
final MessageViewState mvs = mMessageViewStates.get(m.uri);
return (mvs != null && !mvs.read);
@@ -107,6 +114,10 @@
return result;
}
+ public boolean contains(Message m) {
+ return mMessageViewStates.containsKey(m.uri);
+ }
+
@Override
public int describeContents() {
return 0;
diff --git a/src/com/android/mail/ui/TwoPaneLayout.java b/src/com/android/mail/ui/TwoPaneLayout.java
index e6f937a..b9077c7 100644
--- a/src/com/android/mail/ui/TwoPaneLayout.java
+++ b/src/com/android/mail/ui/TwoPaneLayout.java
@@ -99,7 +99,6 @@
private View mConversationListContainer;
private View mConversationView;
- private View mConversationViewOverlay;
/** Left position of each fragment. */
private int mFoldersLeft;
private View mFoldersView;
@@ -485,7 +484,6 @@
mConversationListContainer = findViewById(R.id.conversation_column_container);
mListView = findViewById(R.id.conversation_list);
mConversationView = findViewById(R.id.conversation_pane_container);
- mConversationViewOverlay = findViewById(R.id.conversation_overlay);
sAnimationSlideLeftDuration = res.getInteger(R.integer.activity_slide_left_duration);
sAnimationSlideRightDuration = res.getInteger(R.integer.activity_slide_right_duration);
@@ -531,7 +529,6 @@
*/
private void onFinishEnteringConversationListMode() {
mConversationView.setVisibility(View.GONE);
- mConversationViewOverlay.setVisibility(View.GONE);
mFoldersView.setVisibility(View.VISIBLE);
// Once animations settle, the conversation list always takes up the