summaryrefslogtreecommitdiffstats
path: root/src/com/android/providers
diff options
context:
space:
mode:
authorDave Santoro <dsantoro@google.com>2011-06-27 10:55:35 -0700
committerDave Santoro <dsantoro@google.com>2011-07-13 17:11:44 -0700
commitf547fd54d7933e1c03af4a8dc10560c71c38f6b8 (patch)
tree23ac7e5124987f0476338eec88b44666d0fecb4e /src/com/android/providers
parent1cdfc9dacc136e99d3c0bc5b4212bc3c973be337 (diff)
downloadpackages_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')
-rw-r--r--src/com/android/providers/contacts/ContactAggregator.java51
-rw-r--r--src/com/android/providers/contacts/ContactsDatabaseHelper.java72
-rw-r--r--src/com/android/providers/contacts/ContactsProvider2.java408
-rw-r--r--src/com/android/providers/contacts/DataRowHandlerForPhoto.java78
-rw-r--r--src/com/android/providers/contacts/PhotoProcessor.java153
-rw-r--r--src/com/android/providers/contacts/PhotoStore.java274
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();
+ }
+ }
+}