Redesign of contact photo loading.
Change-Id: Ib0814a789229c8355fbd67162679f4618ee75875
diff --git a/src/com/android/contacts/ContactPhotoLoader.java b/src/com/android/contacts/ContactPhotoLoader.java
new file mode 100644
index 0000000..70f3014
--- /dev/null
+++ b/src/com/android/contacts/ContactPhotoLoader.java
@@ -0,0 +1,402 @@
+/*
+ * Copyright (C) 2010 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.contacts;
+
+import com.google.android.collect.Lists;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.Handler.Callback;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Contacts.Photo;
+import android.widget.ImageView;
+
+import java.lang.ref.SoftReference;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Asynchronously loads contact photos and maintains cache of photos. The class is
+ * mostly single-threaded. The only two methods accessed by the loader thread are
+ * {@link #cacheBitmap} and {@link #obtainPhotoIdsToLoad}. Those methods access concurrent
+ * hash maps shared with the main thread.
+ */
+public class ContactPhotoLoader implements Callback {
+
+ /**
+ * Type of message sent by the UI thread to itself to indicate that some photos
+ * need to be loaded.
+ */
+ private static final int MESSAGE_REQUEST_LOADING = 1;
+
+ /**
+ * Type of message sent by the loader thread to indicate that some photos have
+ * been loaded.
+ */
+ private static final int MESSAGE_PHOTOS_LOADED = 2;
+
+ private static final String[] EMPTY_STRING_ARRAY = new String[0];
+
+ /**
+ * The resource ID of the image to be used when the photo is unavailable or being
+ * loaded.
+ */
+ private final int mDefaultResourceId;
+
+ /**
+ * Maintains the state of a particular photo.
+ */
+ private static class BitmapHolder {
+ private static final int NEEDED = 0;
+ private static final int LOADING = 1;
+ private static final int LOADED = 2;
+
+ int state;
+ SoftReference<Bitmap> bitmapRef;
+ }
+
+ /**
+ * A soft cache for photos.
+ */
+ private final ConcurrentHashMap<Long, BitmapHolder> mBitmapCache =
+ new ConcurrentHashMap<Long, BitmapHolder>();
+
+ /**
+ * A map from ImageView to the corresponding photo ID. Please note that this
+ * photo ID may change before the photo loading request is started.
+ */
+ private final ConcurrentHashMap<ImageView, Long> mPendingRequests =
+ new ConcurrentHashMap<ImageView, Long>();
+
+ /**
+ * Handler for messages sent to the UI thread.
+ */
+ private final Handler mMainThreadHandler = new Handler(this);
+
+ /**
+ * Thread responsible for loading photos from the database. Created upon
+ * the first request.
+ */
+ private LoaderThread mLoaderThread;
+
+ /**
+ * A gate to make sure we only send one instance of MESSAGE_PHOTOS_NEEDED at a time.
+ */
+ private boolean mLoadingRequested;
+
+ /**
+ * Flag indicating if the image loading is paused.
+ */
+ private boolean mPaused;
+
+ private final Context mContext;
+
+ /**
+ * Constructor.
+ *
+ * @param context content context
+ * @param defaultResourceId the image resource ID to be used when there is
+ * no photo for a contact
+ */
+ public ContactPhotoLoader(Context context, int defaultResourceId) {
+ mDefaultResourceId = defaultResourceId;
+ mContext = context;
+ }
+
+ /**
+ * Load photo into the supplied image view. If the photo is already cached,
+ * it is displayed immediately. Otherwise a request is sent to load the photo
+ * from the database.
+ */
+ public void loadPhoto(ImageView view, long photoId) {
+ if (photoId == 0) {
+ // No photo is needed
+ view.setImageResource(mDefaultResourceId);
+ mPendingRequests.remove(view);
+ } else {
+ boolean loaded = loadCachedPhoto(view, photoId);
+ if (loaded) {
+ mPendingRequests.remove(view);
+ } else {
+ mPendingRequests.put(view, photoId);
+ if (!mPaused) {
+ // Send a request to start loading photos
+ requestLoading();
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks if the photo is present in cache. If so, sets the photo on the view,
+ * otherwise sets the state of the photo to {@link BitmapHolder#NEEDED} and
+ * temporarily set the image to the default resource ID.
+ */
+ private boolean loadCachedPhoto(ImageView view, long photoId) {
+ BitmapHolder holder = mBitmapCache.get(photoId);
+ if (holder == null) {
+ holder = new BitmapHolder();
+ mBitmapCache.put(photoId, holder);
+ } else if (holder.state == BitmapHolder.LOADED) {
+ // Null bitmap reference means that database contains no bytes for the photo
+ if (holder.bitmapRef == null) {
+ view.setImageResource(mDefaultResourceId);
+ return true;
+ }
+
+ Bitmap bitmap = holder.bitmapRef.get();
+ if (bitmap != null) {
+ view.setImageBitmap(bitmap);
+ return true;
+ }
+
+ // Null bitmap means that the soft reference was released by the GC
+ // and we need to reload the photo.
+ holder.bitmapRef = null;
+ }
+
+ // The bitmap has not been loaded - should display the placeholder image.
+ view.setImageResource(mDefaultResourceId);
+ holder.state = BitmapHolder.NEEDED;
+ return false;
+ }
+
+ /**
+ * Stops loading images, kills the image loader thread and clears all caches.
+ */
+ public void stop() {
+ pause();
+
+ if (mLoaderThread != null) {
+ mLoaderThread.quit();
+ mLoaderThread = null;
+ }
+
+ mPendingRequests.clear();
+ mBitmapCache.clear();
+ }
+
+ /**
+ * Temporarily stops loading photos from the database.
+ */
+ public void pause() {
+ mPaused = true;
+ }
+
+ /**
+ * Resumes loading photos from the database.
+ */
+ public void resume() {
+ mPaused = false;
+ if (!mPendingRequests.isEmpty()) {
+ requestLoading();
+ }
+ }
+
+ /**
+ * Sends a message to this thread itself to start loading images. If the current
+ * view contains multiple image views, all of those image views will get a chance
+ * to request their respective photos before any of those requests are executed.
+ * This allows us to load images in bulk.
+ */
+ private void requestLoading() {
+ if (!mLoadingRequested) {
+ mLoadingRequested = true;
+ mMainThreadHandler.sendEmptyMessage(MESSAGE_REQUEST_LOADING);
+ }
+ }
+
+ /**
+ * Processes requests on the main thread.
+ */
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MESSAGE_REQUEST_LOADING: {
+ mLoadingRequested = false;
+ if (!mPaused) {
+ if (mLoaderThread == null) {
+ mLoaderThread = new LoaderThread(mContext.getContentResolver());
+ mLoaderThread.start();
+ }
+
+ mLoaderThread.requestLoading();
+ }
+ return true;
+ }
+
+ case MESSAGE_PHOTOS_LOADED: {
+ if (!mPaused) {
+ processLoadedImages();
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Goes over pending loading requests and displays loaded photos. If some of the
+ * photos still haven't been loaded, sends another request for image loading.
+ */
+ private void processLoadedImages() {
+ Iterator<ImageView> iterator = mPendingRequests.keySet().iterator();
+ while (iterator.hasNext()) {
+ ImageView view = iterator.next();
+ long photoId = mPendingRequests.get(view);
+ boolean loaded = loadCachedPhoto(view, photoId);
+ if (loaded) {
+ iterator.remove();
+ }
+ }
+
+ if (!mPendingRequests.isEmpty()) {
+ requestLoading();
+ }
+ }
+
+ /**
+ * Stores the supplied bitmap in cache.
+ */
+ private void cacheBitmap(long id, byte[] bytes) {
+ if (mPaused) {
+ return;
+ }
+
+ BitmapHolder holder = new BitmapHolder();
+ holder.state = BitmapHolder.LOADED;
+ if (bytes != null) {
+ try {
+ Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, null);
+ holder.bitmapRef = new SoftReference<Bitmap>(bitmap);
+ } catch (OutOfMemoryError e) {
+ // Do nothing - the photo will appear to be missing
+ }
+ }
+ mBitmapCache.put(id, holder);
+ }
+
+ /**
+ * Populates an array of photo IDs that need to be loaded.
+ */
+ private void obtainPhotoIdsToLoad(ArrayList<String> photoIds) {
+ photoIds.clear();
+
+ /*
+ * Since the call is made from the loader thread, the map could be
+ * changing during the iteration. That's not really a problem:
+ * ConcurrentHashMap will allow those changes to happen without throwing
+ * exceptions. Since we may miss some requests in the situation of
+ * concurrent change, we will need to check the map again once loading
+ * is complete.
+ */
+ Iterator<Long> iterator = mPendingRequests.values().iterator();
+ while (iterator.hasNext()) {
+ Long id = iterator.next();
+ BitmapHolder holder = mBitmapCache.get(id);
+ if (holder.state == BitmapHolder.NEEDED) {
+ // Assuming atomic behavior
+ holder.state = BitmapHolder.LOADING;
+ photoIds.add(id.toString());
+ }
+ }
+ }
+
+ /**
+ * The thread that performs loading of photos from the database.
+ */
+ private class LoaderThread extends HandlerThread implements Callback {
+ private static final String LOADER_THREAD_NAME = "ContactPhotoLoader";
+ private final String[] COLUMNS = new String[] { Photo._ID, Photo.PHOTO };
+
+ private final ContentResolver mResolver;
+ private final StringBuilder mStringBuilder = new StringBuilder();
+ private final ArrayList<String> mPhotoIds = Lists.newArrayList();
+ private Handler mLoaderThreadHandler;
+
+ public LoaderThread(ContentResolver resolver) {
+ super(LOADER_THREAD_NAME);
+ mResolver = resolver;
+ }
+
+ /**
+ * Sends a message to this thread to load requested photos.
+ */
+ public void requestLoading() {
+ if (mLoaderThreadHandler == null) {
+ mLoaderThreadHandler = new Handler(getLooper(), this);
+ }
+ mLoaderThreadHandler.sendEmptyMessage(0);
+ }
+
+ /**
+ * Receives the above message, loads photos and then sends a message
+ * to the main thread to process them.
+ */
+ public boolean handleMessage(Message msg) {
+ loadPhotosFromDatabase();
+ mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
+ return true;
+ }
+
+ private void loadPhotosFromDatabase() {
+ obtainPhotoIdsToLoad(mPhotoIds);
+
+ int count = mPhotoIds.size();
+ if (count == 0) {
+ return;
+ }
+
+ mStringBuilder.setLength(0);
+ mStringBuilder.append(Photo._ID + " IN(");
+ for (int i = 0; i < count; i++) {
+ if (i != 0) {
+ mStringBuilder.append(',');
+ }
+ mStringBuilder.append('?');
+ }
+ mStringBuilder.append(')');
+
+ Cursor cursor = null;
+ try {
+ cursor = mResolver.query(Data.CONTENT_URI,
+ COLUMNS,
+ mStringBuilder.toString(),
+ mPhotoIds.toArray(EMPTY_STRING_ARRAY),
+ null);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ Long id = cursor.getLong(0);
+ byte[] bytes = cursor.getBlob(1);
+ cacheBitmap(id, bytes);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/contacts/ContactsListActivity.java b/src/com/android/contacts/ContactsListActivity.java
index f68bc44..91a2781 100644
--- a/src/com/android/contacts/ContactsListActivity.java
+++ b/src/com/android/contacts/ContactsListActivity.java
@@ -95,7 +95,6 @@
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.ContextMenu.ContextMenuInfo;
-import android.view.ViewGroup.LayoutParams;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.AbsListView;
@@ -124,11 +123,6 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-/*TODO(emillar) I commented most of the code that deals with modes and filtering. It should be
- * brought back in as we add back that functionality.
- */
-
-
/**
* Displays a list of contacts. Usually is embedded into the ContactsActivity.
*/
@@ -434,6 +428,7 @@
private static final int CONTACTS_ID = 1001;
private static final UriMatcher sContactsIdMatcher;
+ private ContactPhotoLoader mPhotoLoader;
private static ExecutorService sImageFetchThreadPool;
static {
@@ -516,6 +511,7 @@
mIconSize = getResources().getDimensionPixelSize(android.R.dimen.app_icon_size);
mContactsPrefs = new ContactsPreferences(this);
+ mPhotoLoader = new ContactPhotoLoader(this, R.drawable.ic_contact_list_picture);
// Allow the title to be set to a custom String using an extra on the intent
String title = intent.getStringExtra(UI.TITLE_EXTRA_KEY);
@@ -895,13 +891,15 @@
}
@Override
+ protected void onPause() {
+ super.onPause();
+ mPhotoLoader.stop();
+ }
+
+ @Override
protected void onResume() {
super.onResume();
-
- // Force cache to reload so we don't show stale photos.
- if (mAdapter.mBitmapCache != null) {
- mAdapter.mBitmapCache.clear();
- }
+ mPhotoLoader.resume();
mScrollState = OnScrollListener.SCROLL_STATE_IDLE;
boolean runQuery = true;
@@ -977,7 +975,6 @@
mAdapter.setSuggestionsCursor(null);
mAdapter.changeCursor(null);
- mAdapter.clearImageFetching();
if (mMode == MODE_QUERY) {
// Make sure the search box is closed
@@ -2454,22 +2451,18 @@
private boolean mDisplayPhotos = false;
private boolean mDisplayCallButton = false;
private boolean mDisplayAdditionalData = true;
- private HashMap<Long, SoftReference<Bitmap>> mBitmapCache = null;
private HashSet<ImageView> mItemsMissingImages = null;
private int mFrequentSeparatorPos = ListView.INVALID_POSITION;
private boolean mDisplaySectionHeaders = true;
private int[] mSectionPositions;
private Cursor mSuggestionsCursor;
private int mSuggestionsCursorCount;
- private ImageFetchHandler mHandler;
- private ImageDbFetcher mImageFetcher;
private static final int FETCH_IMAGE_MSG = 1;
public ContactItemListAdapter(Context context) {
super(context, R.layout.contacts_list_item, null, false);
- mHandler = new ImageFetchHandler();
mAlphabet = context.getString(com.android.internal.R.string.fast_scroll_alphabet);
mUnknownNameText = context.getText(android.R.string.unknownName);
@@ -2505,8 +2498,6 @@
if ((mMode & MODE_MASK_SHOW_PHOTOS) == MODE_MASK_SHOW_PHOTOS) {
mDisplayPhotos = true;
setViewResource(R.layout.contacts_list_item_photo);
- mBitmapCache = new HashMap<Long, SoftReference<Bitmap>>();
- mItemsMissingImages = new HashSet<ImageView>();
}
if (mMode == MODE_STREQUENT || mMode == MODE_FREQUENT) {
@@ -2518,104 +2509,6 @@
return mDisplaySectionHeaders;
}
- private class ImageFetchHandler extends Handler {
-
- @Override
- public void handleMessage(Message message) {
- if (ContactsListActivity.this.isFinishing()) {
- return;
- }
- switch(message.what) {
- case FETCH_IMAGE_MSG: {
- final ImageView imageView = (ImageView) message.obj;
- if (imageView == null) {
- break;
- }
-
- final PhotoInfo info = (PhotoInfo)imageView.getTag();
- if (info == null) {
- break;
- }
-
- final long photoId = info.photoId;
- if (photoId == 0) {
- break;
- }
-
- SoftReference<Bitmap> photoRef = mBitmapCache.get(photoId);
- if (photoRef == null) {
- break;
- }
- Bitmap photo = photoRef.get();
- if (photo == null) {
- mBitmapCache.remove(photoId);
- break;
- }
-
- // Make sure the photoId on this image view has not changed
- // while we were loading the image.
- synchronized (imageView) {
- final PhotoInfo updatedInfo = (PhotoInfo)imageView.getTag();
- long currentPhotoId = updatedInfo.photoId;
- if (currentPhotoId == photoId) {
- imageView.setImageBitmap(photo);
- mItemsMissingImages.remove(imageView);
- }
- }
- break;
- }
- }
- }
-
- public void clearImageFecthing() {
- removeMessages(FETCH_IMAGE_MSG);
- }
- }
-
- private class ImageDbFetcher implements Runnable {
- long mPhotoId;
- private ImageView mImageView;
-
- public ImageDbFetcher(long photoId, ImageView imageView) {
- this.mPhotoId = photoId;
- this.mImageView = imageView;
- }
-
- public void run() {
- if (ContactsListActivity.this.isFinishing()) {
- return;
- }
-
- if (Thread.interrupted()) {
- // shutdown has been called.
- return;
- }
- Bitmap photo = null;
- try {
- photo = ContactsUtils.loadContactPhoto(mContext, mPhotoId, null);
- } catch (OutOfMemoryError e) {
- // Not enough memory for the photo, do nothing.
- }
-
- if (photo == null) {
- return;
- }
-
- mBitmapCache.put(mPhotoId, new SoftReference<Bitmap>(photo));
-
- if (Thread.interrupted()) {
- // shutdown has been called.
- return;
- }
-
- // Update must happen on UI thread
- Message msg = new Message();
- msg.what = FETCH_IMAGE_MSG;
- msg.obj = mImageView;
- mHandler.sendMessage(msg);
- }
- }
-
public void setSuggestionsCursor(Cursor cursor) {
if (mSuggestionsCursor != null) {
mSuggestionsCursor.close();
@@ -2925,40 +2818,7 @@
final int position = cursor.getPosition();
- viewToUse.setTag(new PhotoInfo(position, photoId));
-
- if (photoId == 0) {
- viewToUse.setImageResource(R.drawable.ic_contact_list_picture);
- } else {
-
- Bitmap photo = null;
-
- // Look for the cached bitmap
- SoftReference<Bitmap> ref = mBitmapCache.get(photoId);
- if (ref != null) {
- photo = ref.get();
- if (photo == null) {
- mBitmapCache.remove(photoId);
- }
- }
-
- // Bind the photo, or use the fallback no photo resource
- if (photo != null) {
- viewToUse.setImageBitmap(photo);
- } else {
- // Cache miss
- viewToUse.setImageResource(R.drawable.ic_contact_list_picture);
-
- // Add it to a set of images that are populated asynchronously.
- mItemsMissingImages.add(viewToUse);
-
- if (mScrollState != OnScrollListener.SCROLL_STATE_FLING) {
-
- // Scrolling is idle or slow, go get the image right now.
- sendFetchImageMessage(viewToUse);
- }
- }
- }
+ mPhotoLoader.loadPhoto(viewToUse, photoId);
}
ImageView presenceView = cache.presenceView;
@@ -3308,59 +3168,12 @@
mScrollState = scrollState;
if (scrollState == OnScrollListener.SCROLL_STATE_FLING) {
- // If we are in a fling, stop loading images.
- clearImageFetching();
+ mPhotoLoader.pause();
} else if (mDisplayPhotos) {
- processMissingImageItems(view);
+ mPhotoLoader.resume();
}
}
- private void processMissingImageItems(AbsListView view) {
- for (ImageView iv : mItemsMissingImages) {
- sendFetchImageMessage(iv);
- }
- }
-
- private void sendFetchImageMessage(ImageView view) {
- final PhotoInfo info = (PhotoInfo) view.getTag();
- if (info == null) {
- return;
- }
- final long photoId = info.photoId;
- if (photoId == 0) {
- return;
- }
- mImageFetcher = new ImageDbFetcher(photoId, view);
- synchronized (ContactsListActivity.this) {
- // can't sync on sImageFetchThreadPool.
- if (sImageFetchThreadPool == null) {
- // TODO: redesign this so that all DB interaction happens
- // on a single background thread and loads photos in bulk.
- sImageFetchThreadPool = Executors.newFixedThreadPool(1);
- }
- sImageFetchThreadPool.execute(mImageFetcher);
- }
- }
-
-
- /**
- * Stop the image fetching for ALL contacts, if one is in progress we'll
- * not query the database.
- *
- * TODO: move this method to ContactsListActivity, it does not apply to the current
- * contact.
- */
- public void clearImageFetching() {
- synchronized (ContactsListActivity.this) {
- if (sImageFetchThreadPool != null) {
- sImageFetchThreadPool.shutdownNow();
- sImageFetchThreadPool = null;
- }
- }
-
- mHandler.clearImageFecthing();
- }
-
/**
* Computes the state of the pinned header. It can be invisible, fully
* visible or partially pushed up out of the view.