diff options
author | Dave Santoro <dsantoro@google.com> | 2011-06-27 10:55:35 -0700 |
---|---|---|
committer | Dave Santoro <dsantoro@google.com> | 2011-07-13 17:11:44 -0700 |
commit | f547fd54d7933e1c03af4a8dc10560c71c38f6b8 (patch) | |
tree | 23ac7e5124987f0476338eec88b44666d0fecb4e | |
parent | 1cdfc9dacc136e99d3c0bc5b4212bc3c973be337 (diff) | |
download | packages_providers_ContactsProvider-f547fd54d7933e1c03af4a8dc10560c71c38f6b8.zip packages_providers_ContactsProvider-f547fd54d7933e1c03af4a8dc10560c71c38f6b8.tar.gz packages_providers_ContactsProvider-f547fd54d7933e1c03af4a8dc10560c71c38f6b8.tar.bz2 |
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
19 files changed, 1682 insertions, 93 deletions
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 @@ public class ContactAggregator { 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 @@ public class ContactAggregator { + 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 @@ public class ContactAggregator { 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 @@ public class ContactAggregator { " 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 @@ public class ContactAggregator { "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 @@ public class ContactAggregator { + 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 @@ public class ContactAggregator { SQLiteStatement statement) { long currentRawContactId = -1; long bestPhotoId = -1; + long bestPhotoFileId = 0; boolean foundSuperPrimaryPhoto = false; int photoPriority = -1; int totalRowCount = 0; @@ -1691,6 +1697,7 @@ public class ContactAggregator { 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 @@ public class ContactAggregator { if (superPrimary || priority > photoPriority) { photoPriority = priority; bestPhotoId = dataId; + bestPhotoFileId = photoFileId; foundSuperPrimaryPhoto |= superPrimary; } } @@ -1721,6 +1729,12 @@ public class ContactAggregator { 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 @@ public class ContactAggregator { 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 @@ public class ContactAggregator { } long bestPhotoId = -1; + long bestPhotoFileId = 0; int photoPriority = -1; long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE); @@ -1815,9 +1832,11 @@ public class ContactAggregator { 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 @@ public class ContactAggregator { if (priority > photoPriority) { photoPriority = priority; bestPhotoId = dataId; + bestPhotoFileId = photoFileId; } } } finally { @@ -1837,7 +1857,14 @@ public class ContactAggregator { } 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; 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 @@ import java.util.Locale; * 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 @@ import java.util.Locale; 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 @@ import java.util.Locale; 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 @@ import java.util.Locale; 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 @@ import java.util.Locale; 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 @@ import java.util.Locale; "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 @@ import java.util.Locale; + 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 @@ import java.util.Locale; + 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 @@ import java.util.Locale; 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 @@ import java.util.Locale; + 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 @@ import java.util.Locale; 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 + "/'||" + + 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 + "/'||" + + " ELSE '" + Contacts.CONTENT_URI + "/'||" + contactIdColumn + "|| '/" + Photo.CONTENT_DIRECTORY + "'" + " END)" + " AS " + alias; @@ -1985,6 +2018,7 @@ import java.util.Locale; } if (oldVersion < 605) { + upgradeViewsAndTriggers = true; upgradeToVersion605(db); oldVersion = 605; } @@ -2002,6 +2036,13 @@ import java.util.Locale; oldVersion = 607; } + if (oldVersion < 608) { + upgradeViewsAndTriggers = true; + upgradeToVersion608(db); + oldVersion = 608; + } + + if (upgradeViewsAndTriggers) { createContactsViews(db); createGroupsView(db); @@ -3130,6 +3171,16 @@ import java.util.Locale; 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 @@ import java.util.Locale; 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.RawContactsColumns; 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.MatrixCursor.RowBuilder; 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.Contacts.AggregationSuggestions; 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.RawContacts; 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 android.util.Log; 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun /** 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun .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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun /** 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun private HandlerThread mBackgroundThread; private Handler mBackgroundHandler; + private long mLastPhotoCleanup = 0; + @Override public boolean onCreate() { super.onCreate(); @@ -1205,11 +1250,16 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun } 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun } /* 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun */ /* package */ void wipeData() { mDbHelper.wipeData(); + mPhotoStore.clear(); mProviderStatus = ProviderStatus.STATUS_NO_ACCOUNTS_NO_CONTACTS; } @@ -3507,7 +3623,11 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun // 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun @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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun } } + /** + * 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 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun 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 @@ package com.android.providers.contacts; 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 class DataRowHandlerForPhoto extends DataRowHandler { 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 @@ public class DataRowHandlerForPhoto extends DataRowHandler { 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 @@ public class DataRowHandlerForPhoto extends DataRowHandler { 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 Binary files differnew file mode 100644 index 0000000..bf79f04 --- /dev/null +++ b/tests/res/drawable/earth_huge.png diff --git a/tests/res/drawable/earth_large.png b/tests/res/drawable/earth_large.png Binary files differnew file mode 100644 index 0000000..c629348 --- /dev/null +++ b/tests/res/drawable/earth_large.png diff --git a/tests/res/drawable/earth_normal.png b/tests/res/drawable/earth_normal.png Binary files differnew file mode 100644 index 0000000..6311a59 --- /dev/null +++ b/tests/res/drawable/earth_normal.png diff --git a/tests/res/drawable/earth_small.png b/tests/res/drawable/earth_small.png Binary files differnew file mode 100644 index 0000000..198d7eb --- /dev/null +++ b/tests/res/drawable/earth_small.png diff --git a/tests/res/drawable/ic_contact_picture.png b/tests/res/drawable/ic_contact_picture.png Binary files differnew file mode 100644 index 0000000..37b558b --- /dev/null +++ b/tests/res/drawable/ic_contact_picture.png 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.RawContacts; 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; 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 @@ public abstract class BaseContactsProvider2Test extends AndroidTestCase { 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 @@ public abstract class BaseContactsProvider2Test extends AndroidTestCase { 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 @@ public abstract class BaseContactsProvider2Test extends AndroidTestCase { 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 @@ public abstract class BaseContactsProvider2Test extends AndroidTestCase { } } - 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.MockContext; 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 @@ public class ContactsActor { 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 @@ package com.android.providers.contacts; 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.Data; 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.RawContactsEntity; 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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 @@ public class ContactsProvider2Test extends BaseContactsProvider2Test { 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.mock.MockContext; 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 @@ public class LegacyContactImporterPerformanceTest extends AndroidTestCase { 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 class LegacyContactsProviderTest extends BaseContactsProvider2Test { } 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 @@ public class LegacyContactsProviderTest extends BaseContactsProvider2Test { 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(); + } + } +} |