summaryrefslogtreecommitdiffstats
path: root/src/com/android/mail
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/mail')
-rw-r--r--src/com/android/mail/ConversationInfo.java205
-rw-r--r--src/com/android/mail/UndoOperation.java10
-rw-r--r--src/com/android/mail/UnifiedEmail.java5
-rw-r--r--src/com/android/mail/browse/ConversationCursor.java658
-rw-r--r--src/com/android/mail/browse/ConversationItemView.java11
-rw-r--r--src/com/android/mail/browse/ConversationItemViewModel.java3
-rw-r--r--src/com/android/mail/browse/ConversationListActivity.java66
-rw-r--r--src/com/android/mail/browse/FolderItem.java32
-rw-r--r--src/com/android/mail/browse/SelectedConversationsActionMenu.java24
-rw-r--r--src/com/android/mail/providers/Conversation.java77
-rw-r--r--src/com/android/mail/utils/LogUtils.java36
-rw-r--r--src/com/android/mail/utils/LoggingInputStream.java2
-rw-r--r--src/com/android/mail/utils/Utils.java30
13 files changed, 732 insertions, 427 deletions
diff --git a/src/com/android/mail/ConversationInfo.java b/src/com/android/mail/ConversationInfo.java
deleted file mode 100644
index 54414451f..000000000
--- a/src/com/android/mail/ConversationInfo.java
+++ /dev/null
@@ -1,205 +0,0 @@
-/*******************************************************************************
- * 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.
- *******************************************************************************/
-
-package com.android.mail;
-
-import com.android.mail.providers.Folder;
-import com.android.mail.providers.UIProvider;
-
-import com.google.common.base.Objects;
-import com.google.common.collect.ImmutableMap;
-
-import android.content.UriMatcher;
-import android.os.Parcel;
-import android.os.Parcelable;
-
-import java.util.Collections;
-import java.util.Map;
-
-/**
- * Helper class that holds the information specifying a conversation or a message (message id,
- * conversation id, max message id and labels). This class is used to move conversation data between
- * the various activities.
- */
-public class ConversationInfo implements Parcelable {
- private final long mConversationId;
- private final long mLocalMessageId; // can be 0
- private final long mServerMessageId; // can be 0
- private long mMaxMessageId;
-
- // This defines an invalid conversation ID. Nobody should rely on its specific value.
- private static final long INVALID_CONVERSATION_ID = -1;
-
- /**
- * Mapping from name of folder to the Folder object. This is the list of all the folders that
- * this conversation belongs to.
- */
- private final Map<String, Folder> mFolders;
-
- // TODO(viki) Get rid of this, and all references and side-effects should be changed
- // to something sane: like a boolean value indicating correctness.
- static final ConversationInfo INVALID_CONVERSATION_INFO =
- new ConversationInfo(INVALID_CONVERSATION_ID);
-
- /**
- * A matcher for data URI's that specify a conversation.
- */
- static final UriMatcher sUrlMatcher = new UriMatcher(UriMatcher.NO_MATCH);
- static {
- sUrlMatcher.addURI(UIProvider.AUTHORITY,
- "account/*/label/*/conversationId/*/maxServerMessageId/*/labels/*", 0);
- }
-
- public ConversationInfo(long conversationId, long localMessageId, long serverMessageId,
- long maxMessageId, Map<String, Folder> folders) {
- mConversationId = conversationId;
- mLocalMessageId = localMessageId;
- mServerMessageId = serverMessageId;
- mMaxMessageId = maxMessageId;
- mFolders = folders;
- }
-
- public ConversationInfo(long conversationId, long maxMessageId, Map<String, Folder> folders) {
- this(conversationId, 0, 0, maxMessageId, folders);
- }
-
- public ConversationInfo(long conversationId) {
- mConversationId = conversationId;
- mServerMessageId = 0;
- mLocalMessageId = 0;
- mMaxMessageId = 0;
- mFolders = Collections.emptyMap();
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeLong(mConversationId);
- dest.writeLong(mLocalMessageId);
- dest.writeLong(mServerMessageId);
- dest.writeLong(mMaxMessageId);
- synchronized (this){
- dest.writeString(Folder.serialize(mFolders));
- }
- }
-
- /**
- * Held together with hope and dreams. Write tests to verify this behavior.
- */
- public static final Parcelable.Creator<ConversationInfo> CREATOR =
- new Parcelable.Creator<ConversationInfo>() {
- @Override
- public ConversationInfo createFromParcel(Parcel source) {
- long conversationId = source.readLong();
- long localMessageId = source.readLong();
- long serverMessageId = source.readLong();
- long maxMessageId = source.readLong();
- return new ConversationInfo(
- conversationId,
- localMessageId,
- serverMessageId,
- maxMessageId,
- Folder.parseFoldersFromString(source.readString()));
- }
-
- @Override
- public ConversationInfo[] newArray(int size) {
- return new ConversationInfo[size];
- }
- };
-
- public long getConversationId() {
- return mConversationId;
- }
-
- public long getLocalMessageId() {
- return mLocalMessageId;
- }
-
- public long getServerMessageId() {
- return mServerMessageId;
- }
-
- public long getMaxMessageId() {
- return mMaxMessageId;
- }
-
- @Override
- public boolean equals(Object o) {
- synchronized(this) {
- if (o == this) {
- return true;
- }
-
- if ((o == null) || (o.getClass() != this.getClass())) {
- return false;
- }
-
- // TODO(viki): Confirm that keySet() is the correct thing to use here. Two folders
- // with the same keys should be equal, irrespective of order.
- ConversationInfo other = (ConversationInfo) o;
- return mConversationId == other.mConversationId
- && mLocalMessageId == other.mLocalMessageId
- && mServerMessageId == other.mServerMessageId
- && mMaxMessageId == other.mMaxMessageId
- && mFolders.keySet().equals(other.mFolders.keySet());
- }
- }
-
- @Override
- public int hashCode() {
- synchronized(this) {
- return Objects.hashCode(mConversationId, mLocalMessageId, mServerMessageId,
- mMaxMessageId, mFolders.keySet());
- }
- }
-
- /**
- * Updates the max server message ID of the conversation, when new messages have arrived.
- */
- public void updateMaxMessageId(long maxMessageId) {
- mMaxMessageId = maxMessageId;
- }
-
- /**
- * @return empty Map if the folders are null, nonempty copy of Folders otherwise
- */
- public Map<String, Folder> getFolders() {
- // If we have an empty folder map, return an empty folder map rather than returning null.
- if (mFolders == null){
- return Collections.emptyMap();
- }
- return ImmutableMap.copyOf(mFolders);
- }
-
- /**
- * Update a conversation info to add this folder to the update.
- * @param folders
- * @param added
- */
- public void updateFolder(Folder folders, boolean added) {
- if (added){
- mFolders.put(folders.name, folders);
- } else {
- mFolders.remove(folders.name);
- }
- }
-}
diff --git a/src/com/android/mail/UndoOperation.java b/src/com/android/mail/UndoOperation.java
index 10f6ba554..b189f510c 100644
--- a/src/com/android/mail/UndoOperation.java
+++ b/src/com/android/mail/UndoOperation.java
@@ -18,6 +18,8 @@ package com.android.mail;
import android.os.Bundle;
+import com.android.mail.providers.Conversation;
+
import java.util.Collection;
/**
@@ -28,11 +30,11 @@ public class UndoOperation {
private static final String DESCRIPTION = "undo-description";
private static final String CONVERSATIONS = "undo-conversations";
- public Collection<ConversationInfo> mConversations;
+ public Collection<Conversation> mConversations;
public String mDescription;
public String mAccount;
- public UndoOperation(String account, Collection<ConversationInfo> conversations,
+ public UndoOperation(String account, Collection<Conversation> conversations,
String description) {
this(account, conversations, description, true /* undoAction */);
}
@@ -47,7 +49,7 @@ public class UndoOperation {
* in order to perform the action. This is only false when un-marshaling a
* previously existing UndoOperation
*/
- private UndoOperation(String account, Collection<ConversationInfo> conversations,
+ private UndoOperation(String account, Collection<Conversation> conversations,
String description, boolean undoAction) {
mAccount = account;
mConversations = conversations;
@@ -61,6 +63,6 @@ public class UndoOperation {
extras.putString(ACCOUNT, mAccount);
extras.putString(DESCRIPTION, mDescription);
extras.putParcelableArray(CONVERSATIONS,
- mConversations.toArray(new ConversationInfo[mConversations.size()]));
+ mConversations.toArray(new Conversation[mConversations.size()]));
}
}
diff --git a/src/com/android/mail/UnifiedEmail.java b/src/com/android/mail/UnifiedEmail.java
index e25155e59..c44e66d99 100644
--- a/src/com/android/mail/UnifiedEmail.java
+++ b/src/com/android/mail/UnifiedEmail.java
@@ -18,7 +18,6 @@
package com.android.mail;
import com.android.mail.browse.ConversationListActivity;
-import com.android.mail.browse.FolderItem;
import com.android.mail.compose.ComposeActivity;
import com.android.mail.ui.ActionbarActivity;
import com.android.mail.ui.MailActivity;
@@ -42,10 +41,6 @@ public class UnifiedEmail extends Activity {
setContentView(R.layout.layout_tests);
}
- public void labelSpinnerTest(View v){
- startActivityWithClass(FolderItem.class);
- }
-
public void accountSpinnerTest(View v){
startActivityWithClass(ComposeActivity.class);
}
diff --git a/src/com/android/mail/browse/ConversationCursor.java b/src/com/android/mail/browse/ConversationCursor.java
index 65c5bd220..28323044e 100644
--- a/src/com/android/mail/browse/ConversationCursor.java
+++ b/src/com/android/mail/browse/ConversationCursor.java
@@ -17,19 +17,29 @@
package com.android.mail.browse;
+import android.app.Activity;
import android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentValues;
-import android.content.Context;
+import android.content.OperationApplicationException;
+import android.database.CharArrayBuffer;
import android.database.ContentObserver;
import android.database.Cursor;
-import android.database.CursorWrapper;
+import android.database.DataSetObserver;
import android.net.Uri;
+import android.os.Bundle;
import android.os.Handler;
+import android.os.Looper;
+import android.os.RemoteException;
import android.util.Log;
+import com.android.mail.providers.Conversation;
+
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.Iterator;
import java.util.List;
/**
@@ -37,7 +47,7 @@ import java.util.List;
* caching for quick UI response. This is effectively a singleton class, as the cache is
* implemented as a static HashMap.
*/
-public class ConversationCursor extends CursorWrapper {
+public final class ConversationCursor implements Cursor {
private static final String TAG = "ConversationCursor";
private static final boolean DEBUG = true; // STOPSHIP Set to false before shipping
@@ -45,10 +55,20 @@ public class ConversationCursor extends CursorWrapper {
// This string must match the declaration in AndroidManifest.xml
private static final String sAuthority = "com.android.mail.conversation.provider";
+ // The cursor instantiator's activity
+ private static Activity sActivity;
+ // The cursor underlying the caching cursor
+ private static Cursor sUnderlyingCursor;
+ // The new cursor obtained via a requery
+ private static Cursor sRequeryCursor;
// A mapping from Uri to updated ContentValues
private static HashMap<String, ContentValues> sCacheMap = new HashMap<String, ContentValues>();
+ // Cache map lock (will be used only very briefly - few ms at most)
+ private static Object sCacheMapLock = new Object();
// A deleted row is indicated by the presence of DELETED_COLUMN in the cache map
private static final String DELETED_COLUMN = "__deleted__";
+ // An row cached during a requery is indicated by the presence of REQUERY_COLUMN in the map
+ private static final String REQUERY_COLUMN = "__requery__";
// A sentinel value for the "index" of the deleted column; it's an int that is otherwise invalid
private static final int DELETED_COLUMN_INDEX = -1;
// The current conversation cursor
@@ -58,9 +78,11 @@ public class ConversationCursor extends CursorWrapper {
private static int sUriColumnIndex;
// The listener registered for this cursor
private static ConversationListener sListener;
+ // The ConversationProvider instance
+ private static ConversationProvider sProvider;
+ // Set when we're in the middle of a requery of the underlying cursor
+ private static boolean sRequeryInProgress = false;
- // The cursor underlying the caching cursor
- private final Cursor mUnderlying;
// Column names for this cursor
private final String[] mColumnNames;
// The resolver for the cursor instantiator's context
@@ -75,33 +97,120 @@ public class ConversationCursor extends CursorWrapper {
// The number of cached deletions from this cursor (used to quickly generate an accurate count)
private static int sDeletedCount = 0;
- public ConversationCursor(Cursor cursor, Context context, String messageListColumn) {
- super(cursor);
+ // Parameters passed to the underlying query
+ private static Uri qUri;
+ private static String[] qProjection;
+ private static String qSelection;
+ private static String[] qSelectionArgs;
+ private static String qSortOrder;
+
+ private ConversationCursor(Cursor cursor, Activity activity, String messageListColumn) {
+ sActivity = activity;
+ mResolver = activity.getContentResolver();
sConversationCursor = this;
- mUnderlying = cursor;
+ sUnderlyingCursor = cursor;
mCursorObserver = new CursorObserver();
- // New cursor -> clear the cache
- resetCache();
+ resetCursor(null);
mColumnNames = cursor.getColumnNames();
- sUriColumnIndex = getColumnIndex(messageListColumn);
+ sUriColumnIndex = cursor.getColumnIndex(messageListColumn);
if (sUriColumnIndex < 0) {
throw new IllegalArgumentException("Cursor must include a message list column");
}
- mResolver = context.getContentResolver();
}
/**
- * Reset the cache; this involves clearing out our cache map and resetting our various counts
- * The cache should be reset whenever we get fresh data from the underlying cursor
+ * Create a ConversationCursor; this should be called by the ListActivity using that cursor
+ * @param activity the activity creating the cursor
+ * @param messageListColumn the column used for individual cursor items
+ * @param uri the query uri
+ * @param projection the query projecion
+ * @param selection the query selection
+ * @param selectionArgs the query selection args
+ * @param sortOrder the query sort order
+ * @return a ConversationCursor
*/
- private void resetCache() {
- sCacheMap.clear();
- sDeletedCount = 0;
- mPosition = -1;
- if (!mCursorObserverRegistered) {
- mUnderlying.registerContentObserver(mCursorObserver);
- mCursorObserverRegistered = true;
+ public static ConversationCursor create(Activity activity, String messageListColumn, Uri uri,
+ String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ qUri = uri;
+ qProjection = projection;
+ qSelection = selection;
+ qSelectionArgs = selectionArgs;
+ qSortOrder = sortOrder;
+ Cursor cursor = activity.getContentResolver().query(uri, projection, selection,
+ selectionArgs, sortOrder);
+ return new ConversationCursor(cursor, activity, messageListColumn);
+ }
+
+ /**
+ * Return whether the uri string (message list uri) is in the underlying cursor
+ * @param uriString the uri string we're looking for
+ * @return true if the uri string is in the cursor; false otherwise
+ */
+ private boolean isInUnderlyingCursor(String uriString) {
+ sUnderlyingCursor.moveToPosition(-1);
+ while (sUnderlyingCursor.moveToNext()) {
+ if (uriString.equals(sUnderlyingCursor.getString(sUriColumnIndex))) {
+ return true;
+ }
}
+ return false;
+ }
+
+ /**
+ * Reset the cursor; this involves clearing out our cache map and resetting our various counts
+ * The cursor should be reset whenever we get fresh data from the underlying cursor. The cache
+ * is locked during the reset, which will block the UI, but for only a very short time
+ * (estimated at a few ms, but we can profile this; remember that the cache will usually
+ * be empty or have a few entries)
+ */
+ private void resetCursor(Cursor newCursor) {
+ // Temporary, log time for reset
+ long startTime = System.currentTimeMillis();
+ synchronized (sCacheMapLock) {
+ // Walk through the cache. Here are the cases:
+ // 1) The entry isn't marked with REQUERY - remove it from the cache. If DELETED is
+ // set, decrement the deleted count
+ // 2) The REQUERY entry is still in the UP
+ // 2a) The REQUERY entry isn't DELETED; we're good, and the client change will remain
+ // (i.e. client wins, it's on its way to the UP)
+ // 2b) The REQUERY entry is DELETED; we're good (client change remains, it's on
+ // its way to the UP)
+ // 3) the REQUERY was deleted on the server (sheesh; this would be bizarre timing!) -
+ // we need to throw the item out of the cache
+ // So ... the only interesting case is #3, we need to look for remaining deleted items
+ // and see if they're still in the UP
+ Iterator<HashMap.Entry<String, ContentValues>> iter = sCacheMap.entrySet().iterator();
+ while (iter.hasNext()) {
+ HashMap.Entry<String, ContentValues> entry = iter.next();
+ ContentValues values = entry.getValue();
+ if (values.containsKey(REQUERY_COLUMN) && isInUnderlyingCursor(entry.getKey())) {
+ // If we're in a requery and we're still around, remove the requery key
+ // We're good here, the cached change (delete/update) is on its way to UP
+ values.remove(REQUERY_COLUMN);
+ } else {
+ // Keep the deleted count up-to-date; remove the cache entry
+ if (values.containsKey(DELETED_COLUMN)) {
+ sDeletedCount--;
+ }
+ // Remove the entry
+ iter.remove();
+ }
+ }
+
+ // Swap cursor
+ if (newCursor != null) {
+ sUnderlyingCursor.close();
+ sUnderlyingCursor = newCursor;
+ }
+
+ mPosition = -1;
+ sUnderlyingCursor.moveToPosition(mPosition);
+ if (!mCursorObserverRegistered) {
+ sUnderlyingCursor.registerContentObserver(mCursorObserver);
+ mCursorObserverRegistered = true;
+ }
+ }
+ Log.d(TAG, "resetCache time: " + ((System.currentTimeMillis() - startTime)) + "ms");
}
/**
@@ -140,81 +249,56 @@ public class ConversationCursor extends CursorWrapper {
}
/**
- * Given a uri string (for the conversation), return its position in the cursor (0 based)
- * @param uriString the uri string to locate
- * @return the position of the row holding uriString, or -1 if not found
- */
- private static int getPositionFromUriString(String uriString) {
- sConversationCursor.moveBeforeFirst();
- int pos = 0;
- while (sConversationCursor.moveToNext()) {
- if (sConversationCursor.getUriString().equals(uriString)) {
- return pos;
- }
- pos++;
- }
- return -1;
- }
-
- /**
* Cache a column name/value pair for a given Uri
* @param uriString the Uri for which the column name/value pair applies
* @param columnName the column name
* @param value the value to be cached
*/
private static void cacheValue(String uriString, String columnName, Object value) {
- // Get the map for our uri
- ContentValues map = sCacheMap.get(uriString);
- // Create one if necessary
- if (map == null) {
- map = new ContentValues();
- sCacheMap.put(uriString, map);
- }
- // If we're caching a deletion, add to our count
- if ((columnName == DELETED_COLUMN) && (map.get(columnName) == null)) {
- sDeletedCount++;
- if (DEBUG) {
- Log.d(TAG, "Deleted " + uriString);
+ synchronized (sCacheMapLock) {
+ // Get the map for our uri
+ ContentValues map = sCacheMap.get(uriString);
+ // Create one if necessary
+ if (map == null) {
+ map = new ContentValues();
+ sCacheMap.put(uriString, map);
}
- // Tell the listener what we deleted
- if (sListener != null) {
- int pos = getPositionFromUriString(uriString);
- if (pos >= 0) {
- ArrayList<Integer> positions = new ArrayList<Integer>();
- positions.add(pos);
- sListener.onDeletedItems(positions);
+ // If we're caching a deletion, add to our count
+ if ((columnName == DELETED_COLUMN) && (map.get(columnName) == null)) {
+ sDeletedCount++;
+ if (DEBUG) {
+ Log.d(TAG, "Deleted " + uriString);
}
}
- }
- // ContentValues has no generic "put", so we must test. For now, the only classes of
- // values implemented are Boolean/Integer/String, though others are trivially added
- if (value instanceof Boolean) {
- map.put(columnName, ((Boolean)value).booleanValue() ? 1 : 0);
- } else if (value instanceof Integer) {
- map.put(columnName, (Integer)value);
- } else if (value instanceof String) {
- map.put(columnName, (String)value);
- } else {
- String cname = value.getClass().getName();
- throw new IllegalArgumentException("Value class not compatible with cache: " + cname);
- }
-
- if (DEBUG && (columnName != DELETED_COLUMN)) {
- Log.d(TAG, "Caching value for " + uriString + ": " + columnName);
+ // ContentValues has no generic "put", so we must test. For now, the only classes of
+ // values implemented are Boolean/Integer/String, though others are trivially added
+ if (value instanceof Boolean) {
+ map.put(columnName, ((Boolean) value).booleanValue() ? 1 : 0);
+ } else if (value instanceof Integer) {
+ map.put(columnName, (Integer) value);
+ } else if (value instanceof String) {
+ map.put(columnName, (String) value);
+ } else {
+ String cname = value.getClass().getName();
+ throw new IllegalArgumentException("Value class not compatible with cache: "
+ + cname);
+ }
+ if (sRequeryInProgress) {
+ map.put(REQUERY_COLUMN, 1);
+ }
+ if (DEBUG && (columnName != DELETED_COLUMN)) {
+ Log.d(TAG, "Caching value for " + uriString + ": " + columnName);
+ }
}
}
- private String getUriString() {
- return super.getString(sUriColumnIndex);
- }
-
/**
* Get the cached value for the provided column; we special case -1 as the "deleted" column
* @param columnIndex the index of the column whose cached value we want to retrieve
* @return the cached value for this column, or null if there is none
*/
private Object getCachedValue(int columnIndex) {
- String uri = super.getString(sUriColumnIndex);
+ String uri = sUnderlyingCursor.getString(sUriColumnIndex);
ContentValues uriMap = sCacheMap.get(uri);
if (uriMap != null) {
String columnName;
@@ -234,27 +318,54 @@ public class ConversationCursor extends CursorWrapper {
private void underlyingChanged() {
if (sListener != null) {
if (mCursorObserverRegistered) {
- mUnderlying.unregisterContentObserver(mCursorObserver);
+ sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
mCursorObserverRegistered = false;
}
- sListener.onNewSyncData();
+ sListener.onRefreshRequired();
}
}
+ public void swapCursors() {
+ if (sRequeryCursor == null) {
+ throw new IllegalStateException("Can't swap cursors; no requery done");
+ }
+ resetCursor(sRequeryCursor);
+ sRequeryCursor = null;
+ }
+
/**
- * When we get a requery from the UI, we'll do it, but also clear the cache
+ * When we get a requery from the UI, we'll do it, but also clear the cache. The listener is
+ * notified when the requery is complete
* NOTE: This will have to change, of course, when we start using loaders...
*/
- public boolean requery() {
- super.requery();
- resetCache();
+ public boolean refresh() {
+ if (sRequeryInProgress) {
+ return false;
+ }
+ // Say we're starting a requery
+ sRequeryInProgress = true;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ // Get new data
+ sRequeryCursor =
+ mResolver.query(qUri, qProjection, qSelection, qSelectionArgs, qSortOrder);
+ // Make sure window is full
+ sRequeryCursor.getCount();
+ sActivity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ sListener.onRefreshReady();
+ }});
+ }
+ }).start();
return true;
}
public void close() {
// Unregister our observer on the underlying cursor and close as usual
- mUnderlying.unregisterContentObserver(mCursorObserver);
- super.close();
+ sUnderlyingCursor.unregisterContentObserver(mCursorObserver);
+ sUnderlyingCursor.close();
}
/**
@@ -262,7 +373,7 @@ public class ConversationCursor extends CursorWrapper {
*/
public boolean moveToNext() {
while (true) {
- boolean ret = super.moveToNext();
+ boolean ret = sUnderlyingCursor.moveToNext();
if (!ret) return false;
if (getCachedValue(DELETED_COLUMN_INDEX) instanceof Integer) continue;
mPosition++;
@@ -275,7 +386,7 @@ public class ConversationCursor extends CursorWrapper {
*/
public boolean moveToPrevious() {
while (true) {
- boolean ret = super.moveToPrevious();
+ boolean ret = sUnderlyingCursor.moveToPrevious();
if (!ret) return false;
if (getCachedValue(-1) instanceof Integer) continue;
mPosition--;
@@ -291,15 +402,11 @@ public class ConversationCursor extends CursorWrapper {
* The actual cursor's count must be decremented by the number we've deleted from the UI
*/
public int getCount() {
- return super.getCount() - sDeletedCount;
- }
-
- private void moveBeforeFirst() {
- super.moveToPosition(-1);
+ return sUnderlyingCursor.getCount() - sDeletedCount;
}
public boolean moveToFirst() {
- super.moveToPosition(-1);
+ sUnderlyingCursor.moveToPosition(-1);
mPosition = -1;
return moveToNext();
}
@@ -341,35 +448,35 @@ public class ConversationCursor extends CursorWrapper {
public double getDouble(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Double)obj;
- return super.getDouble(columnIndex);
+ return sUnderlyingCursor.getDouble(columnIndex);
}
@Override
public float getFloat(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Float)obj;
- return super.getFloat(columnIndex);
+ return sUnderlyingCursor.getFloat(columnIndex);
}
@Override
public int getInt(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Integer)obj;
- return super.getInt(columnIndex);
+ return sUnderlyingCursor.getInt(columnIndex);
}
@Override
public long getLong(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Long)obj;
- return super.getLong(columnIndex);
+ return sUnderlyingCursor.getLong(columnIndex);
}
@Override
public short getShort(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (Short)obj;
- return super.getShort(columnIndex);
+ return sUnderlyingCursor.getShort(columnIndex);
}
@Override
@@ -377,19 +484,19 @@ public class ConversationCursor extends CursorWrapper {
// If we're asking for the Uri for the conversation list, we return a forwarding URI
// so that we can intercept update/delete and handle it ourselves
if (columnIndex == sUriColumnIndex) {
- Uri uri = Uri.parse(super.getString(columnIndex));
+ Uri uri = Uri.parse(sUnderlyingCursor.getString(columnIndex));
return uriToCachingUriString(uri);
}
Object obj = getCachedValue(columnIndex);
if (obj != null) return (String)obj;
- return super.getString(columnIndex);
+ return sUnderlyingCursor.getString(columnIndex);
}
@Override
public byte[] getBlob(int columnIndex) {
Object obj = getCachedValue(columnIndex);
if (obj != null) return (byte[])obj;
- return super.getBlob(columnIndex);
+ return sUnderlyingCursor.getBlob(columnIndex);
}
/**
@@ -403,7 +510,7 @@ public class ConversationCursor extends CursorWrapper {
@Override
public void onChange(boolean selfChange) {
// If we're here, then something outside of the UI has changed the data, and we
- // must requery to get that data from the underlying provider
+ // must query the underlying provider for that data
if (DEBUG) {
Log.d(TAG, "Underlying conversation cursor changed; requerying");
}
@@ -419,9 +526,12 @@ public class ConversationCursor extends CursorWrapper {
* will cause a redraw of the list with updated values.
*/
public static class ConversationProvider extends ContentProvider {
+ public static final String AUTHORITY = sAuthority;
+
@Override
public boolean onCreate() {
- return false;
+ sProvider = this;
+ return true;
}
@Override
@@ -432,6 +542,24 @@ public class ConversationCursor extends CursorWrapper {
}
@Override
+ public Uri insert(Uri uri, ContentValues values) {
+ insertLocal(uri, values);
+ return ProviderExecute.opInsert(uri, values);
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ updateLocal(uri, values, false);
+ return ProviderExecute.opUpdate(uri, values);
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ deleteLocal(uri, false);
+ return ProviderExecute.opDelete(uri);
+ }
+
+ @Override
public String getType(Uri uri) {
return null;
}
@@ -459,55 +587,216 @@ public class ConversationCursor extends CursorWrapper {
this(code, uri, null);
}
- static void opDelete(Uri uri) {
+ static Uri opInsert(Uri uri, ContentValues values) {
+ ProviderExecute e = new ProviderExecute(INSERT, uri, values);
+ if (offUiThread()) return (Uri)e.go();
+ new Thread(e).start();
+ return null;
+ }
+
+ static int opDelete(Uri uri) {
+ ProviderExecute e = new ProviderExecute(DELETE, uri);
+ if (offUiThread()) return (Integer)e.go();
new Thread(new ProviderExecute(DELETE, uri)).start();
+ return 0;
}
- static void opUpdate(Uri uri, ContentValues values) {
- new Thread(new ProviderExecute(UPDATE, uri, values)).start();
+ static int opUpdate(Uri uri, ContentValues values) {
+ ProviderExecute e = new ProviderExecute(UPDATE, uri, values);
+ if (offUiThread()) return (Integer)e.go();
+ new Thread(e).start();
+ return 0;
}
@Override
public void run() {
+ go();
+ }
+
+ public Object go() {
switch(mCode) {
case DELETE:
- mResolver.delete(mUri, null, null);
- break;
+ return mResolver.delete(mUri, null, null);
case INSERT:
- mResolver.insert(mUri, mValues);
- break;
+ return mResolver.insert(mUri, mValues);
case UPDATE:
- mResolver.update(mUri, mValues, null, null);
- break;
+ return mResolver.update(mUri, mValues, null, null);
+ default:
+ return null;
}
}
}
- // Synchronous for now; we'll revisit all this in a later design review
- @Override
- public Uri insert(Uri uri, ContentValues values) {
- return mResolver.insert(uri, values);
+ private void insertLocal(Uri uri, ContentValues values) {
+ // Placeholder for now; there's no local insert
}
- @Override
- public int delete(Uri uri, String selection, String[] selectionArgs) {
+ private void deleteLocal(Uri uri, boolean batch) {
Uri underlyingUri = uriFromCachingUri(uri);
String uriString = underlyingUri.toString();
cacheValue(uriString, DELETED_COLUMN, true);
- ProviderExecute.opDelete(uri);
- return 0;
+ if (!batch && sListener != null) {
+ ArrayList<Integer> positions = getPositionsFromUriString(uriString);
+ if (positions != null) {
+ sListener.onDeletedItems(positions);
+ }
+ }
}
- @Override
- public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ private void updateLocal(Uri uri, ContentValues values, boolean batch) {
Uri underlyingUri = uriFromCachingUri(uri);
// Remember to decode the underlying Uri as it might be encoded (as w/ Gmail)
String uriString = Uri.decode(underlyingUri.toString());
for (String columnName: values.keySet()) {
cacheValue(uriString, columnName, values.get(columnName));
}
- ProviderExecute.opUpdate(uri, values);
- return 0;
+ if (!batch && sListener != null) {
+ ArrayList<Integer> positions = getPositionsFromUriString(uriString);
+ if (positions != null) {
+ sListener.onUpdatedItems(positions);
+ }
+ }
+ }
+
+ /**
+ * Given a uri string (for the conversation), return its position in the cursor (0 based)
+ * @param uriString the uri string to locate
+ * @return the position of the row holding uriString, or -1 if not found
+ */
+ private static int getPositionFromUriString(String uriString) {
+ sUnderlyingCursor.moveToPosition(-1);
+ int pos = 0;
+ while (sConversationCursor.moveToNext()) {
+ if (sUnderlyingCursor.getString(sUriColumnIndex).equals(uriString)) {
+ return pos;
+ }
+ pos++;
+ }
+ return -1;
+ }
+
+ private static ArrayList<Integer> getPositionsFromUriString(String uriString) {
+ int pos = getPositionFromUriString(uriString);
+ if (pos >= 0) {
+ ArrayList<Integer> positions = new ArrayList<Integer>();
+ positions.add(pos);
+ return positions;
+ } else {
+ return null;
+ }
+ }
+
+ static boolean offUiThread() {
+ return Looper.getMainLooper().getThread() != Thread.currentThread();
+ }
+
+ public ContentProviderResult[] apply(ArrayList<ConversationOperation> ops) {
+ final HashMap<String, ArrayList<ContentProviderOperation>> batchMap =
+ new HashMap<String, ArrayList<ContentProviderOperation>>();
+ final ArrayList<Integer> deletePositions = new ArrayList<Integer>();
+ final ArrayList<Integer> updatePositions = new ArrayList<Integer>();
+
+ // Execute locally and build CPO's for underlying provider
+ for (ConversationOperation op: ops) {
+ Uri underlyingUri = uriFromCachingUri(op.mUri);
+ String authority = underlyingUri.getAuthority();
+ ArrayList<ContentProviderOperation> authOps = batchMap.get(authority);
+ if (authOps == null) {
+ authOps = new ArrayList<ContentProviderOperation>();
+ batchMap.put(authority, authOps);
+ }
+ authOps.add(op.execute(underlyingUri));
+ int position = op.mPosition;
+ if (position != Conversation.NO_POSITION) {
+ if (op.mType == ConversationOperation.DELETE) {
+ deletePositions.add(position);
+ } else if (op.mType == ConversationOperation.UPDATE) {
+ updatePositions.add(position);
+ }
+ }
+ }
+
+ // Send out notifications for what we've done
+ if (sListener != null) {
+ if (!deletePositions.isEmpty()) {
+ sListener.onDeletedItems(deletePositions);
+ }
+ if (!updatePositions.isEmpty()) {
+ sListener.onUpdatedItems(updatePositions);
+ }
+ }
+
+ // Send changes to underlying provider
+ for (String authority: batchMap.keySet()) {
+ try {
+ if (offUiThread()) {
+ return mResolver.applyBatch(authority, batchMap.get(authority));
+ } else {
+ final String auth = authority;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ mResolver.applyBatch(auth, batchMap.get(auth));
+ } catch (RemoteException e) {
+ } catch (OperationApplicationException e) {
+ }
+ }
+ }).start();
+ return new ContentProviderResult[ops.size()];
+ }
+ } catch (RemoteException e) {
+ } catch (OperationApplicationException e) {
+ }
+ }
+ // Need to put together the results; ugh, in order
+ return null;
+ }
+ }
+
+ /**
+ * ConversationOperation is the encapsulation of a ContentProvider operation to be performed
+ * atomically as part of a "batch" operation.
+ */
+ public static class ConversationOperation {
+ public static final int DELETE = 0;
+ public static final int INSERT = 1;
+ public static final int UPDATE = 2;
+
+ private final int mType;
+ private final Uri mUri;
+ private final ContentValues mValues;
+ private final int mPosition;
+
+ public ConversationOperation(int type, Conversation conv) {
+ this(type, conv, null);
+ }
+
+ public ConversationOperation(int type, Conversation conv, ContentValues values) {
+ mType = type;
+ mUri = conv.messageListUri;
+ mValues = values;
+ mPosition = conv.position;
+ }
+
+ private ContentProviderOperation execute(Uri underlyingUri) {
+ switch(mType) {
+ case DELETE:
+ sProvider.deleteLocal(mUri, true);
+ return ContentProviderOperation.newDelete(underlyingUri).build();
+ case UPDATE:
+ sProvider.updateLocal(mUri, mValues, true);
+ return ContentProviderOperation.newUpdate(underlyingUri)
+ .withValues(mValues)
+ .build();
+ case INSERT:
+ sProvider.insertLocal(mUri, mValues);
+ return ContentProviderOperation.newInsert(underlyingUri)
+ .withValues(mValues).build();
+ default:
+ throw new UnsupportedOperationException(
+ "No such ConversationOperation type: " + mType);
+ }
}
}
@@ -518,7 +807,122 @@ public class ConversationCursor extends CursorWrapper {
public interface ConversationListener {
// The UI has deleted items at the positions referenced in the array
public void onDeletedItems(ArrayList<Integer> positions);
- // We've received new data from a sync (i.e. outside the UI)
- public void onNewSyncData();
+ // The UI has updated items at the positions referenced in the array
+ public void onUpdatedItems(ArrayList<Integer> positions);
+ // Data in the underlying provider has changed; a refresh is required to sync up
+ public void onRefreshRequired();
+ // We've completed a requested refresh of the underlying cursor
+ public void onRefreshReady();
+ }
+
+ @Override
+ public boolean isFirst() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isLast() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isBeforeFirst() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isAfterLast() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getColumnIndex(String columnName) {
+ return sUnderlyingCursor.getColumnIndex(columnName);
+ }
+
+ @Override
+ public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
+ return sUnderlyingCursor.getColumnIndexOrThrow(columnName);
+ }
+
+ @Override
+ public String getColumnName(int columnIndex) {
+ return sUnderlyingCursor.getColumnName(columnIndex);
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ return sUnderlyingCursor.getColumnNames();
+ }
+
+ @Override
+ public int getColumnCount() {
+ return sUnderlyingCursor.getColumnCount();
+ }
+
+ @Override
+ public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getType(int columnIndex) {
+ return sUnderlyingCursor.getType(columnIndex);
+ }
+
+ @Override
+ public boolean isNull(int columnIndex) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void deactivate() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isClosed() {
+ return sUnderlyingCursor.isClosed();
+ }
+
+ @Override
+ public void registerContentObserver(ContentObserver observer) {
+ }
+
+ @Override
+ public void unregisterContentObserver(ContentObserver observer) {
+ }
+
+ @Override
+ public void registerDataSetObserver(DataSetObserver observer) {
+ }
+
+ @Override
+ public void unregisterDataSetObserver(DataSetObserver observer) {
+ }
+
+ @Override
+ public void setNotificationUri(ContentResolver cr, Uri uri) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean getWantsAllOnMoveCalls() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Bundle getExtras() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Bundle respond(Bundle extras) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean requery() {
+ return true;
}
}
diff --git a/src/com/android/mail/browse/ConversationItemView.java b/src/com/android/mail/browse/ConversationItemView.java
index 380d4d7a7..256b349e8 100644
--- a/src/com/android/mail/browse/ConversationItemView.java
+++ b/src/com/android/mail/browse/ConversationItemView.java
@@ -45,6 +45,7 @@ import android.text.util.Rfc822Tokenizer;
import android.util.SparseArray;
import android.view.MotionEvent;
import android.view.View;
+import android.widget.ListView;
import com.android.mail.R;
import com.android.mail.browse.ConversationItemViewModel.SenderFragment;
@@ -306,7 +307,7 @@ public class ConversationItemView extends View {
mHeader.fontColor = fontColor;
}
- boolean isUnread = true;
+ boolean isUnread = mHeader.unread;
final boolean checkboxEnabled = true;
if (mHeader.checkboxVisible != checkboxEnabled) {
@@ -648,7 +649,7 @@ public class ConversationItemView extends View {
// Senders.
sPaint.setTextSize(mCoordinates.sendersFontSize);
sPaint.setTypeface(Typeface.DEFAULT);
- boolean isUnread = true;
+ boolean isUnread = mHeader.unread;
int sendersColor = getFontColor(isUnread ? SENDERS_TEXT_COLOR_UNREAD
: SENDERS_TEXT_COLOR_READ);
sPaint.setColor(sendersColor);
@@ -774,7 +775,11 @@ public class ConversationItemView extends View {
*/
public void toggleCheckMark() {
mChecked = !mChecked;
- mSelectedConversationSet.toggle(mHeader.conversation);
+ Conversation conv = mHeader.conversation;
+ // Set the list position of this item in the conversation
+ conv.position = mChecked ? ((ListView)getParent()).getPositionForView(this)
+ : Conversation.NO_POSITION;
+ mSelectedConversationSet.toggle(conv);
// We update the background after the checked state has changed now that
// we have a selected background asset. Setting the background usually
// waits for a layout pass, but we don't need a full layout, just an
diff --git a/src/com/android/mail/browse/ConversationItemViewModel.java b/src/com/android/mail/browse/ConversationItemViewModel.java
index 8829297c1..1701e40f0 100644
--- a/src/com/android/mail/browse/ConversationItemViewModel.java
+++ b/src/com/android/mail/browse/ConversationItemViewModel.java
@@ -57,6 +57,8 @@ public class ConversationItemViewModel {
// Star
boolean starred;
+ // Unread
+ boolean unread;
Bitmap starBitmap;
@@ -123,6 +125,7 @@ public class ConversationItemViewModel {
Conversation conv = Conversation.from(cursor);
header.conversation = conv;
header.starred = conv.starred;
+ header.unread = !conv.read;
}
return header;
}
diff --git a/src/com/android/mail/browse/ConversationListActivity.java b/src/com/android/mail/browse/ConversationListActivity.java
index 1dd00ac92..ec5b123ae 100644
--- a/src/com/android/mail/browse/ConversationListActivity.java
+++ b/src/com/android/mail/browse/ConversationListActivity.java
@@ -23,6 +23,7 @@ import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
+import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -30,7 +31,6 @@ import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
-import android.widget.CursorAdapter;
import android.widget.ListView;
import android.widget.SimpleCursorAdapter;
import android.widget.Spinner;
@@ -46,6 +46,7 @@ import com.android.mail.providers.Account;
import com.android.mail.providers.AccountCacheProvider;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.UIProvider;
+import com.android.mail.providers.UIProvider.ConversationColumns;
import com.android.mail.ui.ConversationSelectionSet;
import com.android.mail.ui.ConversationSetObserver;
import com.android.mail.ui.ViewMode;
@@ -57,6 +58,7 @@ public class ConversationListActivity extends Activity implements OnItemSelected
private ListView mListView;
private ConversationItemAdapter mListAdapter;
+ private ConversationCursor mConversationListCursor;
private Spinner mAccountsSpinner;
private AccountsSpinnerAdapter mAccountsAdapter;
private ContentResolver mResolver;
@@ -136,11 +138,7 @@ public class ConversationListActivity extends Activity implements OnItemSelected
public ConversationItemAdapter(Context context, int textViewResourceId,
ConversationCursor cursor) {
- // Set requery/observer flags temporarily; we will be using loaders eventually so
- // this is just a temporary hack to demonstrate push, etc.
- super(context, textViewResourceId, cursor, UIProvider.CONVERSATION_PROJECTION, null,
- CursorAdapter.FLAG_AUTO_REQUERY | CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER);
- // UpdateCachingCursor needs to know about the adapter
+ super(context, textViewResourceId, cursor, UIProvider.CONVERSATION_PROJECTION, null, 0);
}
@Override
@@ -182,14 +180,13 @@ public class ConversationListActivity extends Activity implements OnItemSelected
throw new IllegalStateException("No conversation list for this account");
}
// Create the cursor for the list using the update cache
- ConversationCursor conversationListCursor =
- new ConversationCursor(
- mResolver.query(conversationListUri, UIProvider.CONVERSATION_PROJECTION, null,
- null, null), this, UIProvider.ConversationColumns.MESSAGE_LIST_URI);
+ mConversationListCursor =
+ ConversationCursor.create(this, UIProvider.ConversationColumns.MESSAGE_LIST_URI,
+ conversationListUri, UIProvider.CONVERSATION_PROJECTION, null, null, null);
mListAdapter = new ConversationItemAdapter(this, R.layout.conversation_item_view_normal,
- conversationListCursor);
+ mConversationListCursor);
mListView.setAdapter(mListAdapter);
- conversationListCursor.setListener(this);
+ mConversationListCursor.setListener(this);
}
@Override
@@ -200,6 +197,11 @@ public class ConversationListActivity extends Activity implements OnItemSelected
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Conversation conv = ((ConversationItemView) view).getConversation();
ConversationViewActivity.viewConversation(this, conv, mSelectedAccount);
+ // Quick and dirty flag change
+ if (!conv.read) {
+ conv.read = true;
+ conv.updateBoolean(this, ConversationColumns.READ, true);
+ }
}
@Override
@@ -226,11 +228,45 @@ public class ConversationListActivity extends Activity implements OnItemSelected
// For now, redraw the list
// Trigger of delete animation code would go here...
mListAdapter.notifyDataSetChanged();
+ // Temporary logging
+ Log.d("Deleted", "" + positions);
}
@Override
- public void onNewSyncData() {
- // Refresh the query and redraw
- mListAdapter.getCursor().requery();
+ public void onUpdatedItems(ArrayList<Integer> positions) {
+ // For now, redraw the list
+ // Could just update individual views, if desired
+ mListAdapter.notifyDataSetChanged();
+ // Temporary logging
+ Log.d("Updated", "" + positions);
+ }
+
+ // Underlying provider updates, etc.
+
+ /**
+ * Called when there is new data at the underlying provider
+ * refresh() here causes the new data to be retrieved asynchronously
+ * NOTE: The UI needn't take any action immediately (i.e. it might wait until a more
+ * convenient time to get the update from the provider)
+ */
+ @Override
+ public void onRefreshRequired() {
+ // Refresh the query in the background
+ mConversationListCursor.refresh();
+ }
+
+ /**
+ * Called when new data from the underlying provider is ready for use
+ * swapCursors() causes the cursor to reflect the refreshed data
+ * notifyDataSetChanged() causes the list to redraw
+ * NOTE: The UI needn't take any action immediately if it's otherwise engaged (animating, for
+ * example)
+ */
+ @Override
+ public void onRefreshReady() {
+ // Swap cursors
+ mConversationListCursor.swapCursors();
+ // Redraw with new data
+ mListAdapter.notifyDataSetChanged();
}
}
diff --git a/src/com/android/mail/browse/FolderItem.java b/src/com/android/mail/browse/FolderItem.java
deleted file mode 100644
index b8b7e5b96..000000000
--- a/src/com/android/mail/browse/FolderItem.java
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * 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.browse;
-
-import android.app.Activity;
-import android.os.Bundle;
-import com.android.mail.R;
-
-public class FolderItem extends Activity {
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.label_switch_spinner_dropdown_item);
- }
-
-}
diff --git a/src/com/android/mail/browse/SelectedConversationsActionMenu.java b/src/com/android/mail/browse/SelectedConversationsActionMenu.java
index d59312496..d0a5ce079 100644
--- a/src/com/android/mail/browse/SelectedConversationsActionMenu.java
+++ b/src/com/android/mail/browse/SelectedConversationsActionMenu.java
@@ -19,12 +19,14 @@ package com.android.mail.browse;
import com.android.mail.R;
import com.android.mail.providers.Conversation;
+import com.android.mail.providers.UIProvider.ConversationColumns;
import com.android.mail.ui.ConversationSelectionSet;
import com.android.mail.ui.ConversationSetObserver;
import com.android.mail.utils.LogUtils;
import com.google.common.annotations.VisibleForTesting;
import java.util.Collection;
+import java.util.Iterator;
import android.app.Activity;
import android.content.Context;
@@ -67,11 +69,27 @@ public class SelectedConversationsActionMenu implements ActionMode.Callback,
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
boolean handled = true;
+ Collection<Conversation> conversations = mSelectionSet.values();
switch (item.getItemId()) {
case R.id.delete:
- Collection<Conversation> conversations = mSelectionSet.values();
- for (Conversation conv : conversations) {
- conv.delete(mActivity);
+ Conversation.delete(mActivity, conversations);
+ mSelectionSet.clear();
+ break;
+ case R.id.read_unread:
+ Iterator<Conversation> it = conversations.iterator();
+ if (it.hasNext()) {
+ Conversation conv = (Conversation)it.next();
+ // Is this right? I'm guessing they must all be in the same state
+ boolean read = conv.read;
+ Conversation.updateBoolean(mActivity, conversations, ConversationColumns.READ,
+ !read);
+ }
+ mSelectionSet.clear();
+ break;
+ case R.id.star:
+ if (conversations.size() > 0) {
+ Conversation.updateBoolean(mActivity, conversations,
+ ConversationColumns.STARRED, true);
}
mSelectionSet.clear();
break;
diff --git a/src/com/android/mail/providers/Conversation.java b/src/com/android/mail/providers/Conversation.java
index 285498149..bac120926 100644
--- a/src/com/android/mail/providers/Conversation.java
+++ b/src/com/android/mail/providers/Conversation.java
@@ -16,6 +16,7 @@
package com.android.mail.providers;
+import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@@ -23,7 +24,14 @@ import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
+import com.android.mail.browse.ConversationCursor.ConversationOperation;
+import com.android.mail.browse.ConversationCursor.ConversationProvider;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
public class Conversation implements Parcelable {
+ public static final int NO_POSITION = -1;
public long id;
public String subject;
@@ -38,6 +46,7 @@ public class Conversation implements Parcelable {
public int priority;
public boolean read;
public boolean starred;
+ public transient int position;
@Override
public int describeContents() {
@@ -75,6 +84,7 @@ public class Conversation implements Parcelable {
priority = in.readInt();
read = (in.readByte() != 0);
starred = (in.readByte() != 0);
+ position = NO_POSITION;
}
@Override
@@ -105,6 +115,10 @@ public class Conversation implements Parcelable {
id = cursor.getLong(UIProvider.CONVERSATION_ID_COLUMN);
dateMs = cursor.getLong(UIProvider.CONVERSATION_DATE_RECEIVED_MS_COLUMN);
subject = cursor.getString(UIProvider.CONVERSATION_SUBJECT_COLUMN);
+ // Don't allow null subject
+ if (subject == null) {
+ subject = "";
+ }
snippet = cursor.getString(UIProvider.CONVERSATION_SNIPPET_COLUMN);
hasAttachments = cursor.getInt(UIProvider.CONVERSATION_HAS_ATTACHMENTS_COLUMN) == 1;
messageListUri = Uri.parse(cursor
@@ -116,20 +130,79 @@ public class Conversation implements Parcelable {
priority = cursor.getInt(UIProvider.CONVERSATION_PRIORITY_COLUMN);
read = cursor.getInt(UIProvider.CONVERSATION_READ_COLUMN) == 1;
starred = cursor.getInt(UIProvider.CONVERSATION_STARRED_COLUMN) == 1;
+ position = NO_POSITION;
}
}
// Below are methods that update Conversation data (update/delete)
+ /**
+ * Update a boolean column for a single conversation
+ * @param context the caller's context
+ * @param columnName the column to update
+ * @param value the new value
+ */
public void updateBoolean(Context context, String columnName, boolean value) {
- // For now, synchronous
ContentValues cv = new ContentValues();
cv.put(columnName, value);
context.getContentResolver().update(messageListUri, cv, null, null);
}
+ /**
+ * Update a boolean column for a group of conversations, immediately in the UI and in a single
+ * transaction in the underlying provider
+ * @param conversations a collection of conversations
+ * @param context the caller's context
+ * @param columnName the column to update
+ * @param value the new value
+ */
+ public static void updateBoolean(Context context, Collection<Conversation> conversations,
+ String columnName, boolean value) {
+ ContentValues cv = new ContentValues();
+ cv.put(columnName, value);
+ ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>();
+ for (Conversation conv: conversations) {
+ ConversationOperation op =
+ new ConversationOperation(ConversationOperation.UPDATE, conv, cv);
+ ops.add(op);
+ }
+ apply(context, ops);
+ }
+
+ /**
+ * Delete a single conversation
+ * @param context the caller's context
+ */
public void delete(Context context) {
- // For now, synchronous
context.getContentResolver().delete(messageListUri, null, null);
}
+
+ /**
+ * Delete a group of conversations immediately in the UI and in a single transaction in the
+ * underlying provider
+ * @param context the caller's context
+ * @param conversations a collection of conversations
+ */
+ public static void delete(Context context, Collection<Conversation> conversations) {
+ ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>();
+ for (Conversation conv: conversations) {
+ ConversationOperation op =
+ new ConversationOperation(ConversationOperation.DELETE, conv);
+ ops.add(op);
+ }
+ apply(context, ops);
+ }
+
+ // Convenience methods
+ private static void apply(Context context, ArrayList<ConversationOperation> operations) {
+ ContentProviderClient client =
+ context.getContentResolver().acquireContentProviderClient(
+ ConversationProvider.AUTHORITY);
+ try {
+ ConversationProvider cp = (ConversationProvider)client.getLocalContentProvider();
+ cp.apply(operations);
+ } finally {
+ client.release();
+ }
+ }
}
diff --git a/src/com/android/mail/utils/LogUtils.java b/src/com/android/mail/utils/LogUtils.java
index 851324379..b0d8a3808 100644
--- a/src/com/android/mail/utils/LogUtils.java
+++ b/src/com/android/mail/utils/LogUtils.java
@@ -16,12 +16,18 @@
package com.android.mail.utils;
import android.net.Uri;
+import android.text.TextUtils;
import android.util.Log;
import com.google.common.annotations.VisibleForTesting;
import java.util.List;
+import java.util.regex.Pattern;
public class LogUtils {
+
+ // "GMT" + "+" or "-" + 4 digits
+ private static final Pattern DATE_CLEANUP_PATTERN_WRONG_TIMEZONE =
+ Pattern.compile("GMT([-+]\\d{4})$");
private static String LOG_TAG = "Email";
/**
@@ -378,4 +384,34 @@ public class LogUtils {
public static int wtf(String tag, Throwable tr, String format, Object... args) {
return Log.wtf(tag, String.format(format, args), tr);
}
+
+
+ /**
+ * Try to make a date MIME(RFC 2822/5322)-compliant.
+ *
+ * It fixes:
+ * - "Thu, 10 Dec 09 15:08:08 GMT-0700" to "Thu, 10 Dec 09 15:08:08 -0700"
+ * (4 digit zone value can't be preceded by "GMT")
+ * We got a report saying eBay sends a date in this format
+ */
+ public static String cleanUpMimeDate(String date) {
+ if (TextUtils.isEmpty(date)) {
+ return date;
+ }
+ date = DATE_CLEANUP_PATTERN_WRONG_TIMEZONE.matcher(date).replaceFirst("$1");
+ return date;
+ }
+
+
+ public static String byteToHex(int b) {
+ return byteToHex(new StringBuilder(), b).toString();
+ }
+
+ public static StringBuilder byteToHex(StringBuilder sb, int b) {
+ b &= 0xFF;
+ sb.append("0123456789ABCDEF".charAt(b >> 4));
+ sb.append("0123456789ABCDEF".charAt(b & 0xF));
+ return sb;
+ }
+
}
diff --git a/src/com/android/mail/utils/LoggingInputStream.java b/src/com/android/mail/utils/LoggingInputStream.java
index 55987f4ce..ac1841fbe 100644
--- a/src/com/android/mail/utils/LoggingInputStream.java
+++ b/src/com/android/mail/utils/LoggingInputStream.java
@@ -89,7 +89,7 @@ public class LoggingInputStream extends FilterInputStream {
} else {
// email protocols are supposed to be all 7bits, but there are wrong implementations
// that do send 8 bit characters...
- mSb.append("\\x" + Utils.byteToHex(oneByte));
+ mSb.append("\\x" + LogUtils.byteToHex(oneByte));
}
}
diff --git a/src/com/android/mail/utils/Utils.java b/src/com/android/mail/utils/Utils.java
index 871172a04..dd688767d 100644
--- a/src/com/android/mail/utils/Utils.java
+++ b/src/com/android/mail/utils/Utils.java
@@ -57,9 +57,6 @@ public class Utils {
SENDER_LIST_SEPARATOR);
public static String[] sSenderFragments = new String[8];
- // "GMT" + "+" or "-" + 4 digits
- private static final Pattern DATE_CLEANUP_PATTERN_WRONG_TIMEZONE =
- Pattern.compile("GMT([-+]\\d{4})$");
public static final String EXTRA_ACCOUNT = "account";
@@ -465,33 +462,6 @@ public class Utils {
}
/**
- * Try to make a date MIME(RFC 2822/5322)-compliant.
- *
- * It fixes:
- * - "Thu, 10 Dec 09 15:08:08 GMT-0700" to "Thu, 10 Dec 09 15:08:08 -0700"
- * (4 digit zone value can't be preceded by "GMT")
- * We got a report saying eBay sends a date in this format
- */
- public static String cleanUpMimeDate(String date) {
- if (TextUtils.isEmpty(date)) {
- return date;
- }
- date = DATE_CLEANUP_PATTERN_WRONG_TIMEZONE.matcher(date).replaceFirst("$1");
- return date;
- }
-
- public static String byteToHex(int b) {
- return byteToHex(new StringBuilder(), b).toString();
- }
-
- public static StringBuilder byteToHex(StringBuilder sb, int b) {
- b &= 0xFF;
- sb.append("0123456789ABCDEF".charAt(b >> 4));
- sb.append("0123456789ABCDEF".charAt(b & 0xF));
- return sb;
- }
-
- /**
* Perform a simulated measure pass on the given child view, assuming the child has a ViewGroup
* parent and that it should be laid out within that parent with a matching width but variable
* height.