save/load conversation state, add granular mark unread
Centralize mark read/unread logic in AAC.
Restore some conversation view state upon rotation.
Clean up code for star/unstar from conversation view. Move most
of that logic to AAC.
Move transient conversation state from Message into
ConversationMessage subclass.
Add new AsyncTask for content provider single or batch requests.
We should move to using this instead of AsyncQueryHandler or a
raw thread.
Bug: 6293711
Change-Id: I907a687ef7ff287fece8c90725dbd204a02485e9
diff --git a/src/com/android/mail/browse/ConversationViewAdapter.java b/src/com/android/mail/browse/ConversationViewAdapter.java
index 78c0802..c1f8892 100644
--- a/src/com/android/mail/browse/ConversationViewAdapter.java
+++ b/src/com/android/mail/browse/ConversationViewAdapter.java
@@ -28,12 +28,12 @@
import com.android.mail.FormattedDateBuilder;
import com.android.mail.R;
import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
+import com.android.mail.browse.MessageCursor.ConversationMessage;
import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
import com.android.mail.browse.SuperCollapsedBlock.OnClickListener;
import com.android.mail.providers.Account;
import com.android.mail.providers.Address;
import com.android.mail.providers.Conversation;
-import com.android.mail.providers.Message;
import com.android.mail.providers.UIProvider;
import com.google.common.collect.Lists;
@@ -114,7 +114,7 @@
}
public class MessageHeaderItem extends ConversationOverlayItem {
- public final Message message;
+ public final ConversationMessage message;
// view state variables
private boolean mExpanded;
@@ -125,7 +125,7 @@
public CharSequence timestampLong;
public CharSequence recipientSummaryText;
- private MessageHeaderItem(Message message, boolean expanded) {
+ private MessageHeaderItem(ConversationMessage message, boolean expanded) {
this.message = message;
mExpanded = expanded;
@@ -341,7 +341,7 @@
return addItem(new ConversationHeaderItem(conv));
}
- public int addMessageHeader(Message msg, boolean expanded) {
+ public int addMessageHeader(ConversationMessage msg, boolean expanded) {
return addItem(new MessageHeaderItem(msg, expanded));
}
@@ -349,7 +349,7 @@
return addItem(new MessageFooterItem(headerItem));
}
- public MessageHeaderItem newMessageHeaderItem(Message message, boolean expanded) {
+ public MessageHeaderItem newMessageHeaderItem(ConversationMessage message, boolean expanded) {
return new MessageHeaderItem(message, expanded);
}
diff --git a/src/com/android/mail/browse/MessageCursor.java b/src/com/android/mail/browse/MessageCursor.java
index feb4896..fa19271 100644
--- a/src/com/android/mail/browse/MessageCursor.java
+++ b/src/com/android/mail/browse/MessageCursor.java
@@ -19,10 +19,12 @@
import android.database.Cursor;
import android.database.CursorWrapper;
+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.ui.ConversationListCallbacks;
+import com.android.mail.ui.ConversationUpdater;
import com.google.common.collect.Maps;
import java.util.Map;
@@ -33,19 +35,54 @@
*/
public class MessageCursor extends CursorWrapper {
- private final Map<Long, Message> mCache = Maps.newHashMap();
- private final ConversationListCallbacks mListController;
+ private final Map<Long, ConversationMessage> mCache = Maps.newHashMap();
+ private final Conversation mConversation;
+ private final ConversationUpdater mListController;
- public MessageCursor(Cursor inner, ConversationListCallbacks listController) {
+ /**
+ * 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.
+ *
+ * <p>(N.B. This is a {@link Parcelable}, so try not to add non-transient fields here.
+ * Parcelable state belongs either in {@link Message} or {@link MessageViewState}. The
+ * assumption is that this class never needs the state of its extra context saved.)
+ */
+ public static class ConversationMessage extends Message {
+
+ public final transient Conversation conversation;
+
+ private final transient MessageCursor mOwningCursor;
+ private final transient ConversationUpdater mListController;
+
+ public ConversationMessage(MessageCursor cursor, Conversation conv,
+ ConversationUpdater listController) {
+ super(cursor);
+ conversation = conv;
+ mOwningCursor = cursor;
+ mListController = listController;
+ }
+
+ public boolean isConversationStarred() {
+ return mOwningCursor.isConversationStarred();
+ }
+
+ public void star(boolean newStarred) {
+ mListController.starMessage(this, newStarred);
+ }
+
+ }
+
+ public MessageCursor(Cursor inner, Conversation conv, ConversationUpdater listController) {
super(inner);
+ mConversation = conv;
mListController = listController;
}
- public Message getMessage() {
+ public ConversationMessage getMessage() {
final long id = getWrappedCursor().getLong(UIProvider.MESSAGE_ID_COLUMN);
- Message m = mCache.get(id);
+ ConversationMessage m = mCache.get(id);
if (m == null) {
- m = new Message(this, mListController);
+ m = new ConversationMessage(this, mConversation, mListController);
mCache.put(id, m);
}
return m;
diff --git a/src/com/android/mail/browse/MessageHeaderView.java b/src/com/android/mail/browse/MessageHeaderView.java
index f383961..de7ed33 100644
--- a/src/com/android/mail/browse/MessageHeaderView.java
+++ b/src/com/android/mail/browse/MessageHeaderView.java
@@ -45,6 +45,7 @@
import com.android.mail.FormattedDateBuilder;
import com.android.mail.R;
import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
+import com.android.mail.browse.MessageCursor.ConversationMessage;
import com.android.mail.compose.ComposeActivity;
import com.android.mail.perf.Timer;
import com.android.mail.providers.Account;
@@ -167,7 +168,7 @@
private PopupMenu mPopup;
private MessageHeaderItem mMessageHeaderItem;
- private Message mMessage;
+ private ConversationMessage mMessage;
private boolean mCollapsedDetailsValid;
private boolean mExpandedDetailsValid;
@@ -831,8 +832,7 @@
case R.id.star: {
final boolean newValue = !v.isSelected();
v.setSelected(newValue);
- mMessage.star(newValue, getQueryHandler(), 0 /* token */, null /* cookie */);
- // TODO: propagate the change to the entry in conversation list
+ mMessage.star(newValue);
break;
}
case R.id.edit_draft:
diff --git a/src/com/android/mail/browse/SelectedConversationsActionMenu.java b/src/com/android/mail/browse/SelectedConversationsActionMenu.java
index f204312..51e6a2f 100644
--- a/src/com/android/mail/browse/SelectedConversationsActionMenu.java
+++ b/src/com/android/mail/browse/SelectedConversationsActionMenu.java
@@ -262,27 +262,7 @@
*/
private void markConversationsRead(boolean read) {
final Collection<Conversation> targets = mSelectionSet.values();
- ContentValues values;
- ConversationInfo info;
- for (Conversation target : targets) {
- values = new ContentValues();
- values.put(ConversationColumns.READ, read);
- info = target.conversationInfo;
- if (info != null) {
- try {
- info.markRead(read);
- values.put(ConversationColumns.CONVERSATION_INFO,
- ConversationInfo.toString(info));
- } catch (JSONException e) {
- LogUtils.e(LOG_TAG, e, "Error updating conversation info");
- }
- }
- mUpdater.updateConversation(Conversation.listOf(target), values);
- }
- // Update the conversations in the selection too.
- for (final Conversation c : targets) {
- c.read = read;
- }
+ mUpdater.markConversationsRead(targets, read);
updateSelection();
}
diff --git a/src/com/android/mail/providers/Message.java b/src/com/android/mail/providers/Message.java
index 46a9605..ea2df70 100644
--- a/src/com/android/mail/providers/Message.java
+++ b/src/com/android/mail/providers/Message.java
@@ -25,10 +25,7 @@
import android.provider.BaseColumns;
import android.text.TextUtils;
-import com.android.mail.browse.MessageCursor;
import com.android.mail.providers.UIProvider.MessageColumns;
-import com.android.mail.ui.AbstractActivityController;
-import com.android.mail.ui.ConversationListCallbacks;
import com.android.mail.utils.Utils;
import java.util.Collections;
@@ -181,10 +178,6 @@
private transient List<Attachment> mAttachments = null;
- // While viewing a list of messages, points to the cursor that contains it
- private transient MessageCursor mOwningCursor = null;
- private transient ConversationListCallbacks mListController = null;
-
@Override
public int describeContents() {
return 0;
@@ -301,13 +294,6 @@
};
- public Message(MessageCursor cursor, ConversationListCallbacks listController) {
- this(cursor);
- // Only set message cursor if appropriate
- mOwningCursor = cursor;
- mListController = listController;
- }
-
public Message(Cursor cursor) {
if (cursor != null) {
id = cursor.getLong(UIProvider.MESSAGE_ID_COLUMN);
@@ -440,37 +426,12 @@
* @param cookie (optional) cookie to pass to the handler
*/
public void markAlwaysShowImages(AsyncQueryHandler handler, int token, Object cookie) {
+ alwaysShowImages = true;
+
final ContentValues values = new ContentValues(1);
values.put(UIProvider.MessageColumns.ALWAYS_SHOW_IMAGES, 1);
handler.startUpdate(token, cookie, uri, values, null, null);
}
- /**
- * Helper method to command a provider to star/unstar this message.
- *
- * @param starred whether to star or unstar the message
- * @param handler a caller-provided handler to run the query on
- * @param token (optional) token to identify the command to the handler
- * @param cookie (optional) cookie to pass to the handler
- */
- public void star(boolean starred, AsyncQueryHandler handler, int token, Object cookie) {
- this.starred = starred;
- // If we're unstarring, we need to find out if the conversation is still starred
- if (mListController != null) {
- boolean conversationStarred = starred;
- if (!starred) {
- conversationStarred = mOwningCursor.isConversationStarred();
- }
- // Update the conversation cursor so that changes are reflected simultaneously
- mListController.sendConversationUriStarred(
- AbstractActivityController.TAG_CONVERSATION_LIST, conversationUri,
- conversationStarred, true /*local*/);
- }
- final ContentValues values = new ContentValues(1);
- values.put(UIProvider.MessageColumns.STARRED, starred ? 1 : 0);
-
- handler.startUpdate(token, cookie, uri, values, null, null);
- }
-
}
diff --git a/src/com/android/mail/ui/AbstractActivityController.java b/src/com/android/mail/ui/AbstractActivityController.java
index 2ebf8ec..c47df44 100644
--- a/src/com/android/mail/ui/AbstractActivityController.java
+++ b/src/com/android/mail/ui/AbstractActivityController.java
@@ -27,6 +27,7 @@
import android.app.LoaderManager;
import android.app.SearchManager;
import android.content.ComponentName;
+import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
@@ -61,10 +62,12 @@
import com.android.mail.R;
import com.android.mail.browse.ConversationCursor;
import com.android.mail.browse.ConversationPagerController;
+import com.android.mail.browse.MessageCursor.ConversationMessage;
import com.android.mail.browse.SelectedConversationsActionMenu;
import com.android.mail.compose.ComposeActivity;
import com.android.mail.providers.Account;
import com.android.mail.providers.Conversation;
+import com.android.mail.providers.ConversationInfo;
import com.android.mail.providers.Folder;
import com.android.mail.providers.MailAppProvider;
import com.android.mail.providers.Settings;
@@ -76,6 +79,7 @@
import com.android.mail.providers.UIProvider.ConversationColumns;
import com.android.mail.providers.UIProvider.FolderCapabilities;
import com.android.mail.ui.ActionableToastBar.ActionClickedListener;
+import com.android.mail.utils.ContentProviderTask;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.Utils;
@@ -87,6 +91,7 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.HashMap;
import java.util.Set;
import java.util.TimerTask;
@@ -275,7 +280,7 @@
/**
* Get the conversation list fragment for this activity. If the conversation list fragment
* is not attached, this method returns null
- * @return
+ *
*/
protected ConversationListFragment getConversationListFragment() {
final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_CONVERSATION_LIST);
@@ -288,7 +293,7 @@
/**
* Get the conversation view fragment for this activity. If the conversation view fragment
* is not attached, this method returns null
- * @return
+ *
*/
protected ConversationViewFragment getConversationViewFragment() {
final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_CONVERSATION);
@@ -301,7 +306,7 @@
/**
* Returns the folder list fragment attached with this activity. If no such fragment is attached
* this method returns null.
- * @return
+ *
*/
protected FolderListFragment getFolderListFragment() {
final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_FOLDER_LIST);
@@ -353,7 +358,6 @@
* Different layouts will have their own notion on the visibility of
* fragments, so this method needs to be overriden.
*
- * @return
*/
protected abstract boolean isConversationListVisible();
@@ -689,11 +693,6 @@
case R.id.report_phishing:
delete(target, getAction(R.id.report_phishing, target));
break;
- case R.id.inside_conversation_unread:
- updateConversation(Conversation.listOf(mCurrentConversation),
- ConversationColumns.READ, 0);
- mViewMode.enterConversationListMode();
- break;
case android.R.id.home:
onUpPressed();
break;
@@ -760,6 +759,104 @@
refreshConversationList();
}
+ @Override
+ public void markConversationMessagesUnread(Conversation conv, Set<Uri> unreadMessageUris,
+ String originalConversationInfo) {
+ // locally mark conversation unread (the provider is supposed to propagate message unread
+ // to conversation unread)
+ conv.read = false;
+
+ // mark the entire conversation unread if no messages are specified
+ if (unreadMessageUris == null || unreadMessageUris.isEmpty()) {
+ markConversationsRead(Collections.singletonList(conv), false /* read */);
+ } else {
+
+ mConversationListCursor.setConversationColumn(conv.uri.toString(),
+ ConversationColumns.READ, 0);
+
+ // locally update conversation's conversationInfo JSON to revert to original version
+ mConversationListCursor.setConversationColumn(conv.uri.toString(),
+ ConversationColumns.CONVERSATION_INFO, originalConversationInfo);
+
+ // applyBatch with each CPO as an UPDATE op on each affected message uri
+ final ArrayList<ContentProviderOperation> ops = Lists.newArrayList();
+ String authority = null;
+ for (Uri messageUri : unreadMessageUris) {
+ if (authority == null) {
+ authority = messageUri.getAuthority();
+ }
+ ops.add(ContentProviderOperation.newUpdate(messageUri)
+ .withValue(UIProvider.MessageColumns.READ, 0)
+ .build());
+ }
+
+ new ContentProviderTask() {
+ @Override
+ protected void onPostExecute(Result result) {
+ // TODO: handle errors?
+ }
+ }.run(mResolver, authority, ops);
+
+ }
+
+ mViewMode.enterConversationListMode();
+ }
+
+ @Override
+ public void markConversationsRead(Collection<Conversation> targets, boolean read) {
+ ContentValues values;
+ ConversationInfo info;
+ for (Conversation target : targets) {
+ values = new ContentValues();
+ values.put(ConversationColumns.READ, read);
+ info = target.conversationInfo;
+ if (info != null) {
+ try {
+ info.markRead(read);
+ values.put(ConversationColumns.CONVERSATION_INFO,
+ ConversationInfo.toString(info));
+ } catch (JSONException e) {
+ LogUtils.e(LOG_TAG, e, "Error updating conversation info");
+ }
+ }
+ updateConversation(Conversation.listOf(target), values);
+ }
+ // Update the conversations in the selection too.
+ for (final Conversation c : targets) {
+ c.read = read;
+ }
+ }
+
+ @Override
+ public void starMessage(ConversationMessage msg, boolean starred) {
+ if (msg.starred == starred) {
+ return;
+ }
+
+ msg.starred = starred;
+
+ // locally propagate the change to the owning conversation
+ // (figure the provider will properly propagate the change when it commits it)
+ //
+ // when unstarring, only propagate the change if this was the only message starred
+ final boolean conversationStarred = starred || msg.isConversationStarred();
+ if (conversationStarred != msg.conversation.starred) {
+ msg.conversation.starred = conversationStarred;
+ mConversationListCursor.setConversationColumn(msg.conversationUri,
+ ConversationColumns.STARRED, conversationStarred);
+ }
+
+ final ContentValues values = new ContentValues(1);
+ values.put(UIProvider.MessageColumns.STARRED, starred ? 1 : 0);
+
+ new ContentProviderTask.UpdateTask() {
+ @Override
+ protected void onPostExecute(Result result) {
+ // TODO: handle errors?
+ }
+ }.run(mResolver, msg.uri, values, null /* selection*/, null /* selectionArgs */);
+ }
+
private void requestFolderRefresh() {
if (mFolder != null) {
if (mAsyncRefreshTask != null) {
@@ -885,7 +982,7 @@
}
ConversationListFragment convListFragment = getConversationListFragment();
if (convListFragment != null) {
- ((AnimatedAdapter) convListFragment.getAnimatedAdapter())
+ convListFragment.getAnimatedAdapter()
.onSaveInstanceState(outState);
}
}
@@ -1006,7 +1103,7 @@
ConversationListFragment convListFragment = getConversationListFragment();
if (convListFragment != null) {
- ((AnimatedAdapter) convListFragment.getAnimatedAdapter())
+ convListFragment.getAnimatedAdapter()
.onRestoreInstanceState(savedState);
}
/**
@@ -1154,7 +1251,7 @@
/**
* Returns true if we are waiting for the account to sync, and cannot show any folders or
* conversation for the current account yet.
- * @return
+ *
*/
public boolean inWaitMode() {
final FragmentManager manager = mActivity.getFragmentManager();
@@ -1668,7 +1765,7 @@
/**
* Returns true if this action has been performed, false otherwise.
- * @return
+ *
*/
private synchronized boolean isPerformed() {
if (mCompleted) {
@@ -2003,39 +2100,6 @@
}
}
- @Override
- public void sendConversationRead(String toFragment, Conversation conversation, boolean state,
- boolean local) {
- if (toFragment.equals(TAG_CONVERSATION_LIST)) {
- if (mConversationListCursor != null) {
- if (local) {
- mConversationListCursor.setConversationColumn(conversation.uri.toString(), ConversationColumns.READ,
- state);
- } else {
- mConversationListCursor.markRead(mContext, state, conversation);
- }
- }
- } else if (toFragment.equals(TAG_CONVERSATION)) {
- // TODO Handle setting read in conversation view
- }
- }
-
- @Override
- public void sendConversationUriStarred(String toFragment, String conversationUri,
- boolean state, boolean local) {
- if (toFragment.equals(TAG_CONVERSATION_LIST)) {
- if (mConversationListCursor != null) {
- if (local) {
- mConversationListCursor.setConversationColumn(conversationUri, ConversationColumns.STARRED, state);
- } else {
- mConversationListCursor.updateBoolean(mContext, conversationUri, ConversationColumns.STARRED, state);
- }
- }
- } else if (toFragment.equals(TAG_CONVERSATION)) {
- // TODO Handle setting starred in conversation view
- }
- }
-
/**
* Destroy the pending {@link DestructiveAction} till now and assign the given action as the
* next destructive action..
@@ -2136,7 +2200,7 @@
/**
* Returns true if this action has been performed, false otherwise.
- * @return
+ *
*/
private synchronized boolean isPerformed() {
if (mCompleted) {
diff --git a/src/com/android/mail/ui/ConversationListCallbacks.java b/src/com/android/mail/ui/ConversationListCallbacks.java
index c5604a6..3d6a85f 100644
--- a/src/com/android/mail/ui/ConversationListCallbacks.java
+++ b/src/com/android/mail/ui/ConversationListCallbacks.java
@@ -39,10 +39,4 @@
void registerConversationListObserver(DataSetObserver observer);
void unregisterConversationListObserver(DataSetObserver observer);
-
- // conversation modification commands
- public void sendConversationRead(String toFragment, Conversation conversation, boolean state,
- boolean local);
- public void sendConversationUriStarred(String toFragment, String conversationUri,
- boolean state, boolean local);
}
diff --git a/src/com/android/mail/ui/ConversationUpdater.java b/src/com/android/mail/ui/ConversationUpdater.java
index 790e750..76d23e3 100644
--- a/src/com/android/mail/ui/ConversationUpdater.java
+++ b/src/com/android/mail/ui/ConversationUpdater.java
@@ -18,16 +18,21 @@
package com.android.mail.ui;
import android.content.ContentValues;
+import android.net.Uri;
+import com.android.mail.browse.ConversationCursor;
+import com.android.mail.browse.MessageCursor.ConversationMessage;
import com.android.mail.providers.Conversation;
+import com.android.mail.providers.ConversationInfo;
import com.android.mail.providers.UIProvider;
import java.util.Collection;
+import java.util.Set;
/**
* Classes that can update conversations implement this interface.
*/
-public interface ConversationUpdater {
+public interface ConversationUpdater extends ConversationListCallbacks {
/**
* Modify the given conversation by changing the column provided here to contain the value
* provided. Column names are listed in {@link UIProvider.ConversationColumns}, for example
@@ -76,6 +81,32 @@
void delete(final Collection<Conversation> target, final DestructiveAction action);
/**
+ * Mark a number of conversations as read or unread.
+ *
+ */
+ void markConversationsRead(Collection<Conversation> targets, boolean read);
+
+ /**
+ * Mark a single conversation unread, either entirely or for just a subset of the messages in a
+ * conversation.
+ *
+ * @param conv conversation to mark unread
+ * @param unreadMessageUris URIs for the subset of the conversation's messages to mark unread,
+ * or null/empty set to mark the entire conversation unread.
+ * @param originalConversationInfo the original unread state of the {@link ConversationInfo}
+ * that {@link ConversationCursor} will temporarily use until the commit is complete.
+ */
+ void markConversationMessagesUnread(Conversation conv, Set<Uri> unreadMessageUris,
+ String originalConversationInfo);
+
+ /**
+ * Star a single message within a conversation. This method requires a
+ * {@link ConversationMessage} to propagate the change to the owning {@link Conversation}.
+ *
+ */
+ void starMessage(ConversationMessage msg, boolean starred);
+
+ /**
* Get a destructive action for selected conversations. The action corresponds to Menu item
* identifiers, for example R.id.unread, or R.id.delete.
* @param action
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 8281817..aa96b6c 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -57,6 +57,7 @@
import com.android.mail.browse.ConversationViewHeader;
import com.android.mail.browse.ConversationWebView;
import com.android.mail.browse.MessageCursor;
+import com.android.mail.browse.MessageCursor.ConversationMessage;
import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks;
import com.android.mail.browse.SuperCollapsedBlock;
import com.android.mail.providers.Account;
@@ -68,6 +69,7 @@
import com.android.mail.providers.Settings;
import com.android.mail.providers.UIProvider;
import com.android.mail.providers.UIProvider.AccountCapabilities;
+import com.android.mail.providers.UIProvider.ConversationColumns;
import com.android.mail.providers.UIProvider.FolderCapabilities;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
@@ -77,6 +79,7 @@
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -146,12 +149,19 @@
private boolean mDeferredConversationLoad;
+ /**
+ * Parcelable state of the conversation view. Can safely be used without null checking any time
+ * after {@link #onCreateView(LayoutInflater, ViewGroup, Bundle)}.
+ */
+ private ConversationViewState mViewState;
+
private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks();
private static final String ARG_ACCOUNT = "account";
public static final String ARG_CONVERSATION = "conversation";
private static final String ARG_FOLDER = "folder";
+ private static final String BUNDLE_VIEW_STATE = "viewstate";
private static final boolean DEBUG_DUMP_CONVERSATION_HTML = false;
@@ -233,6 +243,13 @@
@Override
public View onCreateView(LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
+
+ if (savedInstanceState != null) {
+ mViewState = savedInstanceState.getParcelable(BUNDLE_VIEW_STATE);
+ } else {
+ mViewState = new ConversationViewState();
+ }
+
View rootView = inflater.inflate(R.layout.conversation_view, container, false);
mConversationContainer = (ConversationContainer) rootView
.findViewById(R.id.conversation_container);
@@ -288,6 +305,13 @@
}
@Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (mViewState != null) {
+ outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
+ }
+ }
+
+ @Override
public void onDestroyView() {
super.onDestroyView();
mConversationContainer.setOverlayAdapter(null);
@@ -343,6 +367,33 @@
&& !mConversation.muted);
}
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ boolean handled = false;
+
+ switch (item.getItemId()) {
+ case R.id.inside_conversation_unread:
+ markUnread();
+ handled = true;
+ break;
+ }
+
+ return handled;
+ }
+
+ private void markUnread() {
+ // Ignore unsafe calls made after a fragment is detached from an activity
+ final ControllableActivity activity = (ControllableActivity) getActivity();
+ if (activity == null) {
+ LogUtils.w(LOG_TAG, "ignoring markUnread for conv=%s", mConversation.id);
+ return;
+ }
+
+ final String info = (mViewState == null) ? null : mViewState.getConversationInfo();
+ activity.getConversationUpdater().markConversationMessagesUnread(mConversation,
+ mViewState.getUnreadMessageUris(), info);
+ }
+
/**
* {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
* reliability on older platforms.
@@ -452,19 +503,29 @@
mTemplates.startConversation(mWebView.screenPxToWebPx(convHeaderPx));
int collapsedStart = -1;
- Message prevCollapsedMsg = null;
+ ConversationMessage prevCollapsedMsg = null;
boolean prevSafeForImages = false;
while (messageCursor.moveToPosition(++pos)) {
- final Message msg = messageCursor.getMessage();
+ final ConversationMessage msg = messageCursor.getMessage();
// TODO: save/restore 'show pics' state
final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
allowNetworkImages |= safeForImages;
- final boolean expanded = !msg.read || msg.starred || messageCursor.isLast();
+ final Boolean savedExpanded = mViewState.getExpandedState(msg);
+ final boolean expanded;
+ if (savedExpanded != null) {
+ expanded = savedExpanded;
+ } else {
+ expanded = !msg.read || msg.starred || messageCursor.isLast();
+ }
- if (!expanded) {
+ // save off "read" state from the cursor
+ // later, the view may not match the cursor (e.g. conversation marked read on open)
+ mViewState.setReadState(msg, msg.read);
+
+ if (savedExpanded == null && !expanded) {
// contribute to a super-collapsed block that will be emitted just before the next
// expanded header
if (collapsedStart < 0) {
@@ -502,7 +563,8 @@
mTemplates.appendSuperCollapsedHtml(start, mWebView.screenPxToWebPx(blockPx));
}
- private void renderMessage(Message msg, boolean expanded, boolean safeForImages) {
+ private void renderMessage(ConversationMessage msg, boolean expanded,
+ boolean safeForImages) {
final int headerPos = mAdapter.addMessageHeader(msg, expanded);
final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
@@ -526,7 +588,7 @@
for (int i = blockToReplace.getStart(), end = blockToReplace.getEnd(); i <= end; i++) {
cursor.moveToPosition(i);
- final Message msg = cursor.getMessage();
+ final ConversationMessage msg = cursor.getMessage();
final MessageHeaderItem header = mAdapter.newMessageHeaderItem(msg,
false /* expanded */);
final MessageFooterItem footer = mAdapter.newMessageFooterItem(header);
@@ -538,6 +600,8 @@
mWebView.screenPxToWebPx(headerPx), mWebView.screenPxToWebPx(footerPx));
replacements.add(header);
replacements.add(footer);
+
+ mViewState.setExpandedState(msg, false);
}
mAdapter.replaceSuperCollapsedBlock(blockToReplace, replacements);
@@ -588,10 +652,10 @@
// mark as read upon open
if (!mConversation.read) {
- activity.getListHandler().sendConversationRead(
- AbstractActivityController.TAG_CONVERSATION_LIST, mConversation, true,
- false /*local*/);
+ mViewState.setInfoForConversation(mConversation);
mConversation.read = true;
+ activity.getConversationUpdater().markConversationsRead(Arrays.asList(mConversation),
+ true /* read */);
}
activity.onConversationSeen(mConversation);
@@ -651,6 +715,8 @@
item.isExpanded(), h, newSpacerHeightPx);
mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %d);",
mTemplates.getMessageDomId(item.message), item.isExpanded(), h));
+
+ mViewState.setExpandedState(item.message, item.isExpanded());
}
@Override
@@ -672,16 +738,18 @@
private static class MessageLoader extends CursorLoader {
private boolean mDeliveredFirstResults = false;
- private final ConversationListCallbacks mListController;
+ private final Conversation mConversation;
+ private final ConversationUpdater mListController;
- public MessageLoader(Context c, Uri uri, ConversationListCallbacks listController) {
- super(c, uri, UIProvider.MESSAGE_PROJECTION, null, null, null);
- mListController = listController;
+ public MessageLoader(Context c, Conversation conv, ConversationUpdater updater) {
+ super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
+ mConversation = conv;
+ mListController = updater;
}
@Override
public Cursor loadInBackground() {
- return new MessageCursor(super.loadInBackground(), mListController);
+ return new MessageCursor(super.loadInBackground(), mConversation, mListController);
}
@Override
@@ -824,8 +892,7 @@
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
- return new MessageLoader(mContext, mConversation.messageListUri,
- mActivity.getListHandler());
+ return new MessageLoader(mContext, mConversation, mActivity.getConversationUpdater());
}
@Override
diff --git a/src/com/android/mail/ui/ConversationViewState.java b/src/com/android/mail/ui/ConversationViewState.java
new file mode 100644
index 0000000..e6a4f58
--- /dev/null
+++ b/src/com/android/mail/ui/ConversationViewState.java
@@ -0,0 +1,194 @@
+/*
+ * 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.net.Uri;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.mail.providers.Conversation;
+import com.android.mail.providers.Message;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A small class to keep state for conversation view when restoring.
+ *
+ */
+class ConversationViewState implements Parcelable {
+
+ // N.B. don't serialize entire Messages because they contain body HTML/text
+
+ private final Map<Uri, MessageViewState> mMessageViewStates = Maps.newHashMap();
+
+ private String mConversationInfo;
+
+ public ConversationViewState() {}
+
+ public boolean isUnread(Message m) {
+ final MessageViewState mvs = mMessageViewStates.get(m.uri);
+ return (mvs != null && !mvs.read);
+ }
+
+ public void setReadState(Message m, boolean read) {
+ MessageViewState mvs = mMessageViewStates.get(m.uri);
+ if (mvs == null) {
+ mvs = new MessageViewState();
+ }
+ mvs.read = read;
+ mMessageViewStates.put(m.uri, mvs);
+ }
+
+ /**
+ * Returns the expansion state of a message in a conversation view. Expansion state only refers
+ * to the user action of expanding or collapsing a message view, and not any messages that
+ * are expanded by default (e.g. last message, starred messages).
+ *
+ * @param m a Message in the conversation
+ * @return true if the user expanded it, false if the user collapsed it, or null otherwise.
+ */
+ public Boolean getExpandedState(Message m) {
+ final MessageViewState mvs = mMessageViewStates.get(m.uri);
+ return (mvs == null ? null : mvs.expanded);
+ }
+
+ public void setExpandedState(Message m, boolean expanded) {
+ MessageViewState mvs = mMessageViewStates.get(m.uri);
+ if (mvs == null) {
+ mvs = new MessageViewState();
+ }
+ mvs.expanded = expanded;
+ mMessageViewStates.put(m.uri, mvs);
+ }
+
+ public String getConversationInfo() {
+ return mConversationInfo;
+ }
+
+ public void setInfoForConversation(Conversation conv) {
+ mConversationInfo = conv.conversationInfo.toString();
+ }
+
+ /**
+ * Returns URIs of all unread messages in the conversation per
+ * {@link #setReadState(Message, boolean)}. Returns an empty set for read conversations.
+ *
+ */
+ public Set<Uri> getUnreadMessageUris() {
+ final Set<Uri> result = Sets.newHashSet();
+ for (Uri uri : mMessageViewStates.keySet()) {
+ final MessageViewState mvs = mMessageViewStates.get(uri);
+ if (mvs != null && !mvs.read) {
+ result.add(uri);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ final Bundle states = new Bundle();
+ for (Uri uri : mMessageViewStates.keySet()) {
+ final MessageViewState mvs = mMessageViewStates.get(uri);
+ states.putParcelable(uri.toString(), mvs);
+ }
+ dest.writeBundle(states);
+ dest.writeString(mConversationInfo);
+ }
+
+ private ConversationViewState(Parcel source) {
+ final Bundle states = source.readBundle(MessageViewState.class.getClassLoader());
+ for (String key : states.keySet()) {
+ final MessageViewState state = states.getParcelable(key);
+ mMessageViewStates.put(Uri.parse(key), state);
+ }
+ mConversationInfo = source.readString();
+ }
+
+ public static Parcelable.Creator<ConversationViewState> CREATOR =
+ new Parcelable.Creator<ConversationViewState>() {
+
+ @Override
+ public ConversationViewState createFromParcel(Parcel source) {
+ return new ConversationViewState(source);
+ }
+
+ @Override
+ public ConversationViewState[] newArray(int size) {
+ return new ConversationViewState[size];
+ }
+
+ };
+
+ // Keep per-message state in an inner Parcelable.
+ // This is a semi-private implementation detail.
+ static class MessageViewState implements Parcelable {
+
+ public boolean read;
+ /**
+ * null = default, false = collapsed, true = expanded
+ */
+ public Boolean expanded;
+
+ public MessageViewState() {}
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(read ? 1 : 0);
+ dest.writeInt(expanded == null ? -1 : (expanded ? 1 : 0));
+ }
+
+ private MessageViewState(Parcel source) {
+ read = (source.readInt() != 0);
+ final int expandedVal = source.readInt();
+ expanded = (expandedVal == -1) ? null : (expandedVal != 0);
+ }
+
+ @SuppressWarnings("hiding")
+ public static Parcelable.Creator<MessageViewState> CREATOR =
+ new Parcelable.Creator<MessageViewState>() {
+
+ @Override
+ public MessageViewState createFromParcel(Parcel source) {
+ return new MessageViewState(source);
+ }
+
+ @Override
+ public MessageViewState[] newArray(int size) {
+ return new MessageViewState[size];
+ }
+
+ };
+
+ }
+
+}
diff --git a/src/com/android/mail/utils/ContentProviderTask.java b/src/com/android/mail/utils/ContentProviderTask.java
new file mode 100644
index 0000000..e72b898
--- /dev/null
+++ b/src/com/android/mail/utils/ContentProviderTask.java
@@ -0,0 +1,126 @@
+/*
+ * 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.utils;
+
+import android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.AsyncTask;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+
+/**
+ * Simple utility class to make an asynchronous {@link ContentProvider} request expressed as
+ * a list of {@link ContentProviderOperation}s. As with all {@link AsyncTask}s, subclasses should
+ * override {@link #onPostExecute(Result)} to handle success/failure.
+ *
+ * @see InsertTask
+ * @see UpdateTask
+ * @see DeleteTask
+ *
+ */
+public class ContentProviderTask extends AsyncTask<Void, Void, ContentProviderTask.Result> {
+
+ private ContentResolver mResolver;
+ private String mAuthority;
+ private ArrayList<ContentProviderOperation> mOps;
+
+ private static final String LOG_TAG = LogTag.getLogTag();
+
+ public static class Result {
+ Exception exception;
+ ContentProviderResult[] results;
+
+ private Result() {}
+
+ private void setSuccess(ContentProviderResult[] success) {
+ exception = null;
+ results = success;
+ }
+
+ private void setFailure(Exception failure) {
+ results = null;
+ exception = failure;
+ }
+ }
+
+ @Override
+ protected Result doInBackground(Void... params) {
+ Result result = new Result();
+ try {
+ result.setSuccess(mResolver.applyBatch(mAuthority, mOps));
+ } catch (Exception e) {
+ LogUtils.w(LOG_TAG, e, "exception executing ContentProviderOperationsTask");
+ result.setFailure(e);
+ }
+ return result;
+ }
+
+ public void run(ContentResolver resolver, String authority,
+ ArrayList<ContentProviderOperation> ops) {
+ mResolver = resolver;
+ mAuthority = authority;
+ mOps = ops;
+ executeOnExecutor(THREAD_POOL_EXECUTOR, (Void) null);
+ }
+
+ public static class InsertTask extends ContentProviderTask {
+
+ public void run(ContentResolver resolver, Uri uri, ContentValues values) {
+ final ContentProviderOperation op = ContentProviderOperation
+ .newInsert(uri)
+ .withValues(values)
+ .build();
+ super.run(resolver, uri.getAuthority(), Lists.newArrayList(op));
+ }
+
+ }
+
+ public static class UpdateTask extends ContentProviderTask {
+
+ public void run(ContentResolver resolver, Uri uri, ContentValues values,
+ String selection, String[] selectionArgs) {
+ final ContentProviderOperation op = ContentProviderOperation
+ .newUpdate(uri)
+ .withValues(values)
+ .withSelection(selection, selectionArgs)
+ .build();
+ super.run(resolver, uri.getAuthority(), Lists.newArrayList(op));
+ }
+
+ }
+
+ public static class DeleteTask extends ContentProviderTask {
+
+ public void run(ContentResolver resolver, Uri uri, String selection,
+ String[] selectionArgs) {
+ final ContentProviderOperation op = ContentProviderOperation
+ .newDelete(uri)
+ .withSelection(selection, selectionArgs)
+ .build();
+ super.run(resolver, uri.getAuthority(), Lists.newArrayList(op));
+ }
+
+ }
+
+}