am 8af0d3b6: Allow support for alternate data sources.

* commit '8af0d3b6f34e03c08c8e67be2190da01c59889da':
  Allow support for alternate data sources.
diff --git a/src/com/android/ex/chips/BaseRecipientAdapter.java b/src/com/android/ex/chips/BaseRecipientAdapter.java
index b1045a4..6b37359 100644
--- a/src/com/android/ex/chips/BaseRecipientAdapter.java
+++ b/src/com/android/ex/chips/BaseRecipientAdapter.java
@@ -23,16 +23,11 @@
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.res.Resources;
 import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Handler;
 import android.os.Message;
 import android.provider.ContactsContract;
-import android.provider.ContactsContract.CommonDataKinds.Photo;
 import android.provider.ContactsContract.Directory;
-import android.support.v4.util.LruCache;
 import android.text.TextUtils;
 import android.text.util.Rfc822Token;
 import android.util.Log;
@@ -45,10 +40,6 @@
 
 import com.android.ex.chips.DropdownChipLayouter.AdapterType;
 
-import java.io.ByteArrayOutputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
@@ -60,7 +51,8 @@
 /**
  * Adapter for showing a recipient list.
  */
-public class BaseRecipientAdapter extends BaseAdapter implements Filterable, AccountSpecifier {
+public class BaseRecipientAdapter extends BaseAdapter implements Filterable, AccountSpecifier,
+        PhotoManager.PhotoManagerCallback {
     private static final String TAG = "BaseRecipientAdapter";
 
     private static final boolean DEBUG = false;
@@ -83,9 +75,6 @@
     // This is ContactsContract.PRIMARY_ACCOUNT_TYPE. Available from ICS as hidden
     static final String PRIMARY_ACCOUNT_TYPE = "type_for_primary_account";
 
-    /** The number of photos cached in this Adapter. */
-    private static final int PHOTO_CACHE_SIZE = 20;
-
     /**
      * The "Waiting for more contacts" message will be displayed if search is not complete
      * within this many milliseconds.
@@ -113,14 +102,6 @@
         public DirectoryFilter filter;
     }
 
-    private static class PhotoQuery {
-        public static final String[] PROJECTION = {
-            Photo.PHOTO
-        };
-
-        public static final int PHOTO = 0;
-    }
-
     protected static class DirectoryListQuery {
 
         public static final Uri URI =
@@ -269,24 +250,8 @@
                     final List<RecipientEntry> entries = constructEntryList(
                             entryMap, nonAggregatedEntries);
 
-                    // After having local results, check the size of results. If the results are
-                    // not enough, we search remote directories, which will take longer time.
-                    final int limit = mPreferredMaxResultCount - existingDestinations.size();
-                    final List<DirectorySearchParams> paramsList;
-                    if (limit > 0) {
-                        if (DEBUG) {
-                            Log.d(TAG, "More entries should be needed (current: "
-                                    + existingDestinations.size()
-                                    + ", remaining limit: " + limit + ") ");
-                        }
-                        directoryCursor = mContentResolver.query(
-                                DirectoryListQuery.URI, DirectoryListQuery.PROJECTION,
-                                null, null, null);
-                        paramsList = setupOtherDirectories(mContext, directoryCursor, mAccount);
-                    } else {
-                        // We don't need to search other directories.
-                        paramsList = null;
-                    }
+                    final List<DirectorySearchParams> paramsList =
+                            searchOtherDirectories(existingDestinations);
 
                     results.values = new DefaultFilterResult(
                             entries, entryMap, nonAggregatedEntries,
@@ -306,9 +271,6 @@
 
         @Override
         protected void publishResults(final CharSequence constraint, FilterResults results) {
-            // If a user types a string very quickly and database is slow, "constraint" refers to
-            // an older text which shows inconsistent results for users obsolete (b/4998713).
-            // TODO: Fix it.
             mCurrentConstraint = constraint;
 
             clearTempEntries();
@@ -352,6 +314,26 @@
         }
     }
 
+    protected List<DirectorySearchParams> searchOtherDirectories(Set<String> existingDestinations) {
+        // After having local results, check the size of results. If the results are
+        // not enough, we search remote directories, which will take longer time.
+        final int limit = mPreferredMaxResultCount - existingDestinations.size();
+        if (limit > 0) {
+            if (DEBUG) {
+                Log.d(TAG, "More entries should be needed (current: "
+                        + existingDestinations.size()
+                        + ", remaining limit: " + limit + ") ");
+            }
+            final Cursor directoryCursor = mContentResolver.query(
+                    DirectoryListQuery.URI, DirectoryListQuery.PROJECTION,
+                    null, null, null);
+            return setupOtherDirectories(mContext, directoryCursor, mAccount);
+        } else {
+            // We don't need to search other directories.
+            return null;
+        }
+    }
+
     /**
      * An asynchronous filter that performs search in a particular directory.
      */
@@ -433,8 +415,7 @@
                             (ArrayList<TemporaryEntry>) results.values;
 
                     for (TemporaryEntry tempEntry : tempEntries) {
-                        putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT,
-                                mEntryMap, mNonAggregatedEntries, mExistingDestinations);
+                        putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT);
                     }
                 }
 
@@ -457,14 +438,14 @@
             }
 
             // Show the list again without "waiting" message.
-            updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries));
+            updateEntries(constructEntryList());
         }
     }
 
     private final Context mContext;
     private final ContentResolver mContentResolver;
     private Account mAccount;
-    private final int mPreferredMaxResultCount;
+    protected final int mPreferredMaxResultCount;
     private DropdownChipLayouter mDropdownChipLayouter;
 
     /**
@@ -499,9 +480,12 @@
      * Used to ignore asynchronous queries with a different constraint, which may happen when
      * users type characters quickly.
      */
-    private CharSequence mCurrentConstraint;
+    protected CharSequence mCurrentConstraint;
 
-    private final LruCache<Uri, byte[]> mPhotoCacheMap;
+    /**
+     * Performs all photo querying as well as caching for repeated lookups.
+     */
+    private PhotoManager mPhotoManager;
 
     /**
      * Handler specific for maintaining "Waiting for more contacts" message, which will be shown
@@ -513,7 +497,7 @@
         @Override
         public void handleMessage(Message msg) {
             if (mRemainingDirectoryCount > 0) {
-                updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries));
+                updateEntries(constructEntryList());
             }
         }
 
@@ -554,7 +538,7 @@
         mContext = context;
         mContentResolver = context.getContentResolver();
         mPreferredMaxResultCount = preferredMaxResultCount;
-        mPhotoCacheMap = new LruCache<Uri, byte[]>(PHOTO_CACHE_SIZE);
+        mPhotoManager = new DefaultPhotoManager(mContentResolver);
         mQueryType = queryMode;
 
         if (queryMode == QUERY_TYPE_EMAIL) {
@@ -585,6 +569,50 @@
     }
 
     /**
+     * Enables overriding the default photo manager that is used.
+     */
+    public void setPhotoManager(PhotoManager photoManager) {
+        mPhotoManager = photoManager;
+    }
+
+    public PhotoManager getPhotoManager() {
+        return mPhotoManager;
+    }
+
+    /**
+     * If true, a null thumbnail uri is ignored when trying to query for a photo.
+     * Derived classes should only return true if a {@link com.android.ex.chips.PhotoManager}
+     * is used that does not rely on thumbnail uris. Default implementation returns {@code false}.
+     * @return
+     */
+    public boolean ignoreNullThumbnailUri() {
+        return false;
+    }
+
+    /**
+     * If true, forces using the {@link com.android.ex.chips.SingleRecipientArrayAdapter}
+     * instead of {@link com.android.ex.chips.RecipientAlternatesAdapter} when
+     * clicking on a chip. Default implementation returns {@code false}.
+     */
+    public boolean forceShowAddress() {
+        return false;
+    }
+
+    /**
+     * Used to replace email addresses with chips. Default behavior
+     * queries the ContactsProvider for contact information about the contact.
+     * Derived classes should override this method if they wish to use a
+     * new data source.
+     * @param inAddresses addresses to query
+     * @param callback callback to return results in case of success or failure
+     */
+    public void getMatchingRecipients(ArrayList<String> inAddresses,
+            RecipientAlternatesAdapter.RecipientMatchCallback callback) {
+        RecipientAlternatesAdapter.getMatchingRecipients(
+                getContext(), this, inAddresses, getAccount(), callback);
+    }
+
+    /**
      * Set the account when known. Causes the search to prioritize contacts from that account.
      */
     @Override
@@ -599,7 +627,7 @@
     }
 
     /**
-     * An extesion to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows
+     * An extension to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows
      * additional sources of contacts to be considered as matching recipients.
      * @param addresses A set of addresses to be matched
      * @return A list of matches or null if none found
@@ -686,6 +714,20 @@
         mDelayedMessageHandler.sendDelayedLoadMessage();
     }
 
+    /**
+     * Called whenever {@link com.android.ex.chips.BaseRecipientAdapter.DirectoryFilter}
+     * wants to add an additional entry to the results. Derived classes should override
+     * this method if they are not using the default data structures provided by
+     * {@link com.android.ex.chips.BaseRecipientAdapter} and are instead using their
+     * own data structures to store and collate data.
+     * @param entry the entry being added
+     * @param isAggregatedEntry
+     */
+    protected void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry) {
+        putOneEntry(entry, isAggregatedEntry,
+                mEntryMap, mNonAggregatedEntries, mExistingDestinations);
+    }
+
     private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry,
             LinkedHashMap<Long, List<RecipientEntry>> entryMap,
             List<RecipientEntry> nonAggregatedEntries,
@@ -725,6 +767,15 @@
     }
 
     /**
+     * Returns the actual list to use for this Adapter. Derived classes
+     * should override this method if overriding how the adapter stores and collates
+     * data.
+     */
+    protected List<RecipientEntry> constructEntryList() {
+        return constructEntryList(mEntryMap, mNonAggregatedEntries);
+    }
+
+    /**
      * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to
      * fetch a cached photo for each contact entry (other than separators), or request another
      * thread to get one from directories.
@@ -740,7 +791,7 @@
             for (int i = 0; i < size; i++) {
                 RecipientEntry entry = entryList.get(i);
                 entries.add(entry);
-                tryFetchPhoto(entry);
+                mPhotoManager.populatePhotoBytesAsync(entry, this);
                 validEntryCount++;
             }
             if (validEntryCount > mPreferredMaxResultCount) {
@@ -753,8 +804,7 @@
                     break;
                 }
                 entries.add(entry);
-                tryFetchPhoto(entry);
-
+                mPhotoManager.populatePhotoBytesAsync(entry, this);
                 validEntryCount++;
             }
         }
@@ -772,17 +822,17 @@
     }
 
     /** Resets {@link #mEntries} and notify the event to its parent ListView. */
-    private void updateEntries(List<RecipientEntry> newEntries) {
+    protected void updateEntries(List<RecipientEntry> newEntries) {
         mEntries = newEntries;
         mEntriesUpdatedObserver.onChanged(newEntries);
         notifyDataSetChanged();
     }
 
-    private void cacheCurrentEntries() {
+    protected void cacheCurrentEntries() {
         mTempEntries = mEntries;
     }
 
-    private void clearTempEntries() {
+    protected void clearTempEntries() {
         mTempEntries = null;
     }
 
@@ -790,135 +840,8 @@
         return mTempEntries != null ? mTempEntries : mEntries;
     }
 
-    private void tryFetchPhoto(final RecipientEntry entry) {
-        final Uri photoThumbnailUri = entry.getPhotoThumbnailUri();
-        if (photoThumbnailUri != null) {
-            final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
-            if (photoBytes != null) {
-                entry.setPhotoBytes(photoBytes);
-                // notifyDataSetChanged() should be called by a caller.
-            } else {
-                if (DEBUG) {
-                    Log.d(TAG, "No photo cache for " + entry.getDisplayName()
-                            + ". Fetch one asynchronously");
-                }
-                fetchPhotoAsync(entry, photoThumbnailUri);
-            }
-        }
-    }
-
-    // For reading photos for directory contacts, this is the chunksize for
-    // copying from the inputstream to the output stream.
-    private static final int BUFFER_SIZE = 1024*16;
-
-    private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri) {
-        final AsyncTask<Void, Void, byte[]> photoLoadTask = new AsyncTask<Void, Void, byte[]>() {
-            @Override
-            protected byte[] doInBackground(Void... params) {
-                // First try running a query. Images for local contacts are
-                // loaded by sending a query to the ContactsProvider.
-                final Cursor photoCursor = mContentResolver.query(
-                        photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null);
-                if (photoCursor != null) {
-                    try {
-                        if (photoCursor.moveToFirst()) {
-                            return photoCursor.getBlob(PhotoQuery.PHOTO);
-                        }
-                    } finally {
-                        photoCursor.close();
-                    }
-                } else {
-                    // If the query fails, try streaming the URI directly.
-                    // For remote directory images, this URI resolves to the
-                    // directory provider and the images are loaded by sending
-                    // an openFile call to the provider.
-                    try {
-                        InputStream is = mContentResolver.openInputStream(
-                                photoThumbnailUri);
-                        if (is != null) {
-                            byte[] buffer = new byte[BUFFER_SIZE];
-                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-                            try {
-                                int size;
-                                while ((size = is.read(buffer)) != -1) {
-                                    baos.write(buffer, 0, size);
-                                }
-                            } finally {
-                                is.close();
-                            }
-                            return baos.toByteArray();
-                        }
-                    } catch (IOException ex) {
-                        // ignore
-                    }
-                }
-                return null;
-            }
-
-            @Override
-            protected void onPostExecute(final byte[] photoBytes) {
-                entry.setPhotoBytes(photoBytes);
-                if (photoBytes != null) {
-                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
-                    notifyDataSetChanged();
-                }
-            }
-        };
-        photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
-    }
-
     protected void fetchPhoto(final RecipientEntry entry, final Uri photoThumbnailUri) {
-        byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
-        if (photoBytes != null) {
-            entry.setPhotoBytes(photoBytes);
-            return;
-        }
-        final Cursor photoCursor = mContentResolver.query(photoThumbnailUri, PhotoQuery.PROJECTION,
-                null, null, null);
-        if (photoCursor != null) {
-            try {
-                if (photoCursor.moveToFirst()) {
-                    photoBytes = photoCursor.getBlob(PhotoQuery.PHOTO);
-                    entry.setPhotoBytes(photoBytes);
-                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
-                }
-            } finally {
-                photoCursor.close();
-            }
-        } else {
-            InputStream inputStream = null;
-            ByteArrayOutputStream outputStream = null;
-            try {
-                inputStream = mContentResolver.openInputStream(photoThumbnailUri);
-                final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
-
-                if (bitmap != null) {
-                    outputStream = new ByteArrayOutputStream();
-                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
-                    photoBytes = outputStream.toByteArray();
-
-                    entry.setPhotoBytes(photoBytes);
-                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
-                }
-            } catch (final FileNotFoundException e) {
-                Log.w(TAG, "Error opening InputStream for photo", e);
-            } finally {
-                try {
-                    if (inputStream != null) {
-                        inputStream.close();
-                    }
-                } catch (IOException e) {
-                    Log.e(TAG, "Error closing photo input stream", e);
-                }
-                try {
-                    if (outputStream != null) {
-                        outputStream.close();
-                    }
-                } catch (IOException e) {
-                    Log.e(TAG, "Error closing photo output stream", e);
-                }
-            }
-        }
+        mPhotoManager.populatePhotoBytesSync(entry);
     }
 
     private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) {
@@ -1001,4 +924,9 @@
     public Account getAccount() {
         return mAccount;
     }
+
+    @Override
+    public void onPhotoBytesAsynchronouslyPopulated() {
+        notifyDataSetChanged();
+    }
 }
diff --git a/src/com/android/ex/chips/DefaultPhotoManager.java b/src/com/android/ex/chips/DefaultPhotoManager.java
new file mode 100644
index 0000000..04b772e
--- /dev/null
+++ b/src/com/android/ex/chips/DefaultPhotoManager.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2014 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.ex.chips;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.provider.ContactsContract;
+import android.support.v4.util.LruCache;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Default implementation of {@link com.android.ex.chips.PhotoManager} that
+ * queries for photo bytes by using the {@link com.android.ex.chips.RecipientEntry}'s
+ * photoThumbnailUri.
+ */
+public class DefaultPhotoManager implements PhotoManager {
+    private static final String TAG = "DefaultPhotoManager";
+
+    private static final boolean DEBUG = false;
+
+    /**
+     * For reading photos for directory contacts, this is the chunk size for
+     * copying from the {@link InputStream} to the output stream.
+     */
+    private static final int BUFFER_SIZE = 1024*16;
+
+    private static class PhotoQuery {
+        public static final String[] PROJECTION = {
+            ContactsContract.CommonDataKinds.Photo.PHOTO
+        };
+
+        public static final int PHOTO = 0;
+    }
+
+    private final ContentResolver mContentResolver;
+    private final LruCache<Uri, byte[]> mPhotoCacheMap;
+
+    public DefaultPhotoManager(ContentResolver contentResolver) {
+        mContentResolver = contentResolver;
+        mPhotoCacheMap = new LruCache<Uri, byte[]>(PHOTO_CACHE_SIZE);
+    }
+
+    @Override
+    public void populatePhotoBytesAsync(RecipientEntry entry, PhotoManagerCallback callback) {
+        final Uri photoThumbnailUri = entry.getPhotoThumbnailUri();
+        if (photoThumbnailUri != null) {
+            final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
+            if (photoBytes != null) {
+                entry.setPhotoBytes(photoBytes);
+                // notifyDataSetChanged() should be called by a caller.
+            } else {
+                if (DEBUG) {
+                    Log.d(TAG, "No photo cache for " + entry.getDisplayName()
+                            + ". Fetch one asynchronously");
+                }
+                fetchPhotoAsync(entry, photoThumbnailUri, callback);
+            }
+        }
+    }
+
+    private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri,
+            final PhotoManagerCallback callback) {
+        final AsyncTask<Void, Void, byte[]> photoLoadTask = new AsyncTask<Void, Void, byte[]>() {
+            @Override
+            protected byte[] doInBackground(Void... params) {
+                // First try running a query. Images for local contacts are
+                // loaded by sending a query to the ContactsProvider.
+                final Cursor photoCursor = mContentResolver.query(
+                        photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null);
+                if (photoCursor != null) {
+                    try {
+                        if (photoCursor.moveToFirst()) {
+                            return photoCursor.getBlob(PhotoQuery.PHOTO);
+                        }
+                    } finally {
+                        photoCursor.close();
+                    }
+                } else {
+                    // If the query fails, try streaming the URI directly.
+                    // For remote directory images, this URI resolves to the
+                    // directory provider and the images are loaded by sending
+                    // an openFile call to the provider.
+                    try {
+                        InputStream is = mContentResolver.openInputStream(
+                                photoThumbnailUri);
+                        if (is != null) {
+                            byte[] buffer = new byte[BUFFER_SIZE];
+                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                            try {
+                                int size;
+                                while ((size = is.read(buffer)) != -1) {
+                                    baos.write(buffer, 0, size);
+                                }
+                            } finally {
+                                is.close();
+                            }
+                            return baos.toByteArray();
+                        }
+                    } catch (IOException ex) {
+                        // ignore
+                    }
+                }
+                return null;
+            }
+
+            @Override
+            protected void onPostExecute(final byte[] photoBytes) {
+                entry.setPhotoBytes(photoBytes);
+                if (photoBytes != null) {
+                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
+                    if (callback != null) {
+                        callback.onPhotoBytesAsynchronouslyPopulated();
+                    }
+                }
+            }
+        };
+        photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
+    }
+
+    @Override
+    public void populatePhotoBytesSync(RecipientEntry entry) {
+        final Uri photoThumbnailUri = entry.getPhotoThumbnailUri();
+        if (photoThumbnailUri == null) {
+            return;
+        }
+
+        byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
+        if (photoBytes != null) {
+            entry.setPhotoBytes(photoBytes);
+            return;
+        }
+        final Cursor photoCursor = mContentResolver.query(
+                photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null);
+        if (photoCursor != null) {
+            try {
+                if (photoCursor.moveToFirst()) {
+                    photoBytes = photoCursor.getBlob(PhotoQuery.PHOTO);
+                    entry.setPhotoBytes(photoBytes);
+                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
+                }
+            } finally {
+                photoCursor.close();
+            }
+        } else {
+            InputStream inputStream = null;
+            ByteArrayOutputStream outputStream = null;
+            try {
+                inputStream = mContentResolver.openInputStream(photoThumbnailUri);
+                final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+
+                if (bitmap != null) {
+                    outputStream = new ByteArrayOutputStream();
+                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
+                    photoBytes = outputStream.toByteArray();
+
+                    entry.setPhotoBytes(photoBytes);
+                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
+                }
+            } catch (final FileNotFoundException e) {
+                Log.w(TAG, "Error opening InputStream for photo", e);
+            } finally {
+                try {
+                    if (inputStream != null) {
+                        inputStream.close();
+                    }
+                } catch (IOException e) {
+                    Log.e(TAG, "Error closing photo input stream", e);
+                }
+                try {
+                    if (outputStream != null) {
+                        outputStream.close();
+                    }
+                } catch (IOException e) {
+                    Log.e(TAG, "Error closing photo output stream", e);
+                }
+            }
+        }
+    }
+}
diff --git a/src/com/android/ex/chips/PhotoManager.java b/src/com/android/ex/chips/PhotoManager.java
new file mode 100644
index 0000000..8cb8df6
--- /dev/null
+++ b/src/com/android/ex/chips/PhotoManager.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2014 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.ex.chips;
+
+/**
+ * Used by the {@link com.android.ex.chips.BaseRecipientAdapter} to handle fetching
+ * photos from external sources and caching them for faster lookup later.
+ */
+public interface PhotoManager {
+
+    /** The number of photos cached in this Adapter. */
+    public static final int PHOTO_CACHE_SIZE = 20;
+
+    /**
+     * Sets the {@link com.android.ex.chips.RecipientEntry}'s photo bytes. If the photo bytes
+     * are cached, this action happens immediately. Otherwise, the work to fetch the photo
+     * bytes is performed asynchronously before setting the value on the UI thread.<p/>
+     *
+     * If the photo bytes were fetched asynchronously,
+     * {@link PhotoManagerCallback#onPhotoBytesAsynchronouslyPopulated()} is called. This
+     * method is not called if the photo bytes have been cached previously (because no
+     * asynchronous work was performed.
+     */
+    void populatePhotoBytesAsync(RecipientEntry entry, PhotoManagerCallback callback);
+
+    /**
+     * Sets the {@link com.android.ex.chips.RecipientEntry}'s photo bytes. All work
+     * is performed synchronously.
+     */
+    void populatePhotoBytesSync(RecipientEntry entry);
+
+    interface PhotoManagerCallback {
+        void onPhotoBytesAsynchronouslyPopulated();
+    }
+}
diff --git a/src/com/android/ex/chips/RecipientAlternatesAdapter.java b/src/com/android/ex/chips/RecipientAlternatesAdapter.java
index f6f662d..d091fa6 100644
--- a/src/com/android/ex/chips/RecipientAlternatesAdapter.java
+++ b/src/com/android/ex/chips/RecipientAlternatesAdapter.java
@@ -49,7 +49,7 @@
  * queried by email or by phone number.
  */
 public class RecipientAlternatesAdapter extends CursorAdapter {
-    static final int MAX_LOOKUPS = 50;
+    public static final int MAX_LOOKUPS = 50;
 
     private final long mCurrentId;
 
@@ -87,7 +87,6 @@
      * @param context Context.
      * @param inAddresses Array of addresses on which to perform the lookup.
      * @param callback RecipientMatchCallback called when a match or matches are found.
-     * @return HashMap<String,RecipientEntry>
      */
     public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter,
             ArrayList<String> inAddresses, int addressType, Account account,
@@ -133,9 +132,31 @@
                 c.close();
             }
         }
+
+        final Set<String> matchesNotFound = new HashSet<String>();
+
+        getMatchingRecipientsFromDirectoryQueries(context, recipientEntries,
+                addresses, account, matchesNotFound, query, callback);
+
+        getMatchingRecipientsFromExtensionMatcher(adapter, matchesNotFound, callback);
+    }
+
+    public static void getMatchingRecipientsFromDirectoryQueries(Context context,
+            Map<String, RecipientEntry> recipientEntries, Set<String> addresses,
+            Account account, Set<String> matchesNotFound,
+            RecipientMatchCallback callback) {
+        getMatchingRecipientsFromDirectoryQueries(
+                context, recipientEntries, addresses, account,
+                matchesNotFound, Queries.EMAIL, callback);
+    }
+
+    private static void getMatchingRecipientsFromDirectoryQueries(Context context,
+            Map<String, RecipientEntry> recipientEntries, Set<String> addresses,
+            Account account, Set<String> matchesNotFound, Queries.Query query,
+            RecipientMatchCallback callback) {
         // See if any entries did not resolve; if so, we need to check other
         // directories
-        final Set<String> matchesNotFound = new HashSet<String>();
+
         if (recipientEntries.size() < addresses.size()) {
             final List<DirectorySearchParams> paramsList;
             Cursor directoryCursor = null;
@@ -200,7 +221,10 @@
                 }
             }
         }
+    }
 
+    public static void getMatchingRecipientsFromExtensionMatcher(BaseRecipientAdapter adapter,
+            Set<String> matchesNotFound, RecipientMatchCallback callback) {
         // If no matches found in contact provider or the directories, try the extension
         // matcher.
         // todo (aalbert): This whole method needs to be in the adapter?
diff --git a/src/com/android/ex/chips/RecipientEditTextView.java b/src/com/android/ex/chips/RecipientEditTextView.java
index 7eaee98..1d3a167 100644
--- a/src/com/android/ex/chips/RecipientEditTextView.java
+++ b/src/com/android/ex/chips/RecipientEditTextView.java
@@ -91,10 +91,8 @@
 
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -321,7 +319,7 @@
         setDropdownChipLayouter(new DropdownChipLayouter(LayoutInflater.from(context), context));
     }
 
-    protected void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
+    public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
         mDropdownChipLayouter = dropdownChipLayouter;
     }
 
@@ -717,7 +715,8 @@
             byte[] photoBytes = contact.getPhotoBytes();
             // There may not be a photo yet if anything but the first contact address
             // was selected.
-            if (photoBytes == null && contact.getPhotoThumbnailUri() != null) {
+            if (photoBytes == null && (contact.getPhotoThumbnailUri() != null ||
+                    getAdapter().ignoreNullThumbnailUri())) {
                 // TODO: cache this in the recipient entry?
                 getAdapter().fetchPhoto(contact, contact.getPhotoThumbnailUri());
                 photoBytes = contact.getPhotoBytes();
@@ -1823,31 +1822,6 @@
         return entry;
     }
 
-    /** Returns a collection of contact Id for each chip inside this View. */
-    /* package */ Collection<Long> getContactIds() {
-        final Set<Long> result = new HashSet<Long>();
-        DrawableRecipientChip[] chips = getSortedRecipients();
-        if (chips != null) {
-            for (DrawableRecipientChip chip : chips) {
-                result.add(chip.getContactId());
-            }
-        }
-        return result;
-    }
-
-
-    /** Returns a collection of data Id for each chip inside this View. May be null. */
-    /* package */ Collection<Long> getDataIds() {
-        final Set<Long> result = new HashSet<Long>();
-        DrawableRecipientChip [] chips = getSortedRecipients();
-        if (chips != null) {
-            for (DrawableRecipientChip chip : chips) {
-                result.add(chip.getDataId());
-            }
-        }
-        return result;
-    }
-
     // Visible for testing.
     /* package */DrawableRecipientChip[] getSortedRecipients() {
         DrawableRecipientChip[] recips = getSpannable()
@@ -2092,13 +2066,16 @@
             return constructChipSpan(
                     RecipientEntry.constructFakeEntry((String) text, isValid(text.toString())),
                     true);
-        } else if (currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT) {
+        } else {
             int start = getChipStart(currentChip);
             int end = getChipEnd(currentChip);
             getSpannable().removeSpan(currentChip);
             DrawableRecipientChip newChip;
+            final boolean showAddress =
+                    currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT ||
+                    getAdapter().forceShowAddress();
             try {
-                if (mNoChips) {
+                if (showAddress && mNoChips) {
                     return null;
                 }
                 newChip = constructChipSpan(currentChip.getEntry(), true);
@@ -2117,32 +2094,11 @@
             if (shouldShowEditableText(newChip)) {
                 scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip)));
             }
-            showAddress(newChip, mAddressPopup, getWidth());
-            setCursorVisible(false);
-            return newChip;
-        } else {
-            int start = getChipStart(currentChip);
-            int end = getChipEnd(currentChip);
-            getSpannable().removeSpan(currentChip);
-            DrawableRecipientChip newChip;
-            try {
-                newChip = constructChipSpan(currentChip.getEntry(), true);
-            } catch (NullPointerException e) {
-                Log.e(TAG, e.getMessage(), e);
-                return null;
-            }
-            Editable editable = getText();
-            QwertyKeyListener.markAsReplaced(editable, start, end, "");
-            if (start == -1 || end == -1) {
-                Log.d(TAG, "The chip being selected no longer exists but should.");
+            if (showAddress) {
+                showAddress(newChip, mAddressPopup, getWidth());
             } else {
-                editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+                showAlternates(newChip, mAlternatesPopup, getWidth());
             }
-            newChip.setSelected(true);
-            if (shouldShowEditableText(newChip)) {
-                scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip)));
-            }
-            showAlternates(newChip, mAlternatesPopup, getWidth());
             setCursorVisible(false);
             return newChip;
         }
@@ -2639,8 +2595,7 @@
                 }
             }
             final BaseRecipientAdapter adapter = getAdapter();
-            RecipientAlternatesAdapter.getMatchingRecipients(getContext(), adapter, addresses,
-                    adapter.getAccount(), new RecipientMatchCallback() {
+            adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() {
                         @Override
                         public void matchesFound(Map<String, RecipientEntry> entries) {
                             final ArrayList<DrawableRecipientChip> replacements =
@@ -2767,9 +2722,7 @@
                 }
             }
             final BaseRecipientAdapter adapter = getAdapter();
-            RecipientAlternatesAdapter.getMatchingRecipients(getContext(), adapter, addresses,
-                    adapter.getAccount(),
-                    new RecipientMatchCallback() {
+            adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() {
 
                         @Override
                         public void matchesFound(Map<String, RecipientEntry> entries) {
diff --git a/src/com/android/ex/chips/RecipientEntry.java b/src/com/android/ex/chips/RecipientEntry.java
index 7d9b87f..2afa859 100644
--- a/src/com/android/ex/chips/RecipientEntry.java
+++ b/src/com/android/ex/chips/RecipientEntry.java
@@ -35,7 +35,7 @@
     /* package */ static final int GENERATED_CONTACT = -2;
 
     /** Used when {@link #mDestinationType} is invalid and thus shouldn't be used for display. */
-    /* package */ static final int INVALID_DESTINATION_TYPE = -1;
+    public static final int INVALID_DESTINATION_TYPE = -1;
 
     public static final int ENTRY_TYPE_PERSON = 0;
 
@@ -76,10 +76,10 @@
      */
     private byte[] mPhotoBytes;
 
-    /** See {@link ContactsContract.Contacts#LOOKUP_KEY} */
+    /** See {@link android.provider.ContactsContract.ContactsColumns#LOOKUP_KEY} */
     private final String mLookupKey;
 
-    private RecipientEntry(int entryType, String displayName, String destination,
+    protected RecipientEntry(int entryType, String displayName, String destination,
             int destinationType, String destinationLabel, long contactId, Long directoryId,
             long dataId, Uri photoThumbnailUri, boolean isFirstLevel, boolean isValid,
             String lookupKey) {
@@ -174,7 +174,7 @@
         return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource,
                 displayName, destination), destination, destinationType, destinationLabel,
                 contactId, directoryId, dataId, (thumbnailUriAsString != null
-                        ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey);
+                ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey);
     }
 
     public static RecipientEntry constructSecondLevelEntry(String displayName,
@@ -184,7 +184,7 @@
         return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource,
                 displayName, destination), destination, destinationType, destinationLabel,
                 contactId, directoryId, dataId, (thumbnailUriAsString != null
-                        ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey);
+                ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey);
     }
 
     public int getEntryType() {