diff options
Diffstat (limited to 'src/com/android/mail')
-rw-r--r-- | src/com/android/mail/ConversationInfo.java | 205 | ||||
-rw-r--r-- | src/com/android/mail/UndoOperation.java | 10 | ||||
-rw-r--r-- | src/com/android/mail/UnifiedEmail.java | 5 | ||||
-rw-r--r-- | src/com/android/mail/browse/ConversationCursor.java | 658 | ||||
-rw-r--r-- | src/com/android/mail/browse/ConversationItemView.java | 11 | ||||
-rw-r--r-- | src/com/android/mail/browse/ConversationItemViewModel.java | 3 | ||||
-rw-r--r-- | src/com/android/mail/browse/ConversationListActivity.java | 66 | ||||
-rw-r--r-- | src/com/android/mail/browse/FolderItem.java | 32 | ||||
-rw-r--r-- | src/com/android/mail/browse/SelectedConversationsActionMenu.java | 24 | ||||
-rw-r--r-- | src/com/android/mail/providers/Conversation.java | 77 | ||||
-rw-r--r-- | src/com/android/mail/utils/LogUtils.java | 36 | ||||
-rw-r--r-- | src/com/android/mail/utils/LoggingInputStream.java | 2 | ||||
-rw-r--r-- | src/com/android/mail/utils/Utils.java | 30 |
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. |