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