Large photo storage.

This change adds support for storing large photos for contacts in the
file system.  Large photos passed to the provider will be downscaled
and re-encoded as JPEGs before being stored in the usual data BLOB
field (for the thumbnail) and in the photo store (for the display
photo).

See go/large-photo-design for details.

Change-Id: I26a69ac2ccba631962a3ac5c83edb3f45d7cfc7f
diff --git a/res/values/config.xml b/res/values/config.xml
index 096edf6..fc63a50 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -19,4 +19,10 @@
     <!-- Maximum size of photos inserted in social stream items -->
     <integer name="config_stream_item_photo_max_bytes">71680</integer>
 
+    <!-- Maximum dimension (height or width) of contact display photos -->
+    <integer name="config_max_display_photo_dim">256</integer>
+
+    <!-- Maximum dimension (height or width) of contact photo thumbnails -->
+    <integer name="config_max_thumbnail_photo_dim">96</integer>
+
 </resources>
diff --git a/src/com/android/providers/contacts/ContactAggregator.java b/src/com/android/providers/contacts/ContactAggregator.java
index c6d5493..de8d0ce 100644
--- a/src/com/android/providers/contacts/ContactAggregator.java
+++ b/src/com/android/providers/contacts/ContactAggregator.java
@@ -313,7 +313,7 @@
 
         mPhotoIdUpdate = db.compileStatement(
                 "UPDATE " + Tables.CONTACTS +
-                " SET " + Contacts.PHOTO_ID + "=? " +
+                " SET " + Contacts.PHOTO_ID + "=?," + Contacts.PHOTO_FILE_ID + "=? " +
                 " WHERE " + Contacts._ID + "=?");
 
         mDisplayNameUpdate = db.compileStatement(
@@ -1534,7 +1534,8 @@
                         + RawContacts.NAME_VERIFIED + ","
                         + DataColumns.CONCRETE_ID + ","
                         + DataColumns.CONCRETE_MIMETYPE_ID + ","
-                        + Data.IS_SUPER_PRIMARY +
+                        + Data.IS_SUPER_PRIMARY + ","
+                        + Photo.PHOTO_FILE_ID +
                 " FROM " + Tables.RAW_CONTACTS +
                 " LEFT OUTER JOIN " + Tables.DATA +
                 " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
@@ -1565,6 +1566,7 @@
         int DATA_ID = 12;
         int MIMETYPE_ID = 13;
         int IS_SUPER_PRIMARY = 14;
+        int PHOTO_FILE_ID = 15;
     }
 
     private interface ContactReplaceSqlStatement {
@@ -1573,6 +1575,7 @@
                 " SET "
                         + Contacts.NAME_RAW_CONTACT_ID + "=?, "
                         + Contacts.PHOTO_ID + "=?, "
+                        + Contacts.PHOTO_FILE_ID + "=?, "
                         + Contacts.SEND_TO_VOICEMAIL + "=?, "
                         + Contacts.CUSTOM_RINGTONE + "=?, "
                         + Contacts.LAST_TIME_CONTACTED + "=?, "
@@ -1586,6 +1589,7 @@
                 "INSERT INTO " + Tables.CONTACTS + " ("
                         + Contacts.NAME_RAW_CONTACT_ID + ", "
                         + Contacts.PHOTO_ID + ", "
+                        + Contacts.PHOTO_FILE_ID + ", "
                         + Contacts.SEND_TO_VOICEMAIL + ", "
                         + Contacts.CUSTOM_RINGTONE + ", "
                         + Contacts.LAST_TIME_CONTACTED + ", "
@@ -1593,18 +1597,19 @@
                         + Contacts.STARRED + ", "
                         + Contacts.HAS_PHONE_NUMBER + ", "
                         + Contacts.LOOKUP_KEY + ") " +
-                " VALUES (?,?,?,?,?,?,?,?,?)";
+                " VALUES (?,?,?,?,?,?,?,?,?,?)";
 
         int NAME_RAW_CONTACT_ID = 1;
         int PHOTO_ID = 2;
-        int SEND_TO_VOICEMAIL = 3;
-        int CUSTOM_RINGTONE = 4;
-        int LAST_TIME_CONTACTED = 5;
-        int TIMES_CONTACTED = 6;
-        int STARRED = 7;
-        int HAS_PHONE_NUMBER = 8;
-        int LOOKUP_KEY = 9;
-        int CONTACT_ID = 10;
+        int PHOTO_FILE_ID = 3;
+        int SEND_TO_VOICEMAIL = 4;
+        int CUSTOM_RINGTONE = 5;
+        int LAST_TIME_CONTACTED = 6;
+        int TIMES_CONTACTED = 7;
+        int STARRED = 8;
+        int HAS_PHONE_NUMBER = 9;
+        int LOOKUP_KEY = 10;
+        int CONTACT_ID = 11;
     }
 
     /**
@@ -1623,6 +1628,7 @@
             SQLiteStatement statement) {
         long currentRawContactId = -1;
         long bestPhotoId = -1;
+        long bestPhotoFileId = 0;
         boolean foundSuperPrimaryPhoto = false;
         int photoPriority = -1;
         int totalRowCount = 0;
@@ -1691,6 +1697,7 @@
 
                 if (!c.isNull(RawContactsQuery.DATA_ID)) {
                     long dataId = c.getLong(RawContactsQuery.DATA_ID);
+                    long photoFileId = c.getLong(RawContactsQuery.PHOTO_FILE_ID);
                     int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID);
                     boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0;
                     if (mimetypeId == mMimeTypeIdPhoto) {
@@ -1700,6 +1707,7 @@
                             if (superPrimary || priority > photoPriority) {
                                 photoPriority = priority;
                                 bestPhotoId = dataId;
+                                bestPhotoFileId = photoFileId;
                                 foundSuperPrimaryPhoto |= superPrimary;
                             }
                         }
@@ -1721,6 +1729,12 @@
             statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID);
         }
 
+        if (bestPhotoFileId != 0) {
+            statement.bindLong(ContactReplaceSqlStatement.PHOTO_FILE_ID, bestPhotoFileId);
+        } else {
+            statement.bindNull(ContactReplaceSqlStatement.PHOTO_FILE_ID);
+        }
+
         statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL,
                 totalRowCount == contactSendToVoicemail ? 1 : 0);
         DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE,
@@ -1785,11 +1799,13 @@
             RawContacts.ACCOUNT_TYPE,
             DataColumns.CONCRETE_ID,
             Data.IS_SUPER_PRIMARY,
+            Photo.PHOTO_FILE_ID,
         };
 
         int ACCOUNT_TYPE = 0;
         int DATA_ID = 1;
         int IS_SUPER_PRIMARY = 2;
+        int PHOTO_FILE_ID = 3;
     }
 
     public void updatePhotoId(SQLiteDatabase db, long rawContactId) {
@@ -1800,6 +1816,7 @@
         }
 
         long bestPhotoId = -1;
+        long bestPhotoFileId = 0;
         int photoPriority = -1;
 
         long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
@@ -1815,9 +1832,11 @@
         try {
             while (c.moveToNext()) {
                 long dataId = c.getLong(PhotoIdQuery.DATA_ID);
+                long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID);
                 boolean superprimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0;
                 if (superprimary) {
                     bestPhotoId = dataId;
+                    bestPhotoFileId = photoFileId;
                     break;
                 }
 
@@ -1826,6 +1845,7 @@
                 if (priority > photoPriority) {
                     photoPriority = priority;
                     bestPhotoId = dataId;
+                    bestPhotoFileId = photoFileId;
                 }
             }
         } finally {
@@ -1837,7 +1857,14 @@
         } else {
             mPhotoIdUpdate.bindLong(1, bestPhotoId);
         }
-        mPhotoIdUpdate.bindLong(2, contactId);
+
+        if (bestPhotoFileId == 0) {
+            mPhotoIdUpdate.bindNull(2);
+        } else {
+            mPhotoIdUpdate.bindLong(2, bestPhotoFileId);
+        }
+
+        mPhotoIdUpdate.bindLong(3, contactId);
         mPhotoIdUpdate.execute();
     }
 
diff --git a/src/com/android/providers/contacts/ContactsDatabaseHelper.java b/src/com/android/providers/contacts/ContactsDatabaseHelper.java
index 137fce6..10bc39c 100644
--- a/src/com/android/providers/contacts/ContactsDatabaseHelper.java
+++ b/src/com/android/providers/contacts/ContactsDatabaseHelper.java
@@ -57,10 +57,12 @@
 import android.provider.ContactsContract.Contacts.Photo;
 import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.DisplayPhoto;
 import android.provider.ContactsContract.DisplayNameSources;
 import android.provider.ContactsContract.FullNameStyle;
 import android.provider.ContactsContract.Groups;
 import android.provider.ContactsContract.PhoneticNameStyle;
+import android.provider.ContactsContract.PhotoFiles;
 import android.provider.ContactsContract.RawContacts;
 import android.provider.ContactsContract.Settings;
 import android.provider.ContactsContract.StatusUpdates;
@@ -100,7 +102,7 @@
      *   600-699 Ice Cream Sandwich
      * </pre>
      */
-    static final int DATABASE_VERSION = 607;
+    static final int DATABASE_VERSION = 608;
 
     private static final String DATABASE_NAME = "contacts2.db";
     private static final String DATABASE_PRESENCE = "presence_db";
@@ -110,6 +112,7 @@
         public static final String RAW_CONTACTS = "raw_contacts";
         public static final String STREAM_ITEMS = "stream_items";
         public static final String STREAM_ITEM_PHOTOS = "stream_item_photos";
+        public static final String PHOTO_FILES = "photo_files";
         public static final String PACKAGES = "packages";
         public static final String MIMETYPES = "mimetypes";
         public static final String PHONE_LOOKUP = "phone_lookup";
@@ -260,6 +263,8 @@
 
         public static final String CONCRETE_ID = Tables.CONTACTS + "." + BaseColumns._ID;
 
+        public static final String CONCRETE_PHOTO_FILE_ID = Tables.CONTACTS + "."
+                + Contacts.PHOTO_FILE_ID;
         public static final String CONCRETE_TIMES_CONTACTED = Tables.CONTACTS + "."
                 + Contacts.TIMES_CONTACTED;
         public static final String CONCRETE_LAST_TIME_CONTACTED = Tables.CONTACTS + "."
@@ -508,6 +513,13 @@
         String CONCRETE_ACTION_URI = Tables.STREAM_ITEM_PHOTOS + "." + StreamItemPhotos.ACTION_URI;
     }
 
+    public interface PhotoFilesColumns {
+        String CONCRETE_ID = Tables.PHOTO_FILES + "." + BaseColumns._ID;
+        String CONCRETE_HEIGHT = Tables.PHOTO_FILES + "." + PhotoFiles.HEIGHT;
+        String CONCRETE_WIDTH = Tables.PHOTO_FILES + "." + PhotoFiles.WIDTH;
+        String CONCRETE_FILESIZE = Tables.PHOTO_FILES + "." + PhotoFiles.FILESIZE;
+    }
+
     public interface PropertiesColumns {
         String PROPERTY_KEY = "property_key";
         String PROPERTY_VALUE = "property_value";
@@ -801,6 +813,7 @@
                 BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
                 Contacts.NAME_RAW_CONTACT_ID + " INTEGER REFERENCES raw_contacts(_id)," +
                 Contacts.PHOTO_ID + " INTEGER REFERENCES data(_id)," +
+                Contacts.PHOTO_FILE_ID + " INTEGER REFERENCES photo_files(_id)," +
                 Contacts.CUSTOM_RINGTONE + " TEXT," +
                 Contacts.SEND_TO_VOICEMAIL + " INTEGER NOT NULL DEFAULT 0," +
                 Contacts.TIMES_CONTACTED + " INTEGER NOT NULL DEFAULT 0," +
@@ -889,6 +902,12 @@
                 "FOREIGN KEY(" + StreamItemPhotos.STREAM_ITEM_ID + ") REFERENCES " +
                         Tables.STREAM_ITEMS + "(" + StreamItems._ID + "));");
 
+        db.execSQL("CREATE TABLE " + Tables.PHOTO_FILES + " (" +
+                PhotoFiles._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                PhotoFiles.HEIGHT + " INTEGER NOT NULL, " +
+                PhotoFiles.WIDTH + " INTEGER NOT NULL, " +
+                PhotoFiles.FILESIZE + " INTEGER NOT NULL);");
+
         // TODO readd the index and investigate a controlled use of it
 //        db.execSQL("CREATE INDEX raw_contacts_agg_index ON " + Tables.RAW_CONTACTS + " (" +
 //                RawContactsColumns.AGGREGATION_NEEDED +
@@ -1399,6 +1418,7 @@
                 + Contacts.NAME_RAW_CONTACT_ID + ", "
                 + Contacts.LOOKUP_KEY + ", "
                 + Contacts.PHOTO_ID + ", "
+                + Contacts.PHOTO_FILE_ID + ", "
                 + Clauses.CONTACT_VISIBLE + " AS " + Contacts.IN_VISIBLE_GROUP + ", "
                 + ContactsColumns.LAST_STATUS_UPDATE_ID;
 
@@ -1439,9 +1459,9 @@
                 + contactOptionColumns + ", "
                 + contactNameColumns + ", "
                 + baseContactColumns + ", "
-                + buildPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
+                + buildDisplayPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
                         Contacts.PHOTO_URI) + ", "
-                + buildPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
+                + buildThumbnailPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
                         Contacts.PHOTO_THUMBNAIL_URI) + ", "
                 + "EXISTS (SELECT 1 FROM " + Tables.ACCOUNTS +
                     " WHERE " + DataColumns.CONCRETE_RAW_CONTACT_ID +
@@ -1513,8 +1533,8 @@
         String contactsSelect = "SELECT "
                 + ContactsColumns.CONCRETE_ID + " AS " + Contacts._ID + ","
                 + contactsColumns + ", "
-                + buildPhotoUriAlias(ContactsColumns.CONCRETE_ID, Contacts.PHOTO_URI) + ", "
-                + buildPhotoUriAlias(ContactsColumns.CONCRETE_ID,
+                + buildDisplayPhotoUriAlias(ContactsColumns.CONCRETE_ID, Contacts.PHOTO_URI) + ", "
+                + buildThumbnailPhotoUriAlias(ContactsColumns.CONCRETE_ID,
                         Contacts.PHOTO_THUMBNAIL_URI) + ", "
                 + "EXISTS (SELECT 1 FROM " + Tables.ACCOUNTS +
                     " JOIN " + Tables.RAW_CONTACTS + " ON " + RawContactsColumns.CONCRETE_ID + "=" +
@@ -1567,9 +1587,9 @@
                 + dataColumns + ", "
                 + syncColumns + ", "
                 + contactsColumns + ", "
-                + buildPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
+                + buildDisplayPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
                         Contacts.PHOTO_URI) + ", "
-                + buildPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
+                + buildThumbnailPhotoUriAlias(RawContactsColumns.CONCRETE_CONTACT_ID,
                         Contacts.PHOTO_THUMBNAIL_URI) + ", "
                 + "EXISTS (SELECT 1 FROM " + Tables.ACCOUNTS +
                     " JOIN " + Tables.RAW_CONTACTS + " ON " + RawContactsColumns.CONCRETE_ID + "=" +
@@ -1623,11 +1643,24 @@
         db.execSQL("CREATE VIEW " + Views.DATA_USAGE_STAT + " AS " + dataUsageStatSelect);
     }
 
-    private static String buildPhotoUriAlias(String contactIdColumn, String alias) {
-        return "(CASE WHEN " + Contacts.PHOTO_ID + " IS NULL"
+    private static String buildDisplayPhotoUriAlias(String contactIdColumn, String alias) {
+        return "(CASE WHEN " + Contacts.PHOTO_FILE_ID + " IS NULL THEN (CASE WHEN "
+                + Contacts.PHOTO_ID + " IS NULL"
                 + " OR " + Contacts.PHOTO_ID + "=0"
                 + " THEN NULL"
-                + " ELSE " + "'" + Contacts.CONTENT_URI + "/'||"
+                + " ELSE '" + Contacts.CONTENT_URI + "/'||"
+                        + contactIdColumn + "|| '/" + Photo.CONTENT_DIRECTORY + "'"
+                + " END) ELSE '" + DisplayPhoto.CONTENT_URI + "/'||"
+                        + Contacts.PHOTO_FILE_ID + " END)"
+                + " AS " + alias;
+    }
+
+    private static String buildThumbnailPhotoUriAlias(String contactIdColumn, String alias) {
+        return "(CASE WHEN "
+                + Contacts.PHOTO_ID + " IS NULL"
+                + " OR " + Contacts.PHOTO_ID + "=0"
+                + " THEN NULL"
+                + " ELSE '" + Contacts.CONTENT_URI + "/'||"
                         + contactIdColumn + "|| '/" + Photo.CONTENT_DIRECTORY + "'"
                 + " END)"
                 + " AS " + alias;
@@ -1985,6 +2018,7 @@
         }
 
         if (oldVersion < 605) {
+            upgradeViewsAndTriggers = true;
             upgradeToVersion605(db);
             oldVersion = 605;
         }
@@ -2002,6 +2036,13 @@
             oldVersion = 607;
         }
 
+        if (oldVersion < 608) {
+            upgradeViewsAndTriggers = true;
+            upgradeToVersion608(db);
+            oldVersion = 608;
+        }
+
+
         if (upgradeViewsAndTriggers) {
             createContactsViews(db);
             createGroupsView(db);
@@ -3130,6 +3171,16 @@
         db.execSQL("ALTER TABLE groups ADD COLUMN action_uri TEXT");
     }
 
+    private void upgradeToVersion608(SQLiteDatabase db) {
+        db.execSQL("ALTER TABLE contacts ADD photo_file_id INTEGER REFERENCES photo_files(_id);");
+
+        db.execSQL("CREATE TABLE photo_files(" +
+                "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                "height INTEGER NOT NULL, " +
+                "width INTEGER NOT NULL, " +
+                "filesize INTEGER NOT NULL);");
+    }
+
     public String extractHandleFromEmailAddress(String email) {
         Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(email);
         if (tokens.length == 0) {
@@ -3263,6 +3314,7 @@
         db.execSQL("DELETE FROM " + Tables.RAW_CONTACTS + ";");
         db.execSQL("DELETE FROM " + Tables.STREAM_ITEMS + ";");
         db.execSQL("DELETE FROM " + Tables.STREAM_ITEM_PHOTOS + ";");
+        db.execSQL("DELETE FROM " + Tables.PHOTO_FILES + ";");
         db.execSQL("DELETE FROM " + Tables.DATA + ";");
         db.execSQL("DELETE FROM " + Tables.PHONE_LOOKUP + ";");
         db.execSQL("DELETE FROM " + Tables.NAME_LOOKUP + ";");
diff --git a/src/com/android/providers/contacts/ContactsProvider2.java b/src/com/android/providers/contacts/ContactsProvider2.java
index 7bd8726..e62aa87 100644
--- a/src/com/android/providers/contacts/ContactsProvider2.java
+++ b/src/com/android/providers/contacts/ContactsProvider2.java
@@ -39,8 +39,8 @@
 import com.android.providers.contacts.ContactsDatabaseHelper.SearchIndexColumns;
 import com.android.providers.contacts.ContactsDatabaseHelper.SettingsColumns;
 import com.android.providers.contacts.ContactsDatabaseHelper.StatusUpdatesColumns;
-import com.android.providers.contacts.ContactsDatabaseHelper.StreamItemsColumns;
 import com.android.providers.contacts.ContactsDatabaseHelper.StreamItemPhotosColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.StreamItemsColumns;
 import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
 import com.android.providers.contacts.ContactsDatabaseHelper.Views;
 import com.android.providers.contacts.util.DbQueryUtils;
@@ -81,6 +81,8 @@
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteDoneException;
 import android.database.sqlite.SQLiteQueryBuilder;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
 import android.net.Uri;
 import android.net.Uri.Builder;
 import android.os.Binder;
@@ -114,6 +116,7 @@
 import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.DataUsageFeedback;
 import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.DisplayPhoto;
 import android.provider.ContactsContract.Groups;
 import android.provider.ContactsContract.Intents;
 import android.provider.ContactsContract.PhoneLookup;
@@ -122,8 +125,8 @@
 import android.provider.ContactsContract.SearchSnippetColumns;
 import android.provider.ContactsContract.Settings;
 import android.provider.ContactsContract.StatusUpdates;
-import android.provider.ContactsContract.StreamItems;
 import android.provider.ContactsContract.StreamItemPhotos;
+import android.provider.ContactsContract.StreamItems;
 import android.provider.LiveFolders;
 import android.provider.OpenableColumns;
 import android.provider.SyncStateContract;
@@ -134,7 +137,11 @@
 
 import java.io.BufferedWriter;
 import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
 import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
@@ -172,6 +179,7 @@
     private static final int BACKGROUND_TASK_UPDATE_PROVIDER_STATUS = 7;
     private static final int BACKGROUND_TASK_UPDATE_DIRECTORIES = 8;
     private static final int BACKGROUND_TASK_CHANGE_LOCALE = 9;
+    private static final int BACKGROUND_TASK_CLEANUP_PHOTOS = 10;
 
     /** Default for the maximum number of returned aggregation suggestions. */
     private static final int DEFAULT_MAX_SUGGESTIONS = 5;
@@ -179,6 +187,9 @@
     /** Limit for the maximum number of social stream items to store under a raw contact. */
     private static final int MAX_STREAM_ITEMS_PER_RAW_CONTACT = 5;
 
+    /** Rate limit (in ms) for photo cleanup.  Do it at most once per day. */
+    private static final int PHOTO_CLEANUP_RATE_LIMIT = 24 * 60 * 60 * 1000;
+
     /**
      * Property key for the legacy contact import version. The need for a version
      * as opposed to a boolean flag is that if we discover bugs in the contact import process,
@@ -234,22 +245,26 @@
     private static final int CONTACTS_STREQUENT_FILTER = 1007;
     private static final int CONTACTS_GROUP = 1008;
     private static final int CONTACTS_ID_PHOTO = 1009;
-    private static final int CONTACTS_AS_VCARD = 1010;
-    private static final int CONTACTS_AS_MULTI_VCARD = 1011;
-    private static final int CONTACTS_LOOKUP_DATA = 1012;
-    private static final int CONTACTS_LOOKUP_ID_DATA = 1013;
-    private static final int CONTACTS_ID_ENTITIES = 1014;
-    private static final int CONTACTS_LOOKUP_ENTITIES = 1015;
-    private static final int CONTACTS_LOOKUP_ID_ENTITIES = 1016;
-    private static final int CONTACTS_ID_STREAM_ITEMS = 1017;
-    private static final int CONTACTS_LOOKUP_STREAM_ITEMS = 1018;
-    private static final int CONTACTS_LOOKUP_ID_STREAM_ITEMS = 1019;
+    private static final int CONTACTS_ID_DISPLAY_PHOTO = 1010;
+    private static final int CONTACTS_LOOKUP_DISPLAY_PHOTO = 1011;
+    private static final int CONTACTS_LOOKUP_ID_DISPLAY_PHOTO = 1012;
+    private static final int CONTACTS_AS_VCARD = 1013;
+    private static final int CONTACTS_AS_MULTI_VCARD = 1014;
+    private static final int CONTACTS_LOOKUP_DATA = 1015;
+    private static final int CONTACTS_LOOKUP_ID_DATA = 1016;
+    private static final int CONTACTS_ID_ENTITIES = 1017;
+    private static final int CONTACTS_LOOKUP_ENTITIES = 1018;
+    private static final int CONTACTS_LOOKUP_ID_ENTITIES = 1019;
+    private static final int CONTACTS_ID_STREAM_ITEMS = 1020;
+    private static final int CONTACTS_LOOKUP_STREAM_ITEMS = 1021;
+    private static final int CONTACTS_LOOKUP_ID_STREAM_ITEMS = 1022;
 
     private static final int RAW_CONTACTS = 2002;
     private static final int RAW_CONTACTS_ID = 2003;
     private static final int RAW_CONTACTS_DATA = 2004;
     private static final int RAW_CONTACT_ENTITY_ID = 2005;
-    private static final int RAW_CONTACTS_ID_STREAM_ITEMS = 2006;
+    private static final int RAW_CONTACTS_ID_DISPLAY_PHOTO = 2006;
+    private static final int RAW_CONTACTS_ID_STREAM_ITEMS = 2007;
 
     private static final int DATA = 3000;
     private static final int DATA_ID = 3001;
@@ -318,6 +333,9 @@
     private static final int STREAM_ITEMS_ID_PHOTOS_ID = 21004;
     private static final int STREAM_ITEMS_LIMIT = 21005;
 
+    private static final int DISPLAY_PHOTO = 22000;
+    private static final int PHOTO_DIMENSIONS = 22001;
+
     private static final String SELECTION_FAVORITES_GROUPS_BY_RAW_CONTACT_ID =
             RawContactsColumns.CONCRETE_ID + "=? AND "
                     + GroupsColumns.CONCRETE_ACCOUNT_NAME
@@ -501,6 +519,7 @@
             .add(Contacts.PHONETIC_NAME)
             .add(Contacts.PHONETIC_NAME_STYLE)
             .add(Contacts.PHOTO_ID)
+            .add(Contacts.PHOTO_FILE_ID)
             .add(Contacts.PHOTO_URI)
             .add(Contacts.PHOTO_THUMBNAIL_URI)
             .add(Contacts.SEND_TO_VOICEMAIL)
@@ -947,6 +966,8 @@
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/suggestions/*",
                 AGGREGATION_SUGGESTIONS);
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/photo", CONTACTS_ID_PHOTO);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/display_photo",
+                CONTACTS_ID_DISPLAY_PHOTO);
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/stream_items",
                 CONTACTS_ID_STREAM_ITEMS);
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/filter", CONTACTS_FILTER);
@@ -956,6 +977,10 @@
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/#", CONTACTS_LOOKUP_ID);
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/#/data",
                 CONTACTS_LOOKUP_ID_DATA);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/display_photo",
+                CONTACTS_LOOKUP_DISPLAY_PHOTO);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/#/display_photo",
+                CONTACTS_LOOKUP_ID_DISPLAY_PHOTO);
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/entities",
                 CONTACTS_LOOKUP_ENTITIES);
         matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/#/entities",
@@ -975,6 +1000,8 @@
         matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts", RAW_CONTACTS);
         matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#", RAW_CONTACTS_ID);
         matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#/data", RAW_CONTACTS_DATA);
+        matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#/display_photo",
+                RAW_CONTACTS_ID_DISPLAY_PHOTO);
         matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#/entity", RAW_CONTACT_ENTITY_ID);
         matcher.addURI(ContactsContract.AUTHORITY, "raw_contacts/#/stream_items",
                 RAW_CONTACTS_ID_STREAM_ITEMS);
@@ -1061,6 +1088,9 @@
                 STREAM_ITEMS_ID_PHOTOS_ID);
         matcher.addURI(ContactsContract.AUTHORITY, "stream_items_limit", STREAM_ITEMS_LIMIT);
 
+        matcher.addURI(ContactsContract.AUTHORITY, "display_photo/*", DISPLAY_PHOTO);
+        matcher.addURI(ContactsContract.AUTHORITY, "photo_dimensions", PHOTO_DIMENSIONS);
+
         HashMap<String, Integer> tmpTypeMap = new HashMap<String, Integer>();
         tmpTypeMap.put(DataUsageFeedback.USAGE_TYPE_CALL, DataUsageStatColumns.USAGE_TYPE_INT_CALL);
         tmpTypeMap.put(DataUsageFeedback.USAGE_TYPE_LONG_TEXT,
@@ -1149,9 +1179,22 @@
     /** Limit for the maximum byte size of social stream item photos (loaded from config.xml). */
     private int mMaxStreamItemPhotoSizeBytes;
 
+    /**
+     * Maximum dimension (height or width) of display photos.  Larger images will be scaled
+     * to fit.
+     */
+    private int mMaxDisplayPhotoDim;
+
+    /**
+     * Maximum dimension (height or width) of photo thumbnails.
+     */
+    private int mMaxThumbnailPhotoDim;
+
     private HashMap<String, DataRowHandler> mDataRowHandlers;
     private ContactsDatabaseHelper mDbHelper;
 
+    private PhotoStore mPhotoStore;
+
     private NameSplitter mNameSplitter;
     private NameLookupBuilder mNameLookupBuilder;
 
@@ -1187,6 +1230,8 @@
     private HandlerThread mBackgroundThread;
     private Handler mBackgroundHandler;
 
+    private long mLastPhotoCleanup = 0;
+
     @Override
     public boolean onCreate() {
         super.onCreate();
@@ -1205,11 +1250,16 @@
         Resources resources = getContext().getResources();
         mMaxStreamItemPhotoSizeBytes = resources.getInteger(
                 R.integer.config_stream_item_photo_max_bytes);
+        mMaxDisplayPhotoDim = resources.getInteger(
+                R.integer.config_max_display_photo_dim);
+        mMaxThumbnailPhotoDim = resources.getInteger(
+                R.integer.config_max_thumbnail_photo_dim);
 
         mProfileIdCache = new ProfileIdCache();
         mDbHelper = (ContactsDatabaseHelper)getDatabaseHelper();
         mContactDirectoryManager = new ContactDirectoryManager(this);
         mGlobalSearchSupport = new GlobalSearchSupport(this);
+        mPhotoStore = new PhotoStore(getContext().getFilesDir(), mDbHelper);
 
         // The provider is closed for business until fully initialized
         mReadAccessLatch = new CountDownLatch(1);
@@ -1233,6 +1283,7 @@
         scheduleBackgroundTask(BACKGROUND_TASK_UPDATE_SEARCH_INDEX);
         scheduleBackgroundTask(BACKGROUND_TASK_UPDATE_PROVIDER_STATUS);
         scheduleBackgroundTask(BACKGROUND_TASK_OPEN_WRITE_ACCESS);
+        scheduleBackgroundTask(BACKGROUND_TASK_CLEANUP_PHOTOS);
 
         return true;
     }
@@ -1276,7 +1327,7 @@
                 new DataRowHandlerForGroupMembership(context, mDbHelper, mContactAggregator,
                         mGroupIdCache));
         mDataRowHandlers.put(Photo.CONTENT_ITEM_TYPE,
-                new DataRowHandlerForPhoto(context, mDbHelper, mContactAggregator));
+                new DataRowHandlerForPhoto(context, mDbHelper, mContactAggregator, mPhotoStore));
         mDataRowHandlers.put(Note.CONTENT_ITEM_TYPE,
                 new DataRowHandlerForNote(context, mDbHelper, mContactAggregator));
     }
@@ -1367,6 +1418,16 @@
                 }
                 break;
             }
+
+            case BACKGROUND_TASK_CLEANUP_PHOTOS: {
+                // Check rate limit.
+                long now = System.currentTimeMillis();
+                if (now - mLastPhotoCleanup > PHOTO_CLEANUP_RATE_LIMIT) {
+                    mLastPhotoCleanup = now;
+                    cleanupPhotoStore();
+                    break;
+                }
+            }
         }
     }
 
@@ -1451,11 +1512,65 @@
     }
 
     /* Visible for testing */
+    protected void cleanupPhotoStore() {
+        // Assemble the set of photo store keys that are in use, and send those to the photo
+        // store.  Any photos that aren't in that set will be deleted, and any photos that no
+        // longer exist in the photo store will be returned for us to clear out in the DB.
+        Cursor c = mDb.query(Views.DATA, new String[]{Data._ID, Photo.PHOTO_FILE_ID},
+                Data.MIMETYPE + "=" + Photo.MIMETYPE + " AND "
+                        + Photo.PHOTO_FILE_ID + " IS NOT NULL", null, null, null, null);
+        Set<Long> usedKeys = Sets.newHashSet();
+        Map<Long, List<Long>> keysToIdList = Maps.newHashMap();
+        try {
+            while (c.moveToNext()) {
+                long id = c.getLong(0);
+                long key = c.getLong(1);
+                usedKeys.add(key);
+                List<Long> ids = keysToIdList.get(key);
+                if (ids == null) {
+                    ids = Lists.newArrayList();
+                }
+                ids.add(id);
+                keysToIdList.put(key, ids);
+            }
+        } finally {
+            c.close();
+        }
+
+        // Run the photo store cleanup.
+        Set<Long> missingKeys = mPhotoStore.cleanup(usedKeys);
+
+        // If any of the keys we're using no longer exist, clean them up.
+        if (!missingKeys.isEmpty()) {
+            ArrayList<ContentProviderOperation> ops = Lists.newArrayList();
+            for (long key : missingKeys) {
+                for (long id : keysToIdList.get(key)) {
+                    ContentValues updateValues = new ContentValues();
+                    updateValues.putNull(Photo.PHOTO_FILE_ID);
+                    ops.add(ContentProviderOperation.newUpdate(
+                            ContentUris.withAppendedId(Data.CONTENT_URI, id))
+                            .withValues(updateValues).build());
+                }
+            }
+            try {
+                applyBatch(ops);
+            } catch (OperationApplicationException oae) {
+                // Not a fatal problem (and we'll try again on the next cleanup).
+                Log.e(TAG, "Failed to clean up outdated photo references", oae);
+            }
+        }
+    }
+
+    /* Visible for testing */
     @Override
     protected ContactsDatabaseHelper getDatabaseHelper(final Context context) {
         return ContactsDatabaseHelper.getInstance(context);
     }
 
+    /* package */ PhotoStore getPhotoStore() {
+        return mPhotoStore;
+    }
+
     /* package */ NameSplitter getNameSplitter() {
         return mNameSplitter;
     }
@@ -1567,6 +1682,7 @@
      */
     /* package */ void wipeData() {
         mDbHelper.wipeData();
+        mPhotoStore.clear();
         mProviderStatus = ProviderStatus.STATUS_NO_ACCOUNTS_NO_CONTACTS;
     }
 
@@ -3507,7 +3623,11 @@
 
         // Note that the query will return data according to the access restrictions,
         // so we don't need to worry about updating data we don't have permission to read.
-        Cursor c = query(uri, DataRowHandler.DataUpdateQuery.COLUMNS,
+        // This query will be allowed to return profiles, and we'll do the permission check
+        // within the loop.
+        Cursor c = query(uri.buildUpon()
+                .appendQueryParameter(ContactsContract.ALLOW_PROFILE, "1").build(),
+                DataRowHandler.DataUpdateQuery.COLUMNS,
                 selection, selectionArgs, null);
         try {
             while(c.moveToNext()) {
@@ -3531,11 +3651,12 @@
 
         final String mimeType = c.getString(DataRowHandler.DataUpdateQuery.MIMETYPE);
         DataRowHandler rowHandler = getDataRowHandler(mimeType);
-        if (rowHandler.update(mDb, mTransactionContext, values, c, callerIsSyncAdapter)) {
-            return 1;
-        } else {
-            return 0;
+        boolean updated =
+                rowHandler.update(mDb, mTransactionContext, values, c, callerIsSyncAdapter);
+        if (Photo.CONTENT_ITEM_TYPE.equals(mimeType)) {
+            scheduleBackgroundTask(BACKGROUND_TASK_CLEANUP_PHOTOS);
         }
+        return updated ? 1 : 0;
     }
 
     private int updateContactOptions(ContentValues values, String selection,
@@ -4305,6 +4426,7 @@
 
             case CONTACTS_ID_PHOTO: {
                 long contactId = Long.parseLong(uri.getPathSegments().get(1));
+                enforceProfilePermissionForContact(contactId, false);
                 setTablesAndProjectionMapForData(qb, uri, projection, false);
                 selectionArgs = insertSelectionArg(selectionArgs, String.valueOf(contactId));
                 qb.appendWhere(" AND " + RawContacts.CONTACT_ID + "=?");
@@ -4398,6 +4520,14 @@
                 break;
             }
 
+            case PHOTO_DIMENSIONS: {
+                MatrixCursor cursor = new MatrixCursor(
+                        new String[]{DisplayPhoto.DISPLAY_MAX_DIM, DisplayPhoto.THUMBNAIL_MAX_DIM},
+                        1);
+                cursor.addRow(new Object[]{mMaxDisplayPhotoDim, mMaxThumbnailPhotoDim});
+                return cursor;
+            }
+
             case PHONES: {
                 setTablesAndProjectionMapForData(qb, uri, projection, false);
                 qb.appendWhere(" AND " + Data.MIMETYPE + " = '" + Phone.CONTENT_ITEM_TYPE + "'");
@@ -5827,7 +5957,11 @@
     @Override
     public AssetFileDescriptor openAssetFile(Uri uri, String mode) throws FileNotFoundException {
 
-        waitForAccess(mReadAccessLatch);
+        if (mode.equals("r")) {
+            waitForAccess(mReadAccessLatch);
+        } else {
+            waitForAccess(mWriteAccessLatch);
+        }
 
         int match = sUriMatcher.match(uri);
         switch (match) {
@@ -5840,6 +5974,118 @@
                         new String[]{String.valueOf(rawContactId)});
             }
 
+            case CONTACTS_ID_DISPLAY_PHOTO: {
+                if (!mode.equals("r")) {
+                    throw new IllegalArgumentException(
+                            "Display photos retrieved by contact ID can only be read.");
+                }
+                long contactId = Long.parseLong(uri.getPathSegments().get(1));
+                enforceProfilePermissionForContact(contactId, false);
+                Cursor c = mDb.query(Tables.CONTACTS,
+                        new String[]{Contacts.PHOTO_FILE_ID},
+                        Contacts._ID + "=?", new String[]{String.valueOf(contactId)},
+                        null, null, null);
+                try {
+                    c.moveToFirst();
+                    long photoFileId = c.getLong(0);
+                    return openDisplayPhotoForRead(photoFileId);
+                } finally {
+                    c.close();
+                }
+            }
+
+            case CONTACTS_LOOKUP_DISPLAY_PHOTO:
+            case CONTACTS_LOOKUP_ID_DISPLAY_PHOTO: {
+                if (!mode.equals("r")) {
+                    throw new IllegalArgumentException(
+                            "Display photos retrieved by contact lookup key can only be read.");
+                }
+                List<String> pathSegments = uri.getPathSegments();
+                int segmentCount = pathSegments.size();
+                if (segmentCount < 4) {
+                    throw new IllegalArgumentException(mDbHelper.exceptionMessage(
+                            "Missing a lookup key", uri));
+                }
+                String lookupKey = pathSegments.get(2);
+                String[] projection = new String[]{Contacts.PHOTO_FILE_ID};
+                if (segmentCount == 5) {
+                    long contactId = Long.parseLong(pathSegments.get(3));
+                    enforceProfilePermissionForContact(contactId, false);
+                    SQLiteQueryBuilder lookupQb = new SQLiteQueryBuilder();
+                    setTablesAndProjectionMapForContacts(lookupQb, uri, projection);
+                    Cursor c = queryWithContactIdAndLookupKey(lookupQb, mDb, uri,
+                            projection, null, null, null, null, null,
+                            Contacts._ID, contactId, Contacts.LOOKUP_KEY, lookupKey);
+                    if (c != null) {
+                        try {
+                            c.moveToFirst();
+                            long photoFileId = c.getLong(c.getColumnIndex(Contacts.PHOTO_FILE_ID));
+                            return openDisplayPhotoForRead(photoFileId);
+                        } finally {
+                            c.close();
+                        }
+                    }
+                }
+
+                SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+                setTablesAndProjectionMapForContacts(qb, uri, projection);
+                long contactId = lookupContactIdByLookupKey(mDb, lookupKey);
+                enforceProfilePermissionForContact(contactId, false);
+                Cursor c = qb.query(mDb, projection, Contacts._ID + "=?",
+                        new String[]{String.valueOf(contactId)}, null, null, null);
+                try {
+                    c.moveToFirst();
+                    long photoFileId = c.getLong(c.getColumnIndex(Contacts.PHOTO_FILE_ID));
+                    return openDisplayPhotoForRead(photoFileId);
+                } finally {
+                    c.close();
+                }
+            }
+
+            case RAW_CONTACTS_ID_DISPLAY_PHOTO: {
+                long rawContactId = Long.parseLong(uri.getPathSegments().get(1));
+                boolean writeable = !mode.equals("r");
+                enforceProfilePermissionForRawContact(rawContactId, writeable);
+
+                // Find the primary photo data record for this raw contact.
+                SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+                String[] projection = new String[]{Data._ID, Photo.PHOTO_FILE_ID};
+                setTablesAndProjectionMapForData(qb, uri, projection, false);
+                Cursor c = qb.query(mDb, projection,
+                        Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + "=?",
+                        new String[]{String.valueOf(rawContactId), Photo.CONTENT_ITEM_TYPE},
+                        null, null, Data.IS_PRIMARY + " DESC");
+                long dataId = 0;
+                long photoFileId = 0;
+                try {
+                    if (c.getCount() >= 1) {
+                        c.moveToFirst();
+                        dataId = c.getLong(0);
+                        photoFileId = c.getLong(1);
+                    }
+                } finally {
+                    c.close();
+                }
+
+                // If writeable, open a writeable file descriptor that we can monitor.
+                // When the caller finishes writing content, we'll process the photo and
+                // update the data record.
+                if (writeable) {
+                    return openDisplayPhotoForWrite(rawContactId, dataId, uri, mode);
+                } else {
+                    return openDisplayPhotoForRead(photoFileId);
+                }
+            }
+
+            case DISPLAY_PHOTO: {
+                long photoFileId = ContentUris.parseId(uri);
+                if (!mode.equals("r")) {
+                    throw new IllegalArgumentException(
+                            "Display photos retrieved by key can only be read.");
+                }
+                return openDisplayPhotoForRead(photoFileId);
+            }
+
             case DATA_ID: {
                 long dataId = Long.parseLong(uri.getPathSegments().get(1));
                 enforceProfilePermissionForData(dataId, false);
@@ -5931,6 +6177,121 @@
         }
     }
 
+    /**
+     * Opens a display photo from the photo store for reading.
+     * @param photoFileId The display photo file ID
+     * @return An asset file descriptor that allows the file to be read.
+     * @throws FileNotFoundException If no photo file for the given ID exists.
+     */
+    private AssetFileDescriptor openDisplayPhotoForRead(long photoFileId)
+            throws FileNotFoundException {
+        PhotoStore.Entry entry = mPhotoStore.get(photoFileId);
+        if (entry != null) {
+            return makeAssetFileDescriptor(
+                    ParcelFileDescriptor.open(new File(entry.path),
+                            ParcelFileDescriptor.MODE_READ_ONLY),
+                    entry.size);
+        } else {
+            scheduleBackgroundTask(BACKGROUND_TASK_CLEANUP_PHOTOS);
+            throw new FileNotFoundException("No photo file found for ID " + photoFileId);
+        }
+    }
+
+    /**
+     * Opens a file descriptor for a photo to be written.  When the caller completes writing
+     * to the file (closing the output stream), the image will be parsed out and processed.
+     * If processing succeeds, the given raw contact ID's primary photo record will be
+     * populated with the inserted image (if no primary photo record exists, the data ID can
+     * be left as 0, and a new data record will be inserted).
+     * @param rawContactId Raw contact ID this photo entry should be associated with.
+     * @param dataId Data ID for a photo mimetype that will be updated with the inserted
+     *     image.  May be set to 0, in which case the inserted image will trigger creation
+     *     of a new primary photo image data row for the raw contact.
+     * @param uri The URI being used to access this file.
+     * @param mode Read/write mode string.
+     * @return An asset file descriptor the caller can use to write an image file for the
+     *     raw contact.
+     */
+    private AssetFileDescriptor openDisplayPhotoForWrite(long rawContactId, long dataId, Uri uri,
+            String mode) {
+        try {
+            return new AssetFileDescriptor(new MonitoredParcelFileDescriptor(rawContactId, dataId,
+                    ParcelFileDescriptor.open(File.createTempFile("img", null),
+                            ContentResolver.modeToMode(uri, mode))),
+                    0, AssetFileDescriptor.UNKNOWN_LENGTH);
+        } catch (IOException ioe) {
+            Log.e(TAG, "Could not create temp image file in mode " + mode);
+            return null;
+        }
+    }
+
+    /**
+     * Parcel file descriptor wrapper that monitors when the file is closed.
+     * If the file contains a valid image, the image is either inserted into the given
+     * raw contact or updated in the given data row.
+     */
+    private class MonitoredParcelFileDescriptor extends ParcelFileDescriptor {
+        private final long mRawContactId;
+        private final long mDataId;
+        private MonitoredParcelFileDescriptor(long rawContactId, long dataId,
+                ParcelFileDescriptor descriptor) {
+            super(descriptor);
+            mRawContactId = rawContactId;
+            mDataId = dataId;
+        }
+
+        @Override
+        public void close() throws IOException {
+            try {
+                // Check to see whether a valid image was written out.
+                Bitmap b = BitmapFactory.decodeFileDescriptor(getFileDescriptor());
+                if (b != null) {
+                    PhotoProcessor processor = new PhotoProcessor(b, mMaxDisplayPhotoDim,
+                            mMaxThumbnailPhotoDim);
+
+                    // Store the compressed photo in the photo store.
+                    long photoFileId = mPhotoStore.insert(processor);
+
+                    // Depending on whether we already had a data row to attach the photo to,
+                    // do an update or insert.
+                    if (mDataId != 0) {
+                        // Update the data record with the new photo.
+                        ContentValues updateValues = new ContentValues();
+
+                        // Signal that photo processing has already been handled.
+                        updateValues.put(DataRowHandlerForPhoto.SKIP_PROCESSING_KEY, true);
+
+                        if (photoFileId != 0) {
+                            updateValues.put(Photo.PHOTO_FILE_ID, photoFileId);
+                        }
+                        updateValues.put(Photo.PHOTO, processor.getThumbnailPhotoBytes());
+                        update(ContentUris.withAppendedId(Data.CONTENT_URI, mDataId), updateValues,
+                                null, null);
+                    } else {
+                        // Insert a new primary data record with the photo.
+                        ContentValues insertValues = new ContentValues();
+
+                        // Signal that photo processing has already been handled.
+                        insertValues.put(DataRowHandlerForPhoto.SKIP_PROCESSING_KEY, true);
+
+                        insertValues.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+                        insertValues.put(Data.IS_PRIMARY, 1);
+                        if (photoFileId != 0) {
+                            insertValues.put(Photo.PHOTO_FILE_ID, photoFileId);
+                        }
+                        insertValues.put(Photo.PHOTO, processor.getThumbnailPhotoBytes());
+                        insert(RawContacts.CONTENT_URI.buildUpon()
+                                .appendPath(String.valueOf(mRawContactId))
+                                .appendPath(RawContacts.Data.CONTENT_DIRECTORY).build(),
+                                insertValues);
+                    }
+                }
+            } finally {
+                super.close();
+            }
+        }
+    }
+
     private static final String CONTACT_MEMORY_FILE_NAME = "contactAssetFile";
 
     /**
@@ -6019,7 +6380,12 @@
             case PROFILE_AS_VCARD:
                 return Contacts.CONTENT_VCARD_TYPE;
             case CONTACTS_ID_PHOTO:
-                return "image/png";
+            case CONTACTS_ID_DISPLAY_PHOTO:
+            case CONTACTS_LOOKUP_DISPLAY_PHOTO:
+            case CONTACTS_LOOKUP_ID_DISPLAY_PHOTO:
+            case RAW_CONTACTS_ID_DISPLAY_PHOTO:
+            case DISPLAY_PHOTO:
+                return "image/jpeg";
             case RAW_CONTACTS:
             case PROFILE_RAW_CONTACTS:
                 return RawContacts.CONTENT_TYPE;
diff --git a/src/com/android/providers/contacts/DataRowHandlerForPhoto.java b/src/com/android/providers/contacts/DataRowHandlerForPhoto.java
index 152c516..04f60e7 100644
--- a/src/com/android/providers/contacts/DataRowHandlerForPhoto.java
+++ b/src/com/android/providers/contacts/DataRowHandlerForPhoto.java
@@ -17,23 +17,52 @@
 
 import android.content.ContentValues;
 import android.content.Context;
+import android.content.res.Resources;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
+import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.util.Log;
+
+import java.io.IOException;
 
 /**
  * Handler for photo data rows.
  */
 public class DataRowHandlerForPhoto extends DataRowHandler {
 
+    private static final String TAG = "DataRowHandlerForPhoto";
+
+    private final PhotoStore mPhotoStore;
+
+    /**
+     * If this is set in the ContentValues passed in, it indicates that the caller has
+     * already taken care of photo processing, and that the row should be ready for
+     * insert/update.  This is used when the photo has been written directly to an
+     * asset file.
+     */
+    /* package */ static final String SKIP_PROCESSING_KEY = "skip_processing";
+
     public DataRowHandlerForPhoto(
-            Context context, ContactsDatabaseHelper dbHelper, ContactAggregator aggregator) {
+            Context context, ContactsDatabaseHelper dbHelper, ContactAggregator aggregator,
+            PhotoStore photoStore) {
         super(context, dbHelper, aggregator, Photo.CONTENT_ITEM_TYPE);
+        mPhotoStore = photoStore;
     }
 
     @Override
     public long insert(SQLiteDatabase db, TransactionContext txContext, long rawContactId,
             ContentValues values) {
+
+        if (values.containsKey(SKIP_PROCESSING_KEY)) {
+            values.remove(SKIP_PROCESSING_KEY);
+        } else {
+            // Pre-process the photo.
+            if (hasNonNullPhoto(values) && !processPhoto(values)) {
+                return 0;
+            }
+        }
+
         long dataId = super.insert(db, txContext, rawContactId, values);
         if (!txContext.isNewRawContact(rawContactId)) {
             mContactAggregator.updatePhotoId(db, rawContactId);
@@ -45,6 +74,17 @@
     public boolean update(SQLiteDatabase db, TransactionContext txContext, ContentValues values,
             Cursor c, boolean callerIsSyncAdapter) {
         long rawContactId = c.getLong(DataUpdateQuery.RAW_CONTACT_ID);
+
+        if (values.containsKey(SKIP_PROCESSING_KEY)) {
+            values.remove(SKIP_PROCESSING_KEY);
+        } else {
+            // Pre-process the photo if one exists.
+            if (hasNonNullPhoto(values) && !processPhoto(values)) {
+                return false;
+            }
+        }
+
+        // Do the actual update.
         if (!super.update(db, txContext, values, c, callerIsSyncAdapter)) {
             return false;
         }
@@ -53,6 +93,10 @@
         return true;
     }
 
+    private boolean hasNonNullPhoto(ContentValues values) {
+        return values.getAsByteArray(Photo.PHOTO) != null;
+    }
+
     @Override
     public int delete(SQLiteDatabase db, TransactionContext txContext, Cursor c) {
         long rawContactId = c.getLong(DataDeleteQuery.RAW_CONTACT_ID);
@@ -60,4 +104,36 @@
         mContactAggregator.updatePhotoId(db, rawContactId);
         return count;
     }
+
+    /**
+     * Reads the photo out of the given values object and processes it, placing the processed
+     * photos (a photo store file ID and a compressed thumbnail) back into the ContentValues
+     * object.
+     * @param values The values being inserted or updated - assumed to contain a photo BLOB.
+     * @return Whether an image was successfully decoded and processed.
+     */
+    private boolean processPhoto(ContentValues values) {
+        byte[] originalPhoto = values.getAsByteArray(Photo.PHOTO);
+        if (originalPhoto != null) {
+            int maxDisplayPhotoDim = mContext.getResources().getInteger(
+                    R.integer.config_max_display_photo_dim);
+            int maxThumbnailPhotoDim = mContext.getResources().getInteger(
+                    R.integer.config_max_thumbnail_photo_dim);
+            try {
+                PhotoProcessor processor = new PhotoProcessor(
+                        originalPhoto, maxDisplayPhotoDim, maxThumbnailPhotoDim);
+                long photoFileId = mPhotoStore.insert(processor);
+                if (photoFileId != 0) {
+                    values.put(Photo.PHOTO_FILE_ID, photoFileId);
+                } else {
+                    values.putNull(Photo.PHOTO_FILE_ID);
+                }
+                values.put(Photo.PHOTO, processor.getThumbnailPhotoBytes());
+                return true;
+            } catch (IOException ioe) {
+                Log.e(TAG, "Could not process photo for insert or update", ioe);
+            }
+        }
+        return false;
+    }
 }
diff --git a/src/com/android/providers/contacts/PhotoProcessor.java b/src/com/android/providers/contacts/PhotoProcessor.java
new file mode 100644
index 0000000..dc1ecbc
--- /dev/null
+++ b/src/com/android/providers/contacts/PhotoProcessor.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2011 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.providers.contacts;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Matrix;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * Class that converts a bitmap (or byte array representing a bitmap) into a display
+ * photo and a thumbnail photo.
+ */
+/* package-protected */ final class PhotoProcessor {
+
+    private final int mMaxDisplayPhotoDim;
+    private final int mMaxThumbnailPhotoDim;
+    private final Bitmap mOriginal;
+    private Bitmap mDisplayPhoto;
+    private Bitmap mThumbnailPhoto;
+
+    /**
+     * Initializes a photo processor for the given bitmap.
+     * @param original The bitmap to process.
+     * @param maxDisplayPhotoDim The maximum height and width for the display photo.
+     * @param maxThumbnailPhotoDim The maximum height and width for the thumbnail photo.
+     * @throws IOException If bitmap decoding or scaling fails.
+     */
+    public PhotoProcessor(Bitmap original, int maxDisplayPhotoDim, int maxThumbnailPhotoDim)
+            throws IOException {
+        mOriginal = original;
+        mMaxDisplayPhotoDim = maxDisplayPhotoDim;
+        mMaxThumbnailPhotoDim = maxThumbnailPhotoDim;
+        process();
+    }
+
+    /**
+     * Initializes a photo processor for the given bitmap.
+     * @param originalBytes A byte array to decode into a bitmap to process.
+     * @param maxDisplayPhotoDim The maximum height and width for the display photo.
+     * @param maxThumbnailPhotoDim The maximum height and width for the thumbnail photo.
+     * @throws IOException If bitmap decoding or scaling fails.
+     */
+    public PhotoProcessor(byte[] originalBytes, int maxDisplayPhotoDim, int maxThumbnailPhotoDim)
+            throws IOException {
+        this(BitmapFactory.decodeByteArray(originalBytes, 0, originalBytes.length),
+                maxDisplayPhotoDim, maxThumbnailPhotoDim);
+    }
+
+    /**
+     * Processes the original image, producing a scaled-down display photo and thumbnail photo.
+     * @throws IOException If bitmap decoding or scaling fails.
+     */
+    private void process() throws IOException {
+        if (mOriginal == null) {
+            throw new IOException("Invalid image file");
+        }
+        mDisplayPhoto = scale(mMaxDisplayPhotoDim);
+        mThumbnailPhoto = scale(mMaxThumbnailPhotoDim);
+    }
+
+    /**
+     * Scales down the original bitmap to fit within the given maximum width and height.
+     * If the bitmap already fits in those dimensions, the original bitmap will be
+     * returned unmodified.
+     * @param maxDim Maximum width and height (in pixels) for the image.
+     * @return A bitmap that fits the maximum dimensions.
+     */
+    private Bitmap scale(int maxDim) {
+        Bitmap b = mOriginal;
+        int width = mOriginal.getWidth();
+        int height = mOriginal.getHeight();
+        float scaleFactor = ((float) maxDim) / Math.max(width, height);
+        if (scaleFactor < 1.0) {
+            // Need to scale down the photo.
+            Matrix matrix = new Matrix();
+            matrix.setScale(scaleFactor, scaleFactor);
+            b = Bitmap.createBitmap(mOriginal, 0, 0, width, height, matrix, false);
+        }
+        return b;
+    }
+
+    /**
+     * Helper method to compress the given bitmap as a JPEG and return the resulting byte array.
+     */
+    private byte[] getCompressedBytes(Bitmap b) throws IOException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        boolean compressed = b.compress(Bitmap.CompressFormat.JPEG, 95, baos);
+        if (!compressed) {
+            throw new IOException("Unable to compress image");
+        }
+        baos.flush();
+        baos.close();
+        return baos.toByteArray();
+    }
+
+    /**
+     * Retrieves the uncompressed display photo.
+     */
+    public Bitmap getDisplayPhoto() {
+        return mDisplayPhoto;
+    }
+
+    /**
+     * Retrieves the uncompressed thumbnail photo.
+     */
+    public Bitmap getThumbnailPhoto() {
+        return mThumbnailPhoto;
+    }
+
+    /**
+     * Retrieves the compressed display photo as a byte array.
+     */
+    public byte[] getDisplayPhotoBytes() throws IOException {
+        return getCompressedBytes(mDisplayPhoto);
+    }
+
+    /**
+     * Retrieves the compressed thumbnail photo as a byte array.
+     */
+    public byte[] getThumbnailPhotoBytes() throws IOException {
+        return getCompressedBytes(mThumbnailPhoto);
+    }
+
+    /**
+     * Retrieves the maximum width or height (in pixels) of the display photo.
+     */
+    public int getMaxDisplayPhotoDim() {
+        return mMaxDisplayPhotoDim;
+    }
+
+    /**
+     * Retrieves the maximum width or height (in pixels) of the thumbnail.
+     */
+    public int getMaxThumbnailPhotoDim() {
+        return mMaxThumbnailPhotoDim;
+    }
+}
diff --git a/src/com/android/providers/contacts/PhotoStore.java b/src/com/android/providers/contacts/PhotoStore.java
new file mode 100644
index 0000000..1ed925d
--- /dev/null
+++ b/src/com/android/providers/contacts/PhotoStore.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2011 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.providers.contacts;
+
+import com.android.providers.contacts.ContactsDatabaseHelper.PhotoFilesColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
+
+import android.content.ContentValues;
+import android.database.sqlite.SQLiteDatabase;
+import android.graphics.Bitmap;
+import android.provider.ContactsContract.PhotoFiles;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Photo storage system that stores the files directly onto the hard disk
+ * in the specified directory.
+ */
+public class PhotoStore {
+
+    private final String TAG = PhotoStore.class.getSimpleName();
+
+    // Directory name under the root directory for photo storage.
+    private final String DIRECTORY = "photos";
+
+    /** Map of keys to entries in the directory. */
+    private final Map<Long, Entry> mEntries;
+
+    /** Total amount of space currently used by the photo store in bytes. */
+    private long mTotalSize = 0;
+
+    /** The file path for photo storage. */
+    private final File mStorePath;
+
+    /** The database helper. */
+    private final ContactsDatabaseHelper mDatabaseHelper;
+
+    /** The database to use for storing metadata for the photo files. */
+    private SQLiteDatabase mDb;
+
+    /**
+     * Constructs an instance of the PhotoStore under the specified directory.
+     * @param rootDirectory The root directory of the storage.
+     * @param databaseHelper Helper class for obtaining a database instance.
+     */
+    public PhotoStore(File rootDirectory, ContactsDatabaseHelper databaseHelper) {
+        mStorePath = new File(rootDirectory, DIRECTORY);
+        if (!mStorePath.exists()) {
+            if(!mStorePath.mkdirs()) {
+                throw new RuntimeException("Unable to create photo storage directory "
+                        + mStorePath.getPath());
+            }
+        }
+        mDatabaseHelper = databaseHelper;
+        mEntries = new HashMap<Long, Entry>();
+        initialize();
+    }
+
+    /**
+     * Clears the photo storage. Deletes all files from disk.
+     */
+    public synchronized void clear() {
+        File[] files = mStorePath.listFiles();
+        if (files != null) {
+            for (File file : files) {
+                cleanupFile(file);
+            }
+        }
+        mDb.delete(Tables.PHOTO_FILES, null, null);
+        mEntries.clear();
+        mTotalSize = 0;
+    }
+
+    public synchronized long getTotalSize() {
+        return mTotalSize;
+    }
+
+    /**
+     * Returns the entry with the specified key if it exists, null otherwise.
+     */
+    public synchronized Entry get(long key) {
+        return mEntries.get(key);
+    }
+
+    /**
+     * Initializes the PhotoStore by scanning for all files currently in the
+     * specified root directory.
+     */
+    public synchronized void initialize() {
+        File[] files = mStorePath.listFiles();
+        if (files == null) {
+            return;
+        }
+        for (File file : files) {
+            try {
+                Entry entry = new Entry(file);
+                putEntry(entry.id, entry);
+            } catch (NumberFormatException nfe) {
+                // Not a valid photo store entry - delete the file.
+                cleanupFile(file);
+            }
+        }
+
+        // Get a reference to the database.
+        mDb = mDatabaseHelper.getWritableDatabase();
+    }
+
+    /**
+     * Cleans up the photo store such that only the keys in use still remain as
+     * entries in the store (all other entries are deleted).
+     *
+     * If an entry in the keys in use does not exist in the photo store, that key
+     * will be returned in the result set - the caller should take steps to clean
+     * up those references, as the underlying photo entries do not exist.
+     *
+     * @param keysInUse The set of all keys that are in use in the photo store.
+     * @return The set of the keys in use that refer to non-existent entries.
+     */
+    public synchronized Set<Long> cleanup(Set<Long> keysInUse) {
+        Set<Long> keysToRemove = new HashSet<Long>();
+        keysToRemove.addAll(mEntries.keySet());
+        keysToRemove.removeAll(keysInUse);
+        if (!keysToRemove.isEmpty()) {
+            Log.d(TAG, "cleanup removing " + keysToRemove.size() + " entries");
+            for (long key : keysToRemove) {
+                remove(key);
+            }
+        }
+
+        Set<Long> missingKeys = new HashSet<Long>();
+        missingKeys.addAll(keysInUse);
+        missingKeys.removeAll(mEntries.keySet());
+        return missingKeys;
+    }
+
+    /**
+     * Inserts the photo in the given photo processor into the photo store.  If the display photo
+     * is already thumbnail-sized or smaller, this will do nothing (and will return 0).
+     * @param photoProcessor A photo processor containing the photo data to insert.
+     * @return The photo file ID associated with the file, or 0 if the file could not be created or
+     *     is thumbnail-sized or smaller.
+     */
+    public synchronized long insert(PhotoProcessor photoProcessor) {
+        Bitmap displayPhoto = photoProcessor.getDisplayPhoto();
+        int width = displayPhoto.getWidth();
+        int height = displayPhoto.getHeight();
+        int thumbnailDim = photoProcessor.getMaxThumbnailPhotoDim();
+        if (width > thumbnailDim || height > thumbnailDim) {
+            // The display photo is larger than a thumbnail, so write the photo to a temp file,
+            // create the DB record for tracking it, and rename the temp file to match.
+            File file = null;
+            try {
+                // Write the display photo to a temp file.
+                byte[] photoBytes = photoProcessor.getDisplayPhotoBytes();
+                file = File.createTempFile("img", null, mStorePath);
+                FileOutputStream fos = new FileOutputStream(file);
+                fos.write(photoProcessor.getDisplayPhotoBytes());
+                fos.close();
+
+                // Create the DB entry.
+                ContentValues values = new ContentValues();
+                values.put(PhotoFiles.HEIGHT, height);
+                values.put(PhotoFiles.WIDTH, width);
+                values.put(PhotoFiles.FILESIZE, photoBytes.length);
+                long id = mDb.insert(Tables.PHOTO_FILES, null, values);
+                if (id != 0) {
+                    // Rename the temp file.
+                    File target = getFileForPhotoFileId(id);
+                    if (file.renameTo(target)) {
+                        Entry entry = new Entry(target);
+                        putEntry(entry.id, entry);
+                        return id;
+                    }
+                }
+            } catch (IOException e) {
+                // Write failed - will delete the file below.
+            }
+
+            // If anything went wrong, clean up the file before returning.
+            if (file != null) {
+                cleanupFile(file);
+            }
+        }
+        return 0;
+    }
+
+    private void cleanupFile(File file) {
+        boolean deleted = file.delete();
+        if (!deleted) {
+            Log.d("Could not clean up file %s", file.getAbsolutePath());
+        }
+    }
+
+    /**
+     * Removes the specified photo file from the store if it exists.
+     */
+    public synchronized void remove(long id) {
+        cleanupFile(getFileForPhotoFileId(id));
+        removeEntry(id);
+    }
+
+    /**
+     * Returns a file object for the given photo file ID.
+     */
+    private File getFileForPhotoFileId(long id) {
+        return new File(mStorePath, String.valueOf(id));
+    }
+
+    /**
+     * Puts the entry with the specified photo file ID into the store.
+     * @param id The photo file ID to identify the entry by.
+     * @param entry The entry to store.
+     */
+    private void putEntry(long id, Entry entry) {
+        if (!mEntries.containsKey(id)) {
+            mTotalSize += entry.size;
+        } else {
+            Entry oldEntry = mEntries.get(id);
+            mTotalSize += (entry.size - oldEntry.size);
+        }
+        mEntries.put(id, entry);
+    }
+
+    /**
+     * Removes the entry identified by the given photo file ID from the store, removing
+     * the associated photo file entry from the database.
+     */
+    private void removeEntry(long id) {
+        Entry entry = mEntries.get(id);
+        if (entry != null) {
+            mTotalSize -= entry.size;
+            mEntries.remove(id);
+        }
+        mDb.delete(ContactsDatabaseHelper.Tables.PHOTO_FILES, PhotoFilesColumns.CONCRETE_ID + "=?",
+                new String[]{String.valueOf(id)});
+    }
+
+    public static class Entry {
+        /** The photo file ID that identifies the entry. */
+        public final long id;
+
+        /** The size of the data, in bytes. */
+        public final long size;
+
+        /** The path to the file. */
+        public final String path;
+
+        public Entry(File file) {
+            id = Long.parseLong(file.getName());
+            size = file.length();
+            path = file.getAbsolutePath();
+        }
+    }
+}
diff --git a/tests/res/drawable/earth_huge.png b/tests/res/drawable/earth_huge.png
new file mode 100644
index 0000000..bf79f04
--- /dev/null
+++ b/tests/res/drawable/earth_huge.png
Binary files differ
diff --git a/tests/res/drawable/earth_large.png b/tests/res/drawable/earth_large.png
new file mode 100644
index 0000000..c629348
--- /dev/null
+++ b/tests/res/drawable/earth_large.png
Binary files differ
diff --git a/tests/res/drawable/earth_normal.png b/tests/res/drawable/earth_normal.png
new file mode 100644
index 0000000..6311a59
--- /dev/null
+++ b/tests/res/drawable/earth_normal.png
Binary files differ
diff --git a/tests/res/drawable/earth_small.png b/tests/res/drawable/earth_small.png
new file mode 100644
index 0000000..198d7eb
--- /dev/null
+++ b/tests/res/drawable/earth_small.png
Binary files differ
diff --git a/tests/res/drawable/ic_contact_picture.png b/tests/res/drawable/ic_contact_picture.png
new file mode 100644
index 0000000..37b558b
--- /dev/null
+++ b/tests/res/drawable/ic_contact_picture.png
Binary files differ
diff --git a/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java b/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java
index 50cd50e..ea99caf 100644
--- a/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java
+++ b/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java
@@ -16,8 +16,7 @@
 
 package com.android.providers.contacts;
 
-import static com.android.providers.contacts.ContactsActor.PACKAGE_GREY;
-
+import com.google.android.collect.Maps;
 import com.google.android.collect.Sets;
 
 import android.accounts.Account;
@@ -50,7 +49,6 @@
 import android.provider.ContactsContract.Settings;
 import android.provider.ContactsContract.StatusUpdates;
 import android.provider.ContactsContract.StreamItems;
-import android.provider.ContactsContract.StreamItemPhotos;
 import android.test.AndroidTestCase;
 import android.test.MoreAsserts;
 import android.test.mock.MockContentResolver;
@@ -67,10 +65,12 @@
 import java.util.Map.Entry;
 import java.util.Set;
 
+import static com.android.providers.contacts.ContactsActor.PACKAGE_GREY;
+
 /**
  * A common superclass for {@link ContactsProvider2}-related tests.
  */
-public abstract class BaseContactsProvider2Test extends AndroidTestCase {
+public abstract class BaseContactsProvider2Test extends PhotoLoadingTestCase {
 
     protected static final String PACKAGE = "ContactsProvider2Test";
     public static final String READ_ONLY_ACCOUNT_TYPE =
@@ -81,8 +81,6 @@
     protected Account mAccount = new Account("account1", "account type1");
     protected Account mAccountTwo = new Account("account2", "account type2");
 
-    private byte[] mTestPhoto;
-
     protected final static Long NO_LONG = new Long(0);
     protected final static String NO_STRING = new String("");
     protected final static Account NO_ACCOUNT = new Account("a", "b");
@@ -363,6 +361,15 @@
         return resultUri;
     }
 
+    protected Uri insertPhoto(long rawContactId, int resourceId) {
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId);
+        values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+        values.put(Photo.PHOTO, loadPhotoFromResource(resourceId, PhotoSize.ORIGINAL));
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
     protected Uri insertGroupMembership(long rawContactId, String sourceId) {
         ContentValues values = new ContentValues();
         values.put(Data.RAW_CONTACT_ID, rawContactId);
@@ -830,6 +837,10 @@
         return value;
     }
 
+    protected Long getStoredLongValue(Uri uri, String column) {
+        return getStoredLongValue(uri, null, null, column);
+    }
+
     protected void assertStoredValues(Uri rowUri, ContentValues expectedValues) {
         assertStoredValues(rowUri, null, null, expectedValues);
     }
@@ -1025,26 +1036,6 @@
         }
     }
 
-    protected byte[] loadTestPhoto() {
-        if (mTestPhoto == null) {
-            final Resources resources = getContext().getResources();
-            InputStream is = resources
-                    .openRawResource(com.android.internal.R.drawable.ic_contact_picture);
-            ByteArrayOutputStream os = new ByteArrayOutputStream();
-            byte[] buffer = new byte[1000];
-            int count;
-            try {
-                while ((count = is.read(buffer)) != -1) {
-                    os.write(buffer, 0, count);
-                }
-            } catch (IOException e) {
-                throw new RuntimeException(e);
-            }
-            mTestPhoto = os.toByteArray();
-        }
-        return mTestPhoto;
-    }
-
     public static void dump(ContentResolver resolver, boolean aggregatedOnly) {
         String[] projection = new String[] {
                 Contacts._ID,
diff --git a/tests/src/com/android/providers/contacts/ContactsActor.java b/tests/src/com/android/providers/contacts/ContactsActor.java
index f22943e..865c956 100644
--- a/tests/src/com/android/providers/contacts/ContactsActor.java
+++ b/tests/src/com/android/providers/contacts/ContactsActor.java
@@ -59,6 +59,7 @@
 import android.test.mock.MockResources;
 import android.util.TypedValue;
 
+import java.io.File;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Locale;
@@ -152,6 +153,12 @@
         mProviderContext = new IsolatedContext(resolver, targetContextWrapper){
 
             @Override
+            public File getFilesDir() {
+                // TODO: Need to figure out something more graceful than this.
+                return new File("/data/data/com.android.providers.contacts.tests/files");
+            }
+
+            @Override
             public Object getSystemService(String name) {
                 if (Context.COUNTRY_DETECTOR.equals(name)) {
                     return mMockCountryDetector;
diff --git a/tests/src/com/android/providers/contacts/ContactsProvider2Test.java b/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
index ab43158..aa19e4e 100644
--- a/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
+++ b/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
@@ -19,6 +19,7 @@
 import com.android.internal.util.ArrayUtils;
 import com.android.providers.contacts.ContactsDatabaseHelper.AggregationExceptionColumns;
 import com.android.providers.contacts.ContactsDatabaseHelper.PresenceColumns;
+import com.android.providers.contacts.tests.R;
 import com.google.android.collect.Lists;
 
 import android.accounts.Account;
@@ -47,6 +48,7 @@
 import android.provider.ContactsContract.DataUsageFeedback;
 import android.provider.ContactsContract.Directory;
 import android.provider.ContactsContract.DisplayNameSources;
+import android.provider.ContactsContract.DisplayPhoto;
 import android.provider.ContactsContract.FullNameStyle;
 import android.provider.ContactsContract.Groups;
 import android.provider.ContactsContract.PhoneLookup;
@@ -58,16 +60,18 @@
 import android.provider.ContactsContract.SearchSnippetColumns;
 import android.provider.ContactsContract.Settings;
 import android.provider.ContactsContract.StatusUpdates;
-import android.provider.ContactsContract.StreamItems;
 import android.provider.ContactsContract.StreamItemPhotos;
+import android.provider.ContactsContract.StreamItems;
 import android.provider.LiveFolders;
 import android.provider.OpenableColumns;
 import android.test.MoreAsserts;
 import android.test.suitebuilder.annotation.LargeTest;
+import android.text.TextUtils;
 
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.text.Collator;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -104,6 +108,7 @@
                 Contacts.STARRED,
                 Contacts.IN_VISIBLE_GROUP,
                 Contacts.PHOTO_ID,
+                Contacts.PHOTO_FILE_ID,
                 Contacts.PHOTO_URI,
                 Contacts.PHOTO_THUMBNAIL_URI,
                 Contacts.CUSTOM_RINGTONE,
@@ -138,6 +143,7 @@
                 Contacts.STARRED,
                 Contacts.IN_VISIBLE_GROUP,
                 Contacts.PHOTO_ID,
+                Contacts.PHOTO_FILE_ID,
                 Contacts.PHOTO_URI,
                 Contacts.PHOTO_THUMBNAIL_URI,
                 Contacts.CUSTOM_RINGTONE,
@@ -246,6 +252,7 @@
                 Contacts.STARRED,
                 Contacts.IN_VISIBLE_GROUP,
                 Contacts.PHOTO_ID,
+                Contacts.PHOTO_FILE_ID,
                 Contacts.PHOTO_URI,
                 Contacts.PHOTO_THUMBNAIL_URI,
                 Contacts.CUSTOM_RINGTONE,
@@ -314,6 +321,7 @@
                 Contacts.STARRED,
                 Contacts.IN_VISIBLE_GROUP,
                 Contacts.PHOTO_ID,
+                Contacts.PHOTO_FILE_ID,
                 Contacts.PHOTO_URI,
                 Contacts.PHOTO_THUMBNAIL_URI,
                 Contacts.HAS_PHONE_NUMBER,
@@ -395,6 +403,7 @@
                 Contacts.STARRED,
                 Contacts.IN_VISIBLE_GROUP,
                 Contacts.PHOTO_ID,
+                Contacts.PHOTO_FILE_ID,
                 Contacts.PHOTO_URI,
                 Contacts.PHOTO_THUMBNAIL_URI,
                 Contacts.CUSTOM_RINGTONE,
@@ -4012,28 +4021,29 @@
         Uri rawContactUri = mResolver.insert(RawContacts.CONTENT_URI, values);
         long rawContactId = ContentUris.parseId(rawContactUri);
         insertStructuredName(rawContactId, "John", "Doe");
-        Uri photoUri = insertPhoto(rawContactId);
-
-        Uri twigUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
-                queryContactId(rawContactId)), Contacts.Photo.CONTENT_DIRECTORY);
+        long dataId = ContentUris.parseId(insertPhoto(rawContactId, R.drawable.earth_normal));
+        long photoFileId = getStoredLongValue(Data.CONTENT_URI, Data._ID + "=?",
+                new String[]{String.valueOf(dataId)}, Photo.PHOTO_FILE_ID);
+        String photoUri = ContentUris.withAppendedId(DisplayPhoto.CONTENT_URI, photoFileId)
+                .toString();
 
         assertStoredValue(
                 ContentUris.withAppendedId(Contacts.CONTENT_URI, queryContactId(rawContactId)),
-                Contacts.PHOTO_URI, twigUri.toString());
-
-        long twigId = Long.parseLong(getStoredValue(twigUri, Data._ID));
-        assertEquals(ContentUris.parseId(photoUri), twigId);
+                Contacts.PHOTO_URI, photoUri);
     }
 
     public void testInputStreamForPhoto() throws Exception {
         long rawContactId = createRawContact();
-        Uri photoUri = insertPhoto(rawContactId);
-        assertInputStreamContent(loadTestPhoto(), mResolver.openInputStream(photoUri));
+        long contactId = queryContactId(rawContactId);
+        Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
+        insertPhoto(rawContactId);
+        Uri photoUri = Uri.parse(getStoredValue(contactUri, Contacts.PHOTO_URI));
+        Uri photoThumbnailUri = Uri.parse(getStoredValue(contactUri, Contacts.PHOTO_THUMBNAIL_URI));
 
-        Uri contactPhotoUri = Uri.withAppendedPath(
-                ContentUris.withAppendedId(Contacts.CONTENT_URI, queryContactId(rawContactId)),
-                Contacts.Photo.CONTENT_DIRECTORY);
-        assertInputStreamContent(loadTestPhoto(), mResolver.openInputStream(contactPhotoUri));
+        assertInputStreamContent(loadTestPhoto(PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(photoUri));
+        assertInputStreamContent(loadTestPhoto(PhotoSize.THUMBNAIL),
+                mResolver.openInputStream(photoThumbnailUri));
     }
 
     private static void assertInputStreamContent(byte[] expected, InputStream is)
@@ -4051,11 +4061,11 @@
 
     public void testSuperPrimaryPhoto() {
         long rawContactId1 = createRawContact(new Account("a", "a"));
-        Uri photoUri1 = insertPhoto(rawContactId1);
+        Uri photoUri1 = insertPhoto(rawContactId1, R.drawable.earth_normal);
         long photoId1 = ContentUris.parseId(photoUri1);
 
         long rawContactId2 = createRawContact(new Account("b", "b"));
-        Uri photoUri2 = insertPhoto(rawContactId2);
+        Uri photoUri2 = insertPhoto(rawContactId2, R.drawable.earth_normal);
         long photoId2 = ContentUris.parseId(photoUri2);
 
         setAggregationException(AggregationExceptions.TYPE_KEEP_TOGETHER,
@@ -4063,9 +4073,13 @@
 
         Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI,
                 queryContactId(rawContactId1));
+
+        long photoFileId1 = getStoredLongValue(Data.CONTENT_URI, Data._ID + "=?",
+                new String[]{String.valueOf(photoId1)}, Photo.PHOTO_FILE_ID);
+        String photoUri = ContentUris.withAppendedId(DisplayPhoto.CONTENT_URI, photoFileId1)
+                .toString();
         assertStoredValue(contactUri, Contacts.PHOTO_ID, photoId1);
-        assertStoredValue(contactUri, Contacts.PHOTO_URI,
-                Uri.withAppendedPath(contactUri, Contacts.Photo.CONTENT_DIRECTORY));
+        assertStoredValue(contactUri, Contacts.PHOTO_URI, photoUri);
 
         setAggregationException(AggregationExceptions.TYPE_KEEP_SEPARATE,
                 rawContactId1, rawContactId2);
@@ -4107,7 +4121,7 @@
         mResolver.update(dataUri, values, null, null);
         assertNetworkNotified(true);
 
-        long twigId = Long.parseLong(getStoredValue(twigUri, Data._ID));
+        long twigId = getStoredLongValue(twigUri, Data._ID);
         assertEquals(photoId, twigId);
     }
 
@@ -4142,10 +4156,313 @@
         Cursor storedPhoto = mResolver.query(dataUri, new String[] {Photo.PHOTO},
                 Data.MIMETYPE + "=?", new String[] {Photo.CONTENT_ITEM_TYPE}, null);
         storedPhoto.moveToFirst();
-        MoreAsserts.assertEquals(loadTestPhoto(), storedPhoto.getBlob(0));
+        MoreAsserts.assertEquals(loadTestPhoto(PhotoSize.THUMBNAIL), storedPhoto.getBlob(0));
         storedPhoto.close();
     }
 
+    public void testOpenDisplayPhotoForContactId() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+        insertPhoto(rawContactId, R.drawable.earth_normal);
+        Uri photoUri = Contacts.CONTENT_URI.buildUpon()
+                .appendPath(String.valueOf(contactId))
+                .appendPath(Contacts.Photo.DISPLAY_PHOTO).build();
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_normal, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(photoUri));
+    }
+
+    public void testOpenDisplayPhotoForContactLookupKey() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+        String lookupKey = queryLookupKey(contactId);
+        insertPhoto(rawContactId, R.drawable.earth_normal);
+        Uri photoUri = Contacts.CONTENT_LOOKUP_URI.buildUpon()
+                .appendPath(lookupKey)
+                .appendPath(Contacts.Photo.DISPLAY_PHOTO).build();
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_normal, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(photoUri));
+    }
+
+    public void testOpenDisplayPhotoForContactLookupKeyAndId() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+        String lookupKey = queryLookupKey(contactId);
+        insertPhoto(rawContactId, R.drawable.earth_normal);
+        Uri photoUri = Contacts.CONTENT_LOOKUP_URI.buildUpon()
+                .appendPath(lookupKey)
+                .appendPath(String.valueOf(contactId))
+                .appendPath(Contacts.Photo.DISPLAY_PHOTO).build();
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_normal, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(photoUri));
+    }
+
+    public void testOpenDisplayPhotoForRawContactId() throws IOException {
+        long rawContactId = createRawContactWithName();
+        insertPhoto(rawContactId, R.drawable.earth_normal);
+        Uri photoUri = RawContacts.CONTENT_URI.buildUpon()
+                .appendPath(String.valueOf(rawContactId))
+                .appendPath(RawContacts.DisplayPhoto.CONTENT_DIRECTORY).build();
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_normal, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(photoUri));
+    }
+
+    public void testOpenDisplayPhotoByPhotoUri() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+        insertPhoto(rawContactId, R.drawable.earth_normal);
+
+        // Get the photo URI out and check the content.
+        String photoUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_URI);
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_normal, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(Uri.parse(photoUri)));
+    }
+
+    public void testPhotoUriForDisplayPhoto() {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+
+        // Photo being inserted is larger than a thumbnail, so it will be stored as a file.
+        long dataId = ContentUris.parseId(insertPhoto(rawContactId, R.drawable.earth_normal));
+        String photoFileId = getStoredValue(ContentUris.withAppendedId(Data.CONTENT_URI, dataId),
+                Photo.PHOTO_FILE_ID);
+        String photoUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_URI);
+
+        // Check that the photo URI differs from the thumbnail.
+        String thumbnailUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_THUMBNAIL_URI);
+        assertFalse(photoUri.equals(thumbnailUri));
+
+        // URI should be of the form display_photo/ID
+        assertEquals(Uri.withAppendedPath(DisplayPhoto.CONTENT_URI, photoFileId).toString(),
+                photoUri);
+    }
+
+    public void testPhotoUriForThumbnailPhoto() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+
+        // Photo being inserted is a thumbnail, so it will only be stored in a BLOB.  The photo URI
+        // will fall back to the thumbnail URI.
+        insertPhoto(rawContactId, R.drawable.earth_small);
+        String photoUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_URI);
+
+        // Check that the photo URI is equal to the thumbnail URI.
+        String thumbnailUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_THUMBNAIL_URI);
+        assertEquals(photoUri, thumbnailUri);
+
+        // URI should be of the form contacts/ID/photo
+        assertEquals(Uri.withAppendedPath(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.Photo.CONTENT_DIRECTORY).toString(),
+                photoUri);
+
+        // Loading the photo URI content should get the thumbnail.
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_small, PhotoSize.THUMBNAIL),
+                mResolver.openInputStream(Uri.parse(photoUri)));
+    }
+
+    public void testWriteNewPhotoToAssetFile() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+
+        // Load in a huge photo.
+        byte[] originalPhoto = loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.ORIGINAL);
+
+        // Write it out.
+        Uri writeablePhotoUri = RawContacts.CONTENT_URI.buildUpon()
+                .appendPath(String.valueOf(rawContactId))
+                .appendPath(RawContacts.DisplayPhoto.CONTENT_DIRECTORY).build();
+        OutputStream os = mResolver.openOutputStream(writeablePhotoUri, "rw");
+        try {
+            os.write(originalPhoto);
+        } finally {
+            os.close();
+        }
+
+        // Check that the display photo and thumbnail have been set.
+        String photoUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId), Contacts.PHOTO_URI);
+        assertFalse(TextUtils.isEmpty(photoUri));
+        String thumbnailUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_THUMBNAIL_URI);
+        assertFalse(TextUtils.isEmpty(thumbnailUri));
+        assertFalse(photoUri.equals(thumbnailUri));
+
+        // Check the content of the display photo and thumbnail.
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(Uri.parse(photoUri)));
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.THUMBNAIL),
+                mResolver.openInputStream(Uri.parse(thumbnailUri)));
+    }
+
+    public void testWriteUpdatedPhotoToAssetFile() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+
+        // Insert a large photo first.
+        insertPhoto(rawContactId, R.drawable.earth_large);
+        String largeEarthPhotoUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId), Contacts.PHOTO_URI);
+
+        // Load in a huge photo.
+        byte[] originalPhoto = loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.ORIGINAL);
+
+        // Write it out.
+        Uri writeablePhotoUri = RawContacts.CONTENT_URI.buildUpon()
+                .appendPath(String.valueOf(rawContactId))
+                .appendPath(RawContacts.DisplayPhoto.CONTENT_DIRECTORY).build();
+        OutputStream os = mResolver.openOutputStream(writeablePhotoUri, "rw");
+        try {
+            os.write(originalPhoto);
+        } finally {
+            os.close();
+        }
+
+        // Check that the display photo URI has been modified.
+        String hugeEarthPhotoUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId), Contacts.PHOTO_URI);
+        assertFalse(hugeEarthPhotoUri.equals(largeEarthPhotoUri));
+
+        // Check the content of the display photo and thumbnail.
+        String hugeEarthThumbnailUri = getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.PHOTO_THUMBNAIL_URI);
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.DISPLAY_PHOTO),
+                mResolver.openInputStream(Uri.parse(hugeEarthPhotoUri)));
+        assertInputStreamContent(
+                loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.THUMBNAIL),
+                mResolver.openInputStream(Uri.parse(hugeEarthThumbnailUri)));
+
+    }
+
+    public void testPhotoDimensionLimits() {
+        ContentValues values = new ContentValues();
+        values.put(DisplayPhoto.DISPLAY_MAX_DIM, 256);
+        values.put(DisplayPhoto.THUMBNAIL_MAX_DIM, 96);
+        assertStoredValues(DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, values);
+    }
+
+    public void testPhotoStoreCleanup() throws IOException {
+        SynchronousContactsProvider2 provider = (SynchronousContactsProvider2) mActor.provider;
+
+        // Trigger an initial cleanup so another one won't happen while we're running this test.
+        provider.cleanupPhotoStore();
+
+        // Insert a couple of contacts with photos.
+        long rawContactId1 = createRawContactWithName();
+        long contactId1 = queryContactId(rawContactId1);
+        long dataId1 = ContentUris.parseId(insertPhoto(rawContactId1, R.drawable.earth_normal));
+        long photoFileId1 =
+                getStoredLongValue(ContentUris.withAppendedId(Data.CONTENT_URI, dataId1),
+                        Photo.PHOTO_FILE_ID);
+
+        long rawContactId2 = createRawContactWithName();
+        long contactId2 = queryContactId(rawContactId2);
+        long dataId2 = ContentUris.parseId(insertPhoto(rawContactId2, R.drawable.earth_normal));
+        long photoFileId2 =
+                getStoredLongValue(ContentUris.withAppendedId(Data.CONTENT_URI, dataId2),
+                        Photo.PHOTO_FILE_ID);
+
+        // Update the second raw contact with a different photo.
+        ContentValues values = new ContentValues();
+        values.put(Data.RAW_CONTACT_ID, rawContactId2);
+        values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+        values.put(Photo.PHOTO, loadPhotoFromResource(R.drawable.earth_huge, PhotoSize.ORIGINAL));
+        assertEquals(1, mResolver.update(Data.CONTENT_URI, values, Data._ID + "=?",
+                new String[]{String.valueOf(dataId2)}));
+        long replacementPhotoFileId =
+                getStoredLongValue(ContentUris.withAppendedId(Data.CONTENT_URI, dataId2),
+                        Photo.PHOTO_FILE_ID);
+
+        // Insert a third raw contact that has a bogus photo file ID.
+        long bogusFileId = 1234567;
+        long rawContactId3 = createRawContactWithName();
+        long contactId3 = queryContactId(rawContactId3);
+        values.clear();
+        values.put(Data.RAW_CONTACT_ID, rawContactId3);
+        values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+        values.put(Photo.PHOTO, loadPhotoFromResource(R.drawable.earth_normal,
+                PhotoSize.THUMBNAIL));
+        values.put(Photo.PHOTO_FILE_ID, bogusFileId);
+        values.put(DataRowHandlerForPhoto.SKIP_PROCESSING_KEY, true);
+        mResolver.insert(Data.CONTENT_URI, values);
+
+        // Also insert a bogus photo that nobody is using.
+        PhotoStore photoStore = provider.getPhotoStore();
+        long bogusPhotoId = photoStore.insert(new PhotoProcessor(loadPhotoFromResource(
+                R.drawable.earth_huge, PhotoSize.ORIGINAL), 256, 96));
+
+        // Manually trigger another cleanup in the provider.
+        provider.cleanupPhotoStore();
+
+        // The following things should have happened.
+
+        // 1. Raw contact 1 and its photo remain unaffected.
+        assertEquals(photoFileId1, (long) getStoredLongValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId1),
+                Contacts.PHOTO_FILE_ID));
+
+        // 2. Raw contact 2 retains its new photo.  The old one is deleted from the photo store.
+        assertEquals(replacementPhotoFileId, (long) getStoredLongValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId2),
+                Contacts.PHOTO_FILE_ID));
+        assertNull(photoStore.get(photoFileId2));
+
+        // 3. Raw contact 3 should have its photo file reference cleared.
+        assertNull(getStoredValue(
+                ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId3),
+                Contacts.PHOTO_FILE_ID));
+
+        // 4. The bogus photo that nobody was using should be cleared from the photo store.
+        assertNull(photoStore.get(bogusPhotoId));
+    }
+
+    public void testOverwritePhotoWithThumbnail() throws IOException {
+        long rawContactId = createRawContactWithName();
+        long contactId = queryContactId(rawContactId);
+        Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
+
+        // Write a regular-size photo.
+        long dataId = ContentUris.parseId(insertPhoto(rawContactId, R.drawable.earth_normal));
+        Long photoFileId = getStoredLongValue(contactUri, Contacts.PHOTO_FILE_ID);
+        assertTrue(photoFileId != null && photoFileId > 0);
+
+        // Now overwrite the photo with a thumbnail-sized photo.
+        ContentValues update = new ContentValues();
+        update.put(Photo.PHOTO, loadPhotoFromResource(R.drawable.earth_small, PhotoSize.ORIGINAL));
+        mResolver.update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId), update, null, null);
+
+        // Photo file ID should have been nulled out, and the photo URI should be the same as the
+        // thumbnail URI.
+        assertNull(getStoredValue(contactUri, Contacts.PHOTO_FILE_ID));
+        String photoUri = getStoredValue(contactUri, Contacts.PHOTO_URI);
+        String thumbnailUri = getStoredValue(contactUri, Contacts.PHOTO_THUMBNAIL_URI);
+        assertEquals(photoUri, thumbnailUri);
+
+        // Retrieving the photo URI should get the thumbnail content.
+        assertInputStreamContent(loadPhotoFromResource(R.drawable.earth_small, PhotoSize.THUMBNAIL),
+                mResolver.openInputStream(Uri.parse(photoUri)));
+    }
+
     public void testUpdateRawContactSetStarred() {
         long rawContactId1 = createRawContactWithName();
         Uri rawContactUri1 = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId1);
diff --git a/tests/src/com/android/providers/contacts/LegacyContactImporterPerformanceTest.java b/tests/src/com/android/providers/contacts/LegacyContactImporterPerformanceTest.java
index 7e4b39f..d78193b 100644
--- a/tests/src/com/android/providers/contacts/LegacyContactImporterPerformanceTest.java
+++ b/tests/src/com/android/providers/contacts/LegacyContactImporterPerformanceTest.java
@@ -32,6 +32,8 @@
 import android.test.suitebuilder.annotation.MediumTest;
 import android.util.Log;
 
+import java.io.File;
+
 /**
  * Performance test for {@link ContactAggregator}. Run the test like this:
  * <code>
@@ -94,7 +96,13 @@
         RenamingDelegatingContext targetContextWrapper = new RenamingDelegatingContext(context,
                 targetContext, "perf_imp.");
         targetContextWrapper.makeExistingFilesAndDbsAccessible();
-        IsolatedContext providerContext = new IsolatedContext(resolver, targetContextWrapper);
+        IsolatedContext providerContext = new IsolatedContext(resolver, targetContextWrapper) {
+            @Override
+            public File getFilesDir() {
+                // TODO: Need to figure out something more graceful than this.
+                return new File("/data/data/com.android.providers.contacts.tests/files");
+            }
+        };
         SynchronousContactsProvider2 provider = new SynchronousContactsProvider2();
         provider.setDataWipeEnabled(false);
         provider.attachInfo(providerContext, null);
diff --git a/tests/src/com/android/providers/contacts/LegacyContactsProviderTest.java b/tests/src/com/android/providers/contacts/LegacyContactsProviderTest.java
index e034696..e515af2 100644
--- a/tests/src/com/android/providers/contacts/LegacyContactsProviderTest.java
+++ b/tests/src/com/android/providers/contacts/LegacyContactsProviderTest.java
@@ -16,6 +16,8 @@
 
 package com.android.providers.contacts;
 
+import com.android.providers.contacts.tests.*;
+
 import android.app.SearchManager;
 import android.content.ContentProvider;
 import android.content.ContentUris;
@@ -707,7 +709,10 @@
     }
 
     public void testPhotoUpdate() throws Exception {
-        byte[] photo = loadTestPhoto();
+        byte[] photo = loadPhotoFromResource(
+                com.android.providers.contacts.tests.R.drawable.earth_small, PhotoSize.ORIGINAL);
+        byte[] thumbnailedPhoto = loadPhotoFromResource(
+                com.android.providers.contacts.tests.R.drawable.earth_small, PhotoSize.THUMBNAIL);
 
         ContentValues values = new ContentValues();
         Uri personUri = mResolver.insert(People.CONTENT_URI, values);
@@ -722,13 +727,16 @@
 
         Uri photoUri = Uri.withAppendedPath(personUri, Photos.CONTENT_DIRECTORY);
         mResolver.update(photoUri, values, null, null);
+        values.put(Photos.DATA, thumbnailedPhoto);
         assertStoredValues(photoUri, values);
 
         long photoId = Long.parseLong(getStoredValue(photoUri, Photos._ID));
 
         values.put(Photos.LOCAL_VERSION, "11");
+        values.put(Photos.DATA, photo);
         Uri contentUri = ContentUris.withAppendedId(Photos.CONTENT_URI, photoId);
         mResolver.update(contentUri, values, null, null);
+        values.put(Photos.DATA, thumbnailedPhoto);
         assertStoredValues(contentUri, values);
         assertStoredValues(photoUri, values);
     }
diff --git a/tests/src/com/android/providers/contacts/PhotoLoadingTestCase.java b/tests/src/com/android/providers/contacts/PhotoLoadingTestCase.java
new file mode 100644
index 0000000..285378c
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/PhotoLoadingTestCase.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2011 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.providers.contacts;
+
+import com.google.android.collect.Maps;
+
+import android.content.res.Resources;
+import android.test.AndroidTestCase;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+/**
+ * Adds support for loading photo files easily from test resources.
+ */
+public class PhotoLoadingTestCase extends AndroidTestCase {
+
+    private Map<Integer, PhotoEntry> photoResourceCache = Maps.newHashMap();
+    protected static enum PhotoSize {
+        ORIGINAL,
+        DISPLAY_PHOTO,
+        THUMBNAIL
+    }
+
+    protected final class PhotoEntry {
+        Map<PhotoSize, byte[]> photoMap = Maps.newHashMap();
+        public PhotoEntry(byte[] original) {
+            try {
+                Resources resources = getContext().getResources();
+                PhotoProcessor processor = new PhotoProcessor(original,
+                        resources.getInteger(R.integer.config_max_display_photo_dim),
+                        resources.getInteger(R.integer.config_max_thumbnail_photo_dim));
+                photoMap.put(PhotoSize.ORIGINAL, original);
+                photoMap.put(PhotoSize.DISPLAY_PHOTO, processor.getDisplayPhotoBytes());
+                photoMap.put(PhotoSize.THUMBNAIL, processor.getThumbnailPhotoBytes());
+            } catch (IOException ignored) {
+                // Test is probably going to fail as a result anyway.
+            }
+        }
+
+        public byte[] getPhoto(PhotoSize size) {
+            return photoMap.get(size);
+        }
+    }
+
+    // The test photo will be loaded frequently in tests, so we'll just process it once.
+    private static PhotoEntry testPhotoEntry;
+
+
+    protected byte[] loadTestPhoto() {
+        int testPhotoId = com.android.providers.contacts.tests.R.drawable.ic_contact_picture;
+        if (testPhotoEntry == null) {
+            loadPhotoFromResource(testPhotoId, PhotoSize.ORIGINAL);
+            testPhotoEntry = photoResourceCache.get(testPhotoId);
+        }
+        return testPhotoEntry.getPhoto(PhotoSize.ORIGINAL);
+    }
+
+    protected byte[] loadTestPhoto(PhotoSize size) {
+        loadTestPhoto();
+        return testPhotoEntry.getPhoto(size);
+    }
+
+    protected byte[] loadPhotoFromResource(int resourceId, PhotoSize size) {
+        PhotoEntry entry = photoResourceCache.get(resourceId);
+        if (entry == null) {
+            final Resources resources = getTestContext().getResources();
+            InputStream is = resources.openRawResource(resourceId);
+            byte[] content = readInputStreamFully(is);
+            entry = new PhotoEntry(content);
+            photoResourceCache.put(resourceId, entry);
+        }
+        return entry.getPhoto(size);
+    }
+
+    protected byte[] readInputStreamFully(InputStream is) {
+        ByteArrayOutputStream os = new ByteArrayOutputStream();
+        byte[] buffer = new byte[10000];
+        int count;
+        try {
+            while ((count = is.read(buffer)) != -1) {
+                os.write(buffer, 0, count);
+            }
+            is.close();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+        return os.toByteArray();
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/PhotoStoreTest.java b/tests/src/com/android/providers/contacts/PhotoStoreTest.java
new file mode 100644
index 0000000..9b7c50d
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/PhotoStoreTest.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2011 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.providers.contacts;
+
+import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
+import com.android.providers.contacts.tests.R;
+
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.PhotoFiles;
+import android.test.mock.MockContentResolver;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static com.android.providers.contacts.ContactsActor.PACKAGE_GREY;
+
+/**
+ * Tests for {@link PhotoStore}.
+ */
+@LargeTest
+public class PhotoStoreTest extends PhotoLoadingTestCase {
+
+    private ContactsActor mActor;
+    private SynchronousContactsProvider2 mProvider;
+    private SQLiteDatabase mDb;
+
+    // The object under test.
+    private PhotoStore mPhotoStore;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        mActor = new ContactsActor(getContext(), PACKAGE_GREY, SynchronousContactsProvider2.class,
+                ContactsContract.AUTHORITY);
+        mProvider = ((SynchronousContactsProvider2) mActor.provider);
+        mPhotoStore = mProvider.getPhotoStore();
+        mProvider.wipeData();
+        mDb = mProvider.getDatabaseHelper(getContext()).getReadableDatabase();
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        super.tearDown();
+        mPhotoStore.clear();
+    }
+
+    public void testStoreThumbnailPhoto() throws IOException {
+        byte[] photo = loadPhotoFromResource(R.drawable.earth_small, PhotoSize.ORIGINAL);
+
+        // Since the photo is already thumbnail-sized, no file will be stored.
+        assertEquals(0, mPhotoStore.insert(new PhotoProcessor(photo, 256, 96)));
+    }
+
+    public void testStoreMediumPhoto() throws IOException {
+        runStorageTestForResource(R.drawable.earth_normal);
+    }
+
+    public void testStoreLargePhoto() throws IOException {
+        runStorageTestForResource(R.drawable.earth_large);
+    }
+
+    public void testStoreHugePhoto() throws IOException {
+        runStorageTestForResource(R.drawable.earth_huge);
+    }
+
+    /**
+     * Runs the following steps:
+     * - Loads the given photo resource.
+     * - Inserts it into the photo store.
+     * - Checks that the photo has a photo file ID.
+     * - Loads the expected display photo for the resource.
+     * - Gets the photo entry from the photo store.
+     * - Loads the photo entry's file content from disk.
+     * - Compares the expected photo content to the disk content.
+     * - Queries the contacts provider for the photo file entry, checks for its
+     *   existence, and matches it up against the expected metadata.
+     * - Checks that the total storage taken up by the photo store is equal to
+     *   the size of the photo.
+     * @param resourceId The resource ID of the photo file to test.
+     */
+    public void runStorageTestForResource(int resourceId) throws IOException {
+        byte[] photo = loadPhotoFromResource(resourceId, PhotoSize.ORIGINAL);
+        long photoFileId = mPhotoStore.insert(new PhotoProcessor(photo, 256, 96));
+        assertTrue(photoFileId != 0);
+
+        byte[] expectedStoredVersion = loadPhotoFromResource(resourceId, PhotoSize.DISPLAY_PHOTO);
+        File storedFile = new File(mPhotoStore.get(photoFileId).path);
+        assertTrue(storedFile.exists());
+        byte[] storedVersion = readInputStreamFully(new FileInputStream(storedFile));
+        assertEquals(Hex.encodeHex(expectedStoredVersion, false),
+                Hex.encodeHex(storedVersion, false));
+
+        Cursor c = mDb.query(Tables.PHOTO_FILES,
+                new String[]{PhotoFiles.HEIGHT, PhotoFiles.WIDTH, PhotoFiles.FILESIZE},
+                PhotoFiles._ID + "=?", new String[]{String.valueOf(photoFileId)}, null, null, null);
+        try {
+            assertEquals(1, c.getCount());
+            c.moveToFirst();
+            assertEquals(256, c.getInt(0));
+            assertEquals(256, c.getInt(1));
+            assertEquals(expectedStoredVersion.length, c.getInt(2));
+        } finally {
+            c.close();
+        }
+
+        assertEquals(expectedStoredVersion.length, mPhotoStore.getTotalSize());
+    }
+
+    public void testRemoveEntry() throws IOException {
+        byte[] photo = loadPhotoFromResource(R.drawable.earth_normal, PhotoSize.ORIGINAL);
+        long photoFileId = mPhotoStore.insert(new PhotoProcessor(photo, 256, 96));
+        PhotoStore.Entry entry = mPhotoStore.get(photoFileId);
+        assertTrue(new File(entry.path).exists());
+
+        mPhotoStore.remove(photoFileId);
+
+        // Check that the file has been deleted.
+        assertFalse(new File(entry.path).exists());
+
+        // Check that the database record has also been removed.
+        Cursor c = mDb.query(Tables.PHOTO_FILES, new String[]{PhotoFiles._ID},
+                PhotoFiles._ID + "=?", new String[]{String.valueOf(photoFileId)}, null, null, null);
+        try {
+            assertEquals(0, c.getCount());
+        } finally {
+            c.close();
+        }
+    }
+
+    public void testCleanup() throws IOException {
+        // Load some photos into the store.
+        Set<Long> photoFileIds = new HashSet<Long>();
+        Map<Integer, Long> resourceIdToPhotoMap = new HashMap<Integer, Long>();
+        int[] resourceIds = new int[] {
+                R.drawable.earth_normal, R.drawable.earth_large, R.drawable.earth_huge
+        };
+        for (int resourceId : resourceIds) {
+            long photoFileId = mPhotoStore.insert(
+                    new PhotoProcessor(loadPhotoFromResource(resourceId, PhotoSize.ORIGINAL),
+                            256, 96));
+            resourceIdToPhotoMap.put(resourceId, photoFileId);
+            photoFileIds.add(photoFileId);
+        }
+        assertFalse(photoFileIds.contains(0L));
+        assertEquals(3, photoFileIds.size());
+
+        // Run cleanup with the indication that only the large and huge photos are in use, along
+        // with a bogus photo file ID that isn't in the photo store.
+        long bogusPhotoFileId = 42;
+        Set<Long> photoFileIdsInUse = new HashSet<Long>();
+        photoFileIdsInUse.add(resourceIdToPhotoMap.get(R.drawable.earth_large));
+        photoFileIdsInUse.add(resourceIdToPhotoMap.get(R.drawable.earth_huge));
+        photoFileIdsInUse.add(bogusPhotoFileId);
+
+        Set<Long> photoIdsToCleanup = mPhotoStore.cleanup(photoFileIdsInUse);
+
+        // The set of photo IDs to clean up should consist of the bogus photo file ID.
+        assertEquals(1, photoIdsToCleanup.size());
+        assertTrue(photoIdsToCleanup.contains(bogusPhotoFileId));
+
+        // The entry for the normal-sized photo should have been cleaned up, since it isn't being
+        // used.
+        long normalPhotoId = resourceIdToPhotoMap.get(R.drawable.earth_normal);
+        assertNull(mPhotoStore.get(normalPhotoId));
+
+        // Check that the database record has also been removed.
+        Cursor c = mDb.query(Tables.PHOTO_FILES, new String[]{PhotoFiles._ID},
+                PhotoFiles._ID + "=?", new String[]{String.valueOf(normalPhotoId)},
+                null, null, null);
+        try {
+            assertEquals(0, c.getCount());
+        } finally {
+            c.close();
+        }
+    }
+}