diff --git a/res/drawable-hdpi/ic_contact_picture.png b/res/drawable-hdpi/ic_contact_picture.png new file mode 100644 index 000000000..7c34f5c94 Binary files /dev/null and b/res/drawable-hdpi/ic_contact_picture.png differ diff --git a/res/layout/message_list_item.xml b/res/layout/message_list_item.xml index f29606639..72bca7ed8 100644 --- a/res/layout/message_list_item.xml +++ b/res/layout/message_list_item.xml @@ -33,6 +33,22 @@ + + mBitmapCache; + + /** + * LRU cache of email addresses that don't belong to a contact we have a picture for. + * + *

+ * We don't store the default picture for unknown contacts or contacts without a picture in + * {@link #mBitmapCache}, because that would lead to an unnecessarily complex implementation of + * the {@code LruCache.sizeOf()} method. Instead, we save the email addresses we know don't + * belong to one of our contacts with a picture. Knowing this, we can avoid querying the + * contacts database for those addresses and immediately return the default picture. + *

+ */ + private final LruCache mUnknownContactsCache; + + + public ContactPictureLoader(Context context, int defaultPictureResource) { + Context appContext = context.getApplicationContext(); + mContentResolver = appContext.getContentResolver(); + mResources = appContext.getResources(); + mContactsHelper = Contacts.getInstance(appContext); + mDefaultPicture = BitmapFactory.decodeResource(mResources, defaultPictureResource); + + ActivityManager activityManager = + (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE); + int memClass = activityManager.getMemoryClass(); + + // Use 1/16th of the available memory for this memory cache. + final int cacheSize = 1024 * 1024 * memClass / 16; + + mBitmapCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + // The cache size will be measured in bytes rather than number of items. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) { + return bitmap.getByteCount(); + } + + return bitmap.getRowBytes() * bitmap.getHeight(); + } + }; + + mUnknownContactsCache = new LruCache(MAX_UNKNOWN_CONTACTS); + } + + /** + * Load a contact picture and display it using the supplied {@link QuickContactBadge} instance. + * + *

+ * If the supplied email address doesn't belong to any of our contacts, the default picture is + * returned. If the picture is found in the cache, it is displayed in the + * {@code QuickContactBadge} immediately. Otherwise a {@link ContactPictureRetrievalTask} is + * started to try to load the contact picture in a background thread. The picture is then + * stored in the bitmap cache or the email address is stored in the "unknown contacts cache" if + * it doesn't belong to one of our contacts. + *

+ * + * @param email + * The email address that is used to search the contacts database. + * @param badge + * The {@code QuickContactBadge} instance to receive the picture. + * + * @see #mBitmapCache + * @see #mUnknownContactsCache + */ + public void loadContactPicture(String email, QuickContactBadge badge) { + Bitmap bitmap = getBitmapFromCache(email); + if (bitmap != null) { + // The picture was found in the bitmap cache + badge.setImageBitmap(bitmap); + } else if (isEmailInUnknownContactsCache(email)) { + // This email address doesn't belong to a contact we have a picture for. Use the + // default picture. + badge.setImageBitmap(mDefaultPicture); + } else if (cancelPotentialWork(email, badge)) { + // Query the contacts database in a background thread and try to load the contact + // picture, if there is one. + ContactPictureRetrievalTask task = new ContactPictureRetrievalTask(badge); + AsyncDrawable asyncDrawable = new AsyncDrawable(mResources, mDefaultPicture, task); + badge.setImageDrawable(asyncDrawable); + try { + task.exec(email); + } catch (RejectedExecutionException e) { + // We flooded the thread pool queue... fall back to using the default picture + badge.setImageBitmap(mDefaultPicture); + } + } + } + + private void addBitmapToCache(String key, Bitmap bitmap) { + if (getBitmapFromCache(key) == null) { + mBitmapCache.put(key, bitmap); + } + } + + private Bitmap getBitmapFromCache(String key) { + return mBitmapCache.get(key); + } + + private void addEmailToUnknownContactsCache(String key) { + if (!isEmailInUnknownContactsCache(key)) { + mUnknownContactsCache.put(key, DUMMY_INT_ARRAY); + } + } + + private boolean isEmailInUnknownContactsCache(String key) { + return mUnknownContactsCache.get(key) != null; + } + + /** + * Checks if a {@code ContactPictureRetrievalTask} was already created to load the contact + * picture for the supplied email address. + * + * @param email + * The email address to check the contacts database for. + * @param badge + * The {@code QuickContactBadge} instance that will receive the picture. + * + * @return {@code true}, if the contact picture should be loaded in a background thread. + * {@code false}, if another {@link ContactPictureRetrievalTask} was already scheduled + * to load that contact picture. + */ + private boolean cancelPotentialWork(String email, QuickContactBadge badge) { + final ContactPictureRetrievalTask task = getContactPictureRetrievalTask(badge); + + if (task != null && email != null) { + String emailFromTask = task.getEmail(); + if (!email.equals(emailFromTask)) { + // Cancel previous task + task.cancel(true); + } else { + // The same work is already in progress + return false; + } + } + + // No task associated with the QuickContactBadge, or an existing task was cancelled + return true; + } + + private ContactPictureRetrievalTask getContactPictureRetrievalTask(QuickContactBadge badge) { + if (badge != null) { + Drawable drawable = badge.getDrawable(); + if (drawable instanceof AsyncDrawable) { + AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getContactPictureRetrievalTask(); + } + } + + return null; + } + + + /** + * Load a contact picture in a background thread. + */ + class ContactPictureRetrievalTask extends AsyncTask { + private final WeakReference mQuickContactBadgeReference; + private String mEmail; + + ContactPictureRetrievalTask(QuickContactBadge badge) { + mQuickContactBadgeReference = new WeakReference(badge); + } + + public void exec(String... args) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, args); + } else { + execute(args); + } + } + + public String getEmail() { + return mEmail; + } + + @Override + protected Bitmap doInBackground(String... args) { + String email = args[0]; + mEmail = email; + final Uri x = mContactsHelper.getPhotoUri(email); + Bitmap bitmap = null; + if (x != null) { + try { + InputStream stream = mContentResolver.openInputStream(x); + if (stream != null) { + try { + bitmap = BitmapFactory.decodeStream(stream); + } finally { + try { stream.close(); } catch (IOException e) { /* ignore */ } + } + } + } catch (FileNotFoundException e) { + /* ignore */ + } + + } + + if (bitmap == null) { + bitmap = mDefaultPicture; + + // Remember that we don't have a contact picture for this email address + addEmailToUnknownContactsCache(email); + } else { + // Save the picture of the contact with that email address in the memory cache + addBitmapToCache(email, bitmap); + } + + return bitmap; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (mQuickContactBadgeReference != null) { + QuickContactBadge badge = mQuickContactBadgeReference.get(); + if (badge != null && getContactPictureRetrievalTask(badge) == this) { + badge.setImageBitmap(bitmap); + } + } + } + } + + /** + * {@code Drawable} subclass that stores a reference to the {@link ContactPictureRetrievalTask} + * that is trying to load the contact picture. + * + *

+ * The reference is used by {@link ContactPictureLoader#cancelPotentialWork(String, + * QuickContactBadge)} to find out if the contact picture is already being loaded by a + * {@code ContactPictureRetrievalTask}. + *

+ */ + static class AsyncDrawable extends BitmapDrawable { + private final WeakReference mAsyncTaskReference; + + public AsyncDrawable(Resources res, Bitmap bitmap, ContactPictureRetrievalTask task) { + super(res, bitmap); + mAsyncTaskReference = new WeakReference(task); + } + + public ContactPictureRetrievalTask getContactPictureRetrievalTask() { + return mAsyncTaskReference.get(); + } + } +} diff --git a/src/com/fsck/k9/fragment/MessageListFragment.java b/src/com/fsck/k9/fragment/MessageListFragment.java index ba2123334..a4f1c649c 100644 --- a/src/com/fsck/k9/fragment/MessageListFragment.java +++ b/src/com/fsck/k9/fragment/MessageListFragment.java @@ -54,6 +54,7 @@ import android.widget.CheckBox; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; import android.widget.ListView; +import android.widget.QuickContactBadge; import android.widget.TextView; import android.widget.Toast; @@ -74,6 +75,7 @@ import com.fsck.k9.activity.ActivityListener; import com.fsck.k9.activity.ChooseFolder; import com.fsck.k9.activity.FolderInfoHolder; import com.fsck.k9.activity.MessageReference; +import com.fsck.k9.activity.misc.ContactPictureLoader; import com.fsck.k9.controller.MessagingController; import com.fsck.k9.fragment.ConfirmationDialogFragment; import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener; @@ -428,6 +430,8 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick */ private boolean mInitialized = false; + private ContactPictureLoader mContactsPictureLoader; + /** * This class is used to run operations that modify UI elements in the UI thread. * @@ -746,6 +750,9 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick mPreviewLines = K9.messageListPreviewLines(); mCheckboxes = K9.messageListCheckboxes(); + mContactsPictureLoader = new ContactPictureLoader(getActivity(), + R.drawable.ic_contact_picture); + restoreInstanceState(savedInstanceState); decodeArguments(); @@ -1726,6 +1733,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick holder.date = (TextView) view.findViewById(R.id.date); holder.chip = view.findViewById(R.id.chip); holder.preview = (TextView) view.findViewById(R.id.preview); + holder.contactBadge = (QuickContactBadge) view.findViewById(R.id.contact_badge); if (mSenderAboveSubject) { holder.from = (TextView) view.findViewById(R.id.subject); @@ -1772,6 +1780,17 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick CharSequence displayName = mMessageHelper.getDisplayName(account, fromAddrs, toAddrs); + String counterpartyAddress = null; + if (fromMe) { + if (toAddrs.length > 0) { + counterpartyAddress = toAddrs[0].getAddress(); + } else if (ccAddrs.length > 0) { + counterpartyAddress = ccAddrs[0].getAddress(); + } + } else if (fromAddrs.length > 0) { + counterpartyAddress = fromAddrs[0].getAddress(); + } + Date sentDate = new Date(cursor.getLong(DATE_COLUMN)); String displayDate = mMessageHelper.formatDate(sentDate); @@ -1819,6 +1838,15 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick holder.position = cursor.getPosition(); } + if (holder.contactBadge != null) { + holder.contactBadge.assignContactFromEmail(counterpartyAddress, true); + if (counterpartyAddress != null) { + mContactsPictureLoader.loadContactPicture(counterpartyAddress, holder.contactBadge); + } else { + holder.contactBadge.setImageResource(R.drawable.ic_contact_picture); + } + } + // Background indicator if (K9.useBackgroundAsUnreadIndicator()) { int res = (read) ? R.attr.messageListReadItemBackgroundColor : @@ -1939,6 +1967,7 @@ public class MessageListFragment extends SherlockFragment implements OnItemClick public TextView threadCount; public CheckBox selected; public int position = -1; + public QuickContactBadge contactBadge; @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { diff --git a/src/com/fsck/k9/helper/Contacts.java b/src/com/fsck/k9/helper/Contacts.java index 0923c1446..6e8f9fbb4 100644 --- a/src/com/fsck/k9/helper/Contacts.java +++ b/src/com/fsck/k9/helper/Contacts.java @@ -3,6 +3,7 @@ package com.fsck.k9.helper; import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; +import android.net.Uri; import android.os.Build; import android.content.Intent; import com.fsck.k9.mail.Address; @@ -157,6 +158,18 @@ public abstract class Contacts { */ public abstract ContactItem extractInfoFromContactPickerIntent(final Intent intent); + /** + * Get URI to the picture of the contact with the supplied email address. + * + * @param address + * An email address. The contact database is searched for a contact with this email + * address. + * + * @return URI to the picture of the contact with the supplied email address. {@code null} if + * no such contact could be found or the contact doesn't have a picture. + */ + public abstract Uri getPhotoUri(String address); + /** * Does the device actually have a Contacts application suitable for * picking a contact. As hard as it is to believe, some vendors ship diff --git a/src/com/fsck/k9/helper/ContactsSdk5.java b/src/com/fsck/k9/helper/ContactsSdk5.java index d9dd03c22..e9efef10e 100644 --- a/src/com/fsck/k9/helper/ContactsSdk5.java +++ b/src/com/fsck/k9/helper/ContactsSdk5.java @@ -2,6 +2,7 @@ package com.fsck.k9.helper; import java.util.ArrayList; +import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.database.Cursor; @@ -258,4 +259,47 @@ public class ContactsSdk5 extends com.fsck.k9.helper.Contacts { SORT_ORDER); return c; } + + @Override + public Uri getPhotoUri(String address) { + Long contactId; + try { + final Cursor c = getContactByAddress(address); + if (c == null) { + return null; + } + + try { + if (!c.moveToFirst()) { + return null; + } + + contactId = c.getLong(CONTACT_ID_INDEX); + } finally { + c.close(); + } + + Cursor cur = mContentResolver.query( + ContactsContract.Data.CONTENT_URI, + null, + ContactsContract.Data.CONTACT_ID + "=" + contactId + " AND " + + ContactsContract.Data.MIMETYPE + "='" + + ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE + "'", null, + null); + if (cur == null) { + return null; + } + if (!cur.moveToFirst()) { + cur.close(); + return null; // no photo + } + // Ok, they have a photo + cur.close(); + Uri person = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contactId); + return Uri.withAppendedPath(person, ContactsContract.Contacts.Photo.CONTENT_DIRECTORY); + } catch (Exception e) { + Log.e(K9.LOG_TAG, "Couldn't fetch photo for contact with email " + address, e); + return null; + } + } }