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 /src/com/android/providers | |
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
Diffstat (limited to 'src/com/android/providers')
6 files changed, 992 insertions, 44 deletions
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(); + } + } +} |