OSDN Git Service

Add initial implementation of PhotosProvider.
authorGeorge Mount <mount@google.com>
Thu, 21 Feb 2013 01:01:54 +0000 (17:01 -0800)
committerGeorge Mount <mount@google.com>
Tue, 26 Feb 2013 20:49:13 +0000 (12:49 -0800)
Change-Id: I98694cf54bd0fb549703a7184e1816e9590a05ff

AndroidManifest.xml
src/com/android/photos/data/PhotoDatabase.java [new file with mode: 0644]
src/com/android/photos/data/PhotoProvider.java [new file with mode: 0644]
tests/AndroidManifest.xml
tests/src/com/android/photos/data/DataTestRunner.java [new file with mode: 0644]
tests/src/com/android/photos/data/PhotoDatabaseTest.java [new file with mode: 0644]
tests/src/com/android/photos/data/PhotoDatabaseUtils.java [new file with mode: 0644]
tests/src/com/android/photos/data/PhotoProviderTest.java [new file with mode: 0644]

index 3cd30cb..1a6f800 100644 (file)
                 android:exported="true"
                 android:permission="com.android.gallery3d.permission.GALLERY_PROVIDER"
                 android:authorities="com.android.gallery3d.provider" />
+        <provider
+                android:name="com.android.photos.data.PhotoProvider"
+                android:authorities="com.android.gallery3d.photoprovider"
+                android:syncable="false"
+                android:exported="false"/>
         <activity android:name="com.android.gallery3d.gadget.WidgetClickHandler" />
         <activity android:name="com.android.gallery3d.app.DialogPicker"
                 android:configChanges="keyboardHidden|orientation|screenSize"
diff --git a/src/com/android/photos/data/PhotoDatabase.java b/src/com/android/photos/data/PhotoDatabase.java
new file mode 100644 (file)
index 0000000..64a857f
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import com.android.photos.data.PhotoProvider.Albums;
+import com.android.photos.data.PhotoProvider.Metadata;
+import com.android.photos.data.PhotoProvider.Photos;
+
+/**
+ * Used in PhotoProvider to create and access the database containing
+ * information about photo and video information stored on the server.
+ */
+public class PhotoDatabase extends SQLiteOpenHelper {
+    @SuppressWarnings("unused")
+    private static final String TAG = PhotoDatabase.class.getSimpleName();
+    static final String DB_NAME = "photo.db";
+    static final int DB_VERSION = 1;
+
+    private static final String SQL_CREATE_TABLE = "CREATE TABLE ";
+
+    private static final String[][] CREATE_PHOTO = {
+        { Photos._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" },
+        { Photos.SERVER_ID, "INTEGER UNIQUE" },
+        { Photos.WIDTH, "INTEGER NOT NULL" },
+        { Photos.HEIGHT, "INTEGER NOT NULL" },
+        { Photos.DATE_TAKEN, "INTEGER NOT NULL" },
+        // Photos.ALBUM_ID is a foreign key to Albums._ID
+        { Photos.ALBUM_ID, "INTEGER" },
+        { Photos.MIME_TYPE, "TEXT NOT NULL" },
+    };
+
+    private static final String[][] CREATE_ALBUM = {
+        { Albums._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" },
+        // Albums.PARENT_ID is a foriegn key to Albums._ID
+        { Albums.PARENT_ID, "INTEGER" },
+        { Albums.NAME, "Text NOT NULL" },
+        { Albums.VISIBILITY, "INTEGER NOT NULL" },
+        { Albums.SERVER_ID, "INTEGER UNIQUE" },
+        createUniqueConstraint(Albums.PARENT_ID, Albums.NAME),
+    };
+
+    private static final String[][] CREATE_METADATA = {
+        { Metadata._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" },
+        // Metadata.PHOTO_ID is a foreign key to Photos._ID
+        { Metadata.PHOTO_ID, "INTEGER NOT NULL" },
+        { Metadata.KEY, "TEXT NOT NULL" },
+        { Metadata.VALUE, "TEXT NOT NULL" },
+        createUniqueConstraint(Metadata.PHOTO_ID, Metadata.KEY),
+    };
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        createTable(db, Albums.TABLE, CREATE_ALBUM);
+        createTable(db, Photos.TABLE, CREATE_PHOTO);
+        createTable(db, Metadata.TABLE, CREATE_METADATA);
+    }
+
+    public PhotoDatabase(Context context) {
+        super(context, DB_NAME, null, DB_VERSION);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+    }
+
+    protected static void createTable(SQLiteDatabase db, String table, String[][] columns) {
+        StringBuilder create = new StringBuilder(SQL_CREATE_TABLE);
+        create.append(table).append('(');
+        boolean first = true;
+        for (String[] column : columns) {
+            if (!first) {
+                create.append(',');
+            }
+            first = false;
+            for (String val: column) {
+                create.append(val).append(' ');
+            }
+        }
+        create.append(')');
+        db.beginTransaction();
+        try {
+            db.execSQL(create.toString());
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    protected static String[] createUniqueConstraint(String column1, String column2) {
+        return new String[] {
+                "UNIQUE(", column1, ",", column2, ")"
+        };
+    }
+}
diff --git a/src/com/android/photos/data/PhotoProvider.java b/src/com/android/photos/data/PhotoProvider.java
new file mode 100644 (file)
index 0000000..eefa373
--- /dev/null
@@ -0,0 +1,514 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteStatement;
+import android.net.Uri;
+import android.os.CancellationSignal;
+import android.provider.BaseColumns;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A provider that gives access to photo and video information for media stored
+ * on the server. Only media that is or will be put on the server will be
+ * accessed by this provider. Use Photos.CONTENT_URI to query all photos and
+ * videos. Use Albums.CONTENT_URI to query all albums. Use Metadata.CONTENT_URI
+ * to query metadata about a photo or video, based on the ID of the media. Use
+ * ImageCache.THUMBNAIL_CONTENT_URI, ImageCache.PREVIEW_CONTENT_URI, or
+ * ImageCache.ORIGINAL_CONTENT_URI to query the path of the thumbnail, preview,
+ * or original-sized image respectfully. <br/>
+ * To add or update metadata, use the update function rather than insert. All
+ * values for the metadata must be in the ContentValues, even if they are also
+ * in the selection. The selection and selectionArgs are not used when updating
+ * metadata. If the metadata values are null, the row will be deleted.
+ */
+public class PhotoProvider extends ContentProvider {
+    @SuppressWarnings("unused")
+    private static final String TAG = PhotoProvider.class.getSimpleName();
+    static final String AUTHORITY = "com.android.gallery3d.photoprovider";
+    static final Uri BASE_CONTENT_URI = new Uri.Builder().scheme("content").authority(AUTHORITY)
+            .build();
+
+    /**
+     * Contains columns that can be accessed via PHOTOS_CONTENT_URI.
+     */
+    public static interface Photos extends BaseColumns {
+        /**
+         * Internal database table used for basic photo information.
+         */
+        public static final String TABLE = "photo";
+        /**
+         * Content URI for basic photo and video information.
+         */
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE);
+        /**
+         * Identifier used on the server. Long value.
+         */
+        public static final String SERVER_ID = "server_id";
+        /**
+         * Column name for the width of the original image. Integer value.
+         */
+        public static final String WIDTH = "width";
+        /**
+         * Column name for the height of the original image. Integer value.
+         */
+        public static final String HEIGHT = "height";
+        /**
+         * Column name for the date that the original image was taken. Long
+         * value indicating the milliseconds since epoch in the GMT time zone.
+         */
+        public static final String DATE_TAKEN = "date_taken";
+        /**
+         * Column name indicating the long value of the album id that this image
+         * resides in. Will be NULL if it it has not been uploaded to the
+         * server.
+         */
+        public static final String ALBUM_ID = "album_id";
+        /**
+         * The column name for the mime-type String.
+         */
+        public static final String MIME_TYPE = "mime_type";
+    }
+
+    /**
+     * Contains columns and Uri for accessing album information.
+     */
+    public static interface Albums extends BaseColumns {
+        /**
+         * Internal database table used album information.
+         */
+        public static final String TABLE = "album";
+        /**
+         * Content URI for album information.
+         */
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE);
+        /**
+         * Parent directory or null if this is in the root.
+         */
+        public static final String PARENT_ID = "parent";
+        /**
+         * Column name for the name of the album. String value.
+         */
+        public static final String NAME = "name";
+        /**
+         * Column name for the visibility level of the album. Can be any of the
+         * VISIBILITY_* values.
+         */
+        public static final String VISIBILITY = "visibility";
+        /**
+         * Column name for the server identifier for this album. NULL if the
+         * server doesn't have this album yet.
+         */
+        public static final String SERVER_ID = "server_id";
+
+        // Privacy values for Albums.VISIBILITY
+        public static final int VISIBILITY_PRIVATE = 1;
+        public static final int VISIBILITY_SHARED = 2;
+        public static final int VISIBILITY_PUBLIC = 3;
+    }
+
+    /**
+     * Contains columns and Uri for accessing photo and video metadata
+     */
+    public static interface Metadata extends BaseColumns {
+        /**
+         * Internal database table used metadata information.
+         */
+        public static final String TABLE = "metadata";
+        /**
+         * Content URI for photo and video metadata.
+         */
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE);
+        /**
+         * Foreign key to photo_id. Long value.
+         */
+        public static final String PHOTO_ID = "photo_id";
+        /**
+         * Metadata key. String value
+         */
+        public static final String KEY = "key";
+        /**
+         * Metadata value. Type is based on key.
+         */
+        public static final String VALUE = "value";
+    }
+
+    /**
+     * Contains columns and Uri for maintaining the image cache.
+     */
+    public static interface ImageCache extends BaseColumns {
+        /**
+         * Internal database table used for the image cache
+         */
+        public static final String TABLE = "image_cache";
+
+        /**
+         * The image_type query parameter required for accessing a specific
+         * image
+         */
+        public static final String IMAGE_TYPE_QUERY_PARAMETER = "image_type";
+
+        // ImageCache.IMAGE_TYPE values
+        public static final int IMAGE_TYPE_THUMBNAIL = 1;
+        public static final int IMAGE_TYPE_PREVIEW = 2;
+        public static final int IMAGE_TYPE_ORIGINAL = 3;
+
+        /**
+         * Content URI for retrieving image paths. The
+         * IMAGE_TYPE_QUERY_PARAMETER must be used in queries.
+         */
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE);
+
+        /**
+         * Foreign key to the photos._id. Long value.
+         */
+        public static final String PHOTO_ID = "photos_id";
+        /**
+         * One of IMAGE_TYPE_* values.
+         */
+        public static final String IMAGE_TYPE = "image_type";
+        /**
+         * The String path to the image.
+         */
+        public static final String PATH = "path";
+    };
+
+    // SQL used within this class.
+    protected static final String WHERE_ID = BaseColumns._ID + " = ?";
+    protected static final String WHERE_METADATA_ID = Metadata.PHOTO_ID + " = ? AND "
+            + Metadata.KEY + " = ?";
+
+    protected static final String SELECT_ALBUM_ID = "SELECT " + Albums._ID + " FROM "
+            + Albums.TABLE;
+    protected static final String SELECT_PHOTO_ID = "SELECT " + Photos._ID + " FROM "
+            + Photos.TABLE;
+    protected static final String SELECT_PHOTO_COUNT = "SELECT COUNT(*) FROM " + Photos.TABLE;
+    protected static final String DELETE_PHOTOS = "DELETE FROM " + Photos.TABLE;
+    protected static final String DELETE_METADATA = "DELETE FROM " + Metadata.TABLE;
+    protected static final String SELECT_METADATA_COUNT = "SELECT COUNT(*) FROM " + Metadata.TABLE;
+    protected static final String WHERE = " WHERE ";
+    protected static final String IN = " IN ";
+    protected static final String NESTED_SELECT_START = "(";
+    protected static final String NESTED_SELECT_END = ")";
+
+    /**
+     * For selecting the mime-type for an image.
+     */
+    private static final String[] PROJECTION_MIME_TYPE = {
+        Photos.MIME_TYPE,
+    };
+
+    private SQLiteOpenHelper mOpenHelper;
+    protected static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    protected static final int MATCH_PHOTO = 1;
+    protected static final int MATCH_PHOTO_ID = 2;
+    protected static final int MATCH_ALBUM = 3;
+    protected static final int MATCH_ALBUM_ID = 4;
+    protected static final int MATCH_METADATA = 5;
+    protected static final int MATCH_METADATA_ID = 6;
+    protected static final int MATCH_IMAGE = 7;
+
+    static {
+        sUriMatcher.addURI(AUTHORITY, Photos.TABLE, MATCH_PHOTO);
+        // match against Photos._ID
+        sUriMatcher.addURI(AUTHORITY, Photos.TABLE + "/#", MATCH_PHOTO_ID);
+        sUriMatcher.addURI(AUTHORITY, Albums.TABLE, MATCH_ALBUM);
+        // match against Albums._ID
+        sUriMatcher.addURI(AUTHORITY, Albums.TABLE + "/#", MATCH_ALBUM_ID);
+        sUriMatcher.addURI(AUTHORITY, Metadata.TABLE, MATCH_METADATA);
+        // match against metadata/<Metadata._ID>
+        sUriMatcher.addURI(AUTHORITY, Metadata.TABLE + "/#", MATCH_METADATA_ID);
+        // match against image_cache/<ImageCache.PHOTO_ID>
+        sUriMatcher.addURI(AUTHORITY, ImageCache.TABLE + "/#", MATCH_IMAGE);
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        int match = matchUri(uri);
+        if (match == MATCH_IMAGE) {
+            throw new IllegalArgumentException("Cannot delete from image cache");
+        }
+        selection = addIdToSelection(match, selection);
+        selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs);
+        List<Uri> changeUris = new ArrayList<Uri>();
+        int deleted = 0;
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            deleted = deleteCascade(db, match, selection, selectionArgs, changeUris, uri);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        for (Uri changeUri : changeUris) {
+            notifyChanges(changeUri);
+        }
+        return deleted;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        Cursor cursor = query(uri, PROJECTION_MIME_TYPE, null, null, null);
+        String mimeType = null;
+        if (cursor.moveToNext()) {
+            mimeType = cursor.getString(0);
+        }
+        cursor.close();
+        return mimeType;
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        // Cannot insert into this ContentProvider
+        return null;
+    }
+
+    @Override
+    public boolean onCreate() {
+        mOpenHelper = createDatabaseHelper();
+        return true;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        return query(uri, projection, selection, selectionArgs, sortOrder, null);
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder, CancellationSignal cancellationSignal) {
+        int match = matchUri(uri);
+        selection = addIdToSelection(match, selection);
+        selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs);
+        String table = getTableFromMatch(match, uri);
+        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        return db.query(false, table, projection, selection, selectionArgs, null, null, sortOrder,
+                null, cancellationSignal);
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        int match = matchUri(uri);
+        int rowsUpdated = 0;
+        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            if (match == MATCH_METADATA) {
+                rowsUpdated = modifyMetadata(db, values);
+            } else {
+                selection = addIdToSelection(match, selection);
+                selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs);
+                String table = getTableFromMatch(match, uri);
+                rowsUpdated = db.update(table, values, selection, selectionArgs);
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        notifyChanges(uri);
+        return rowsUpdated;
+    }
+
+    protected static String addIdToSelection(int match, String selection) {
+        String where;
+        switch (match) {
+            case MATCH_PHOTO_ID:
+            case MATCH_ALBUM_ID:
+            case MATCH_METADATA_ID:
+                where = WHERE_ID;
+                break;
+            default:
+                return selection;
+        }
+        return DatabaseUtils.concatenateWhere(selection, where);
+    }
+
+    protected static String[] addIdToSelectionArgs(int match, Uri uri, String[] selectionArgs) {
+        String[] whereArgs;
+        switch (match) {
+            case MATCH_PHOTO_ID:
+            case MATCH_ALBUM_ID:
+            case MATCH_METADATA_ID:
+                whereArgs = new String[] {
+                    uri.getPathSegments().get(1),
+                };
+                break;
+            default:
+                return selectionArgs;
+        }
+        return DatabaseUtils.appendSelectionArgs(selectionArgs, whereArgs);
+    }
+
+    protected static String[] addMetadataKeysToSelectionArgs(String[] selectionArgs, Uri uri) {
+        List<String> segments = uri.getPathSegments();
+        String[] additionalArgs = {
+                segments.get(1),
+                segments.get(2),
+        };
+
+        return DatabaseUtils.appendSelectionArgs(selectionArgs, additionalArgs);
+    }
+
+    protected static String getTableFromMatch(int match, Uri uri) {
+        String table;
+        switch (match) {
+            case MATCH_PHOTO:
+            case MATCH_PHOTO_ID:
+                table = Photos.TABLE;
+                break;
+            case MATCH_ALBUM:
+            case MATCH_ALBUM_ID:
+                table = Albums.TABLE;
+                break;
+            case MATCH_METADATA:
+            case MATCH_METADATA_ID:
+                table = Metadata.TABLE;
+                break;
+            default:
+                throw unknownUri(uri);
+        }
+        return table;
+    }
+
+    protected final SQLiteOpenHelper getDatabaseHelper() {
+        return mOpenHelper;
+    }
+
+    protected SQLiteOpenHelper createDatabaseHelper() {
+        return new PhotoDatabase(getContext());
+    }
+
+    private int modifyMetadata(SQLiteDatabase db, ContentValues values) {
+        String[] selectionArgs = {
+            values.getAsString(Metadata.PHOTO_ID),
+            values.getAsString(Metadata.KEY),
+        };
+        int rowCount;
+        if (values.get(Metadata.VALUE) == null) {
+            rowCount = db.delete(Metadata.TABLE, WHERE_METADATA_ID, selectionArgs);
+        } else {
+            rowCount = (int) DatabaseUtils.queryNumEntries(db, Metadata.TABLE, WHERE_METADATA_ID,
+                    selectionArgs);
+            if (rowCount > 0) {
+                db.update(Metadata.TABLE, values, WHERE_METADATA_ID, selectionArgs);
+            } else {
+                db.insert(Metadata.TABLE, null, values);
+                rowCount = 1;
+            }
+        }
+        return rowCount;
+    }
+
+    private int matchUri(Uri uri) {
+        int match = sUriMatcher.match(uri);
+        if (match == UriMatcher.NO_MATCH) {
+            throw unknownUri(uri);
+        }
+        return match;
+    }
+
+    protected void notifyChanges(Uri uri) {
+        ContentResolver resolver = getContext().getContentResolver();
+        resolver.notifyChange(uri, null, false);
+    }
+
+    protected static IllegalArgumentException unknownUri(Uri uri) {
+        return new IllegalArgumentException("Unknown Uri format: " + uri);
+    }
+
+    protected static String nestSql(String base, String columnMatch, String nested) {
+        StringBuilder sql = new StringBuilder(base);
+        sql.append(WHERE);
+        sql.append(columnMatch);
+        sql.append(IN);
+        sql.append(NESTED_SELECT_START);
+        sql.append(nested);
+        sql.append(NESTED_SELECT_END);
+        return sql.toString();
+    }
+
+    protected static String addWhere(String base, String where) {
+        if (where == null || where.isEmpty()) {
+            return base;
+        }
+        return base + WHERE + where;
+    }
+
+    protected static int deleteCascade(SQLiteDatabase db, int match, String selection,
+            String[] selectionArgs, List<Uri> changeUris, Uri uri) {
+        switch (match) {
+            case MATCH_PHOTO:
+            case MATCH_PHOTO_ID: {
+                String selectPhotoIdsSql = addWhere(SELECT_PHOTO_ID, selection);
+                deleteCascadeMetadata(db, selectPhotoIdsSql, selectionArgs, changeUris);
+                break;
+            }
+            case MATCH_ALBUM:
+            case MATCH_ALBUM_ID: {
+                String selectAlbumIdSql = addWhere(SELECT_ALBUM_ID, selection);
+                deleteCascadePhotos(db, selectAlbumIdSql, selectionArgs, changeUris);
+                break;
+            }
+        }
+        String table = getTableFromMatch(match, uri);
+        changeUris.add(uri);
+        return db.delete(table, selection, selectionArgs);
+    }
+
+    protected static void execSql(SQLiteDatabase db, String sql, String[] args) {
+        if (args == null) {
+            db.execSQL(sql);
+        } else {
+            db.execSQL(sql, args);
+        }
+    }
+
+    private static void deleteCascadePhotos(SQLiteDatabase db, String albumSelect,
+            String[] selectArgs, List<Uri> changeUris) {
+        String selectPhotoIdSql = nestSql(SELECT_PHOTO_ID, Photos.ALBUM_ID, albumSelect);
+        deleteCascadeMetadata(db, selectPhotoIdSql, selectArgs, changeUris);
+        String deletePhotoSql = nestSql(DELETE_PHOTOS, Photos.ALBUM_ID, albumSelect);
+        SQLiteStatement statement = db.compileStatement(deletePhotoSql);
+        statement.bindAllArgsAsStrings(selectArgs);
+        int deleted = statement.executeUpdateDelete();
+        if (deleted > 0) {
+            changeUris.add(Photos.CONTENT_URI);
+        }
+    }
+
+    private static void deleteCascadeMetadata(SQLiteDatabase db, String photosSelect,
+            String[] selectArgs, List<Uri> changeUris) {
+        String deleteMetadataSql = nestSql(DELETE_METADATA, Metadata.PHOTO_ID, photosSelect);
+        SQLiteStatement statement = db.compileStatement(deleteMetadataSql);
+        statement.bindAllArgsAsStrings(selectArgs);
+        int deleted = statement.executeUpdateDelete();
+        if (deleted > 0) {
+            changeUris.add(Metadata.CONTENT_URI);
+        }
+    }
+}
index ef63cc4..b98b5e0 100644 (file)
@@ -36,4 +36,8 @@
     <instrumentation android:name="com.android.gallery3d.stress.CameraStressTestRunner"
             android:targetPackage="com.android.gallery3d"
             android:label="Camera stress test runner"/>
+
+    <instrumentation android:name="com.android.photos.data.DataTestRunner"
+            android:targetPackage="com.android.gallery3d"
+            android:label="Tests for android photo DataProviders."/>
 </manifest>
diff --git a/tests/src/com/android/photos/data/DataTestRunner.java b/tests/src/com/android/photos/data/DataTestRunner.java
new file mode 100644 (file)
index 0000000..4322585
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.test.InstrumentationTestRunner;
+import android.test.InstrumentationTestSuite;
+
+import junit.framework.TestSuite;
+
+public class DataTestRunner extends InstrumentationTestRunner {
+    @Override
+    public TestSuite getAllTests() {
+        TestSuite suite = new InstrumentationTestSuite(this);
+        suite.addTestSuite(PhotoDatabaseTest.class);
+        suite.addTestSuite(PhotoProviderTest.class);
+        return suite;
+    }
+
+    @Override
+    public ClassLoader getLoader() {
+        return DataTestRunner.class.getClassLoader();
+    }
+}
diff --git a/tests/src/com/android/photos/data/PhotoDatabaseTest.java b/tests/src/com/android/photos/data/PhotoDatabaseTest.java
new file mode 100644 (file)
index 0000000..48e79d4
--- /dev/null
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.test.InstrumentationTestCase;
+
+import com.android.photos.data.PhotoProvider.Albums;
+import com.android.photos.data.PhotoProvider.Metadata;
+import com.android.photos.data.PhotoProvider.Photos;
+
+import java.io.File;
+import java.io.IOException;
+
+public class PhotoDatabaseTest extends InstrumentationTestCase {
+
+    private PhotoDatabase mDBHelper;
+
+    @Override
+    protected void setUp() {
+        Context context = getInstrumentation().getTargetContext();
+        mDBHelper = new PhotoDatabase(context);
+    }
+
+    @Override
+    protected void tearDown() {
+        mDBHelper.close();
+    }
+
+    public void testCreateDatabase() throws IOException {
+        Context context = getInstrumentation().getTargetContext();
+        File dbFile = context.getDatabasePath(PhotoDatabase.DB_NAME);
+        if (dbFile.exists()) {
+            dbFile.delete();
+        }
+        SQLiteDatabase db = getReadableDB();
+        db.beginTransaction();
+        db.endTransaction();
+        assertTrue(dbFile.exists());
+        dbFile.delete();
+    }
+
+    public void testTables() {
+        validateTable(Metadata.TABLE, PhotoDatabaseUtils.PROJECTION_METADATA);
+        validateTable(Albums.TABLE, PhotoDatabaseUtils.PROJECTION_ALBUMS);
+        validateTable(Photos.TABLE, PhotoDatabaseUtils.PROJECTION_PHOTOS);
+    }
+
+    public void testAlbumsConstraints() {
+        SQLiteDatabase db = getWriteableDB();
+        db.beginTransaction();
+        try {
+            // Test NOT NULL constraint on name
+            assertFalse(PhotoDatabaseUtils
+                    .insertAlbum(db, null, null, Albums.VISIBILITY_PRIVATE, null));
+
+            // test NOT NULL constraint on privacy
+            assertFalse(PhotoDatabaseUtils.insertAlbum(db, null, "hello", null, null));
+
+            // Normal insert
+            assertTrue(PhotoDatabaseUtils.insertAlbum(db, null, "hello", Albums.VISIBILITY_PRIVATE,
+                    100L));
+
+            // Test server id uniqueness
+            assertFalse(PhotoDatabaseUtils.insertAlbum(db, null, "world", Albums.VISIBILITY_PRIVATE,
+                    100L));
+
+            // Different server id allowed
+            assertTrue(PhotoDatabaseUtils.insertAlbum(db, null, "world", Albums.VISIBILITY_PRIVATE,
+                    101L));
+
+            // Allow null server id
+            assertTrue(PhotoDatabaseUtils.insertAlbum(db, null, "hello world",
+                    Albums.VISIBILITY_PRIVATE, null));
+
+            long albumId = PhotoDatabaseUtils.queryAlbumIdFromServerId(db, 100);
+
+            // Assign a valid child
+            assertTrue(PhotoDatabaseUtils.insertAlbum(db, albumId, "hello", Albums.VISIBILITY_PRIVATE,
+                    null));
+
+            long otherAlbumId = PhotoDatabaseUtils.queryAlbumIdFromServerId(db, 101);
+            assertNotSame(albumId, otherAlbumId);
+
+            // This is a valid child of another album.
+            assertTrue(PhotoDatabaseUtils.insertAlbum(db, otherAlbumId, "hello",
+                    Albums.VISIBILITY_PRIVATE, null));
+
+            // This isn't allowed due to uniqueness constraint (parent_id/name)
+            assertFalse(PhotoDatabaseUtils.insertAlbum(db, otherAlbumId, "hello",
+                    Albums.VISIBILITY_PRIVATE, null));
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void testPhotosConstraints() {
+        SQLiteDatabase db = getWriteableDB();
+        db.beginTransaction();
+        try {
+            int width = 100;
+            int height = 100;
+            long dateTaken = System.currentTimeMillis();
+            String mimeType = "test/test";
+
+            // Test NOT NULL mime-type
+            assertFalse(PhotoDatabaseUtils.insertPhoto(db, null, width, height, dateTaken, null,
+                    null));
+
+            // Test NOT NULL width
+            assertFalse(PhotoDatabaseUtils.insertPhoto(db, null, null, height, dateTaken, null,
+                    mimeType));
+
+            // Test NOT NULL height
+            assertFalse(PhotoDatabaseUtils.insertPhoto(db, null, width, null, dateTaken, null,
+                    mimeType));
+
+            // Test NOT NULL dateTaken
+            assertFalse(PhotoDatabaseUtils.insertPhoto(db, null, width, height, null, null,
+                    mimeType));
+
+            // Test normal insert
+            assertTrue(PhotoDatabaseUtils.insertPhoto(db, null, width, height, dateTaken, null,
+                    mimeType));
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void testMetadataConstraints() {
+        SQLiteDatabase db = getWriteableDB();
+        db.beginTransaction();
+        try {
+            final String mimeType = "test/test";
+            long photoServerId = 100;
+            PhotoDatabaseUtils.insertPhoto(db, photoServerId, 100, 100, 100L, null, mimeType);
+            long photoId = PhotoDatabaseUtils.queryPhotoIdFromServerId(db, photoServerId);
+
+            // Test NOT NULL PHOTO_ID constraint.
+            assertFalse(PhotoDatabaseUtils.insertMetadata(db, null, "foo", "bar"));
+
+            // Normal insert.
+            assertTrue(PhotoDatabaseUtils.insertMetadata(db, photoId, "foo", "bar"));
+
+            // Test uniqueness constraint.
+            assertFalse(PhotoDatabaseUtils.insertMetadata(db, photoId, "foo", "baz"));
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    private SQLiteDatabase getReadableDB() {
+        return mDBHelper.getReadableDatabase();
+    }
+
+    private SQLiteDatabase getWriteableDB() {
+        return mDBHelper.getWritableDatabase();
+    }
+
+    private void validateTable(String table, String[] projection) {
+        SQLiteDatabase db = getReadableDB();
+        Cursor cursor = db.query(table, projection, null, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(cursor.getCount(), 0);
+        assertEquals(cursor.getColumnCount(), projection.length);
+        for (int i = 0; i < projection.length; i++) {
+            assertEquals(cursor.getColumnName(i), projection[i]);
+        }
+    }
+
+
+}
diff --git a/tests/src/com/android/photos/data/PhotoDatabaseUtils.java b/tests/src/com/android/photos/data/PhotoDatabaseUtils.java
new file mode 100644 (file)
index 0000000..6fd73e1
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+
+import com.android.photos.data.PhotoProvider.Albums;
+import com.android.photos.data.PhotoProvider.Metadata;
+import com.android.photos.data.PhotoProvider.Photos;
+
+import junit.framework.AssertionFailedError;
+
+public class PhotoDatabaseUtils {
+    public static String[] PROJECTION_ALBUMS = {
+        Albums._ID,
+        Albums.PARENT_ID,
+        Albums.VISIBILITY,
+        Albums.NAME,
+        Albums.SERVER_ID,
+    };
+
+    public static String[] PROJECTION_METADATA = {
+        Metadata.PHOTO_ID,
+        Metadata.KEY,
+        Metadata.VALUE,
+    };
+
+    public static String[] PROJECTION_PHOTOS = {
+        Photos._ID,
+        Photos.SERVER_ID,
+        Photos.WIDTH,
+        Photos.HEIGHT,
+        Photos.DATE_TAKEN,
+        Photos.ALBUM_ID,
+        Photos.MIME_TYPE,
+    };
+
+    private static String SELECTION_ALBUM_SERVER_ID = Albums.SERVER_ID + " = ?";
+    private static String SELECTION_PHOTO_SERVER_ID = Photos.SERVER_ID + " = ?";
+
+    public static long queryAlbumIdFromServerId(SQLiteDatabase db, long serverId) {
+        return queryId(db, Albums.TABLE, PROJECTION_ALBUMS, SELECTION_ALBUM_SERVER_ID, serverId);
+    }
+
+    public static long queryPhotoIdFromServerId(SQLiteDatabase db, long serverId) {
+        return queryId(db, Photos.TABLE, PROJECTION_PHOTOS, SELECTION_PHOTO_SERVER_ID, serverId);
+    }
+
+    public static long queryId(SQLiteDatabase db, String table, String[] projection,
+            String selection, Object parameter) {
+        String paramString = parameter == null ? null : parameter.toString();
+        String[] selectionArgs = {
+            paramString,
+        };
+        Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, null);
+        try {
+            if (cursor.getCount() != 1 || !cursor.moveToNext()) {
+                throw new AssertionFailedError("Couldn't find item in table");
+            }
+            long id = cursor.getLong(0);
+            return id;
+        } finally {
+            cursor.close();
+        }
+    }
+
+    public static boolean insertPhoto(SQLiteDatabase db, Long serverId, Integer width,
+            Integer height, Long dateTaken, Long albumId, String mimeType) {
+        ContentValues values = new ContentValues();
+        values.put(Photos.SERVER_ID, serverId);
+        values.put(Photos.WIDTH, width);
+        values.put(Photos.HEIGHT, height);
+        values.put(Photos.DATE_TAKEN, dateTaken);
+        values.put(Photos.ALBUM_ID, albumId);
+        values.put(Photos.MIME_TYPE, mimeType);
+        return db.insert(Photos.TABLE, null, values) != -1;
+    }
+
+    public static boolean insertAlbum(SQLiteDatabase db, Long parentId, String name,
+            Integer privacy, Long serverId) {
+        ContentValues values = new ContentValues();
+        values.put(Albums.PARENT_ID, parentId);
+        values.put(Albums.NAME, name);
+        values.put(Albums.VISIBILITY, privacy);
+        values.put(Albums.SERVER_ID, serverId);
+        return db.insert(Albums.TABLE, null, values) != -1;
+    }
+
+    public static boolean insertMetadata(SQLiteDatabase db, Long photosId, String key, String value) {
+        ContentValues values = new ContentValues();
+        values.put(Metadata.PHOTO_ID, photosId);
+        values.put(Metadata.KEY, key);
+        values.put(Metadata.VALUE, value);
+        return db.insert(Metadata.TABLE, null, values) != -1;
+    }
+
+    public static void deleteAllContent(SQLiteDatabase db) {
+        db.delete(Metadata.TABLE, null, null);
+        db.delete(Photos.TABLE, null, null);
+        db.delete(Albums.TABLE, null, null);
+    }
+}
diff --git a/tests/src/com/android/photos/data/PhotoProviderTest.java b/tests/src/com/android/photos/data/PhotoProviderTest.java
new file mode 100644 (file)
index 0000000..ad913b0
--- /dev/null
@@ -0,0 +1,463 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.BaseColumns;
+import android.test.InstrumentationTestCase;
+
+import com.android.photos.data.PhotoProvider.Albums;
+import com.android.photos.data.PhotoProvider.Metadata;
+import com.android.photos.data.PhotoProvider.Photos;
+
+public class PhotoProviderTest extends InstrumentationTestCase {
+    @SuppressWarnings("unused")
+    private static final String TAG = PhotoProviderTest.class.getSimpleName();
+
+    private static final String MIME_TYPE = "test/test";
+    private static final String ALBUM_NAME = "My Album";
+    private static final long ALBUM_SERVER_ID = 100;
+    private static final long PHOTO_SERVER_ID = 50;
+    private static final String META_KEY = "mykey";
+    private static final String META_VALUE = "myvalue";
+
+    private static final Uri NO_TABLE_URI = PhotoProvider.BASE_CONTENT_URI;
+    private static final Uri BAD_TABLE_URI = Uri.withAppendedPath(PhotoProvider.BASE_CONTENT_URI,
+            "bad_table");
+
+    private static final String WHERE_METADATA_PHOTOS_ID = Metadata.PHOTO_ID + " = ?";
+    private static final String WHERE_METADATA = Metadata.PHOTO_ID + " = ? AND " + Metadata.KEY
+            + " = ?";
+
+    private static final long WAIT_FOR_CHANGE_MILLIS = 200;
+
+    private long mAlbumId;
+    private long mPhotoId;
+    private long mMetadataId;
+
+    private PhotoDatabase mDBHelper;
+    private ContentResolver mResolver;
+
+    private static class WatchContentObserverThread extends Thread {
+        private WatchContentObserver mObserver;
+        private Looper mLooper;
+
+        @Override
+        public void run() {
+            Looper.prepare();
+            mLooper = Looper.myLooper();
+            WatchContentObserver observer = new WatchContentObserver();
+            synchronized (this) {
+                mObserver = observer;
+                this.notifyAll();
+            }
+            Looper.loop();
+        }
+
+        public void waitForObserver() throws InterruptedException {
+            synchronized (this) {
+                while (mObserver == null) {
+                    this.wait();
+                }
+            }
+        }
+
+        public WatchContentObserver getObserver() {
+            return mObserver;
+        }
+
+        public void stopLooper() {
+            mLooper.quit();
+        }
+    };
+
+    private static class WatchContentObserver extends ContentObserver {
+        private boolean mOnChangeReceived = false;
+        private Uri mUri = null;
+
+        public WatchContentObserver() {
+            super(new Handler());
+        }
+
+        @Override
+        public synchronized void onChange(boolean selfChange, Uri uri) {
+            mOnChangeReceived = true;
+            mUri = uri;
+            notifyAll();
+        }
+
+        @Override
+        public synchronized void onChange(boolean selfChange) {
+            mOnChangeReceived = true;
+            notifyAll();
+        }
+
+        public boolean waitForNotification() {
+            synchronized (this) {
+                if (!mOnChangeReceived) {
+                    try {
+                        wait(WAIT_FOR_CHANGE_MILLIS);
+                    } catch (InterruptedException e) {
+                    }
+                }
+            }
+            return mOnChangeReceived;
+        }
+    };
+
+    @Override
+    protected void setUp() {
+        Context context = getInstrumentation().getTargetContext();
+        mDBHelper = new PhotoDatabase(context);
+        mResolver = context.getContentResolver();
+        SQLiteDatabase db = mDBHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            PhotoDatabaseUtils.insertAlbum(db, null, ALBUM_NAME, Albums.VISIBILITY_PRIVATE,
+                    ALBUM_SERVER_ID);
+            mAlbumId = PhotoDatabaseUtils.queryAlbumIdFromServerId(db, ALBUM_SERVER_ID);
+            PhotoDatabaseUtils.insertPhoto(db, PHOTO_SERVER_ID, 100, 100,
+                    System.currentTimeMillis(), mAlbumId, MIME_TYPE);
+            mPhotoId = PhotoDatabaseUtils.queryPhotoIdFromServerId(db, PHOTO_SERVER_ID);
+            PhotoDatabaseUtils.insertMetadata(db, mPhotoId, META_KEY, META_VALUE);
+            String[] projection = {
+                    BaseColumns._ID,
+            };
+            Cursor cursor = db.query(Metadata.TABLE, projection, null, null, null, null, null);
+            cursor.moveToNext();
+            mMetadataId = cursor.getLong(0);
+            cursor.close();
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    @Override
+    protected void tearDown() {
+        SQLiteDatabase db = mDBHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            PhotoDatabaseUtils.deleteAllContent(db);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        mDBHelper.close();
+        mDBHelper = null;
+    }
+
+    public void testDelete() {
+        try {
+            mResolver.delete(NO_TABLE_URI, null, null);
+            fail("Exeption should be thrown when no table given");
+        } catch (Exception e) {
+            // expected exception
+        }
+        try {
+            mResolver.delete(BAD_TABLE_URI, null, null);
+            fail("Exeption should be thrown when deleting from a table that doesn't exist");
+        } catch (Exception e) {
+            // expected exception
+        }
+
+        String[] selectionArgs = {
+            String.valueOf(mPhotoId)
+        };
+        // Delete some metadata
+        assertEquals(1,
+                mResolver.delete(Metadata.CONTENT_URI, WHERE_METADATA_PHOTOS_ID, selectionArgs));
+        Uri photoUri = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId);
+        assertEquals(1, mResolver.delete(photoUri, null, null));
+        Uri albumUri = ContentUris.withAppendedId(Albums.CONTENT_URI, mAlbumId);
+        assertEquals(1, mResolver.delete(albumUri, null, null));
+        // now delete something that isn't there
+        assertEquals(0, mResolver.delete(photoUri, null, null));
+    }
+
+    public void testDeleteMetadataId() {
+        Uri metadataUri = ContentUris.withAppendedId(Metadata.CONTENT_URI, mMetadataId);
+        assertEquals(1, mResolver.delete(metadataUri, null, null));
+        Cursor cursor = mResolver.query(Metadata.CONTENT_URI, null, null, null, null);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+    }
+    // Delete the album and ensure that the photos referring to the album are
+    // deleted.
+    public void testDeleteAlbumCascade() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Photos.CONTENT_URI, true, observer);
+        try {
+            Uri albumUri = ContentUris.withAppendedId(Albums.CONTENT_URI, mAlbumId);
+            mResolver.delete(albumUri, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, Photos.CONTENT_URI);
+            Cursor cursor = mResolver.query(Photos.CONTENT_URI,
+                    PhotoDatabaseUtils.PROJECTION_PHOTOS, null, null, null);
+            assertEquals(0, cursor.getCount());
+            cursor.close();
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    // Delete the album and ensure that the metadata referring to photos in that
+    // album are deleted.
+    public void testDeleteAlbumCascade2() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Metadata.CONTENT_URI, true, observer);
+        try {
+            Uri albumUri = ContentUris.withAppendedId(Albums.CONTENT_URI, mAlbumId);
+            mResolver.delete(albumUri, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, Metadata.CONTENT_URI);
+            Cursor cursor = mResolver.query(Metadata.CONTENT_URI,
+                    PhotoDatabaseUtils.PROJECTION_METADATA, null, null, null);
+            assertEquals(0, cursor.getCount());
+            cursor.close();
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    // Delete all albums and ensure that photos in any album are deleted.
+    public void testDeleteAlbumCascade3() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Photos.CONTENT_URI, true, observer);
+        try {
+            mResolver.delete(Albums.CONTENT_URI, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, Photos.CONTENT_URI);
+            Cursor cursor = mResolver.query(Photos.CONTENT_URI,
+                    PhotoDatabaseUtils.PROJECTION_PHOTOS, null, null, null);
+            assertEquals(0, cursor.getCount());
+            cursor.close();
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    // Delete a photo and ensure that the metadata for that photo are deleted.
+    public void testDeletePhotoCascade() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Metadata.CONTENT_URI, true, observer);
+        try {
+            Uri albumUri = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId);
+            mResolver.delete(albumUri, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, Metadata.CONTENT_URI);
+            Cursor cursor = mResolver.query(Metadata.CONTENT_URI,
+                    PhotoDatabaseUtils.PROJECTION_METADATA, null, null, null);
+            assertEquals(0, cursor.getCount());
+            cursor.close();
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    public void testGetType() {
+        // We don't return types for albums
+        assertNull(mResolver.getType(Albums.CONTENT_URI));
+
+        Uri noImage = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId + 1);
+        assertNull(mResolver.getType(noImage));
+
+        Uri image = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId);
+        assertEquals(MIME_TYPE, mResolver.getType(image));
+    }
+
+    public void testInsert() {
+        ContentValues values = new ContentValues();
+        values.put(Albums.NAME, "don't add me");
+        values.put(Albums.VISIBILITY, Albums.VISIBILITY_PRIVATE);
+        assertNull(mResolver.insert(Albums.CONTENT_URI, values));
+    }
+
+    public void testUpdate() {
+        ContentValues values = new ContentValues();
+        // Normal update -- use an album.
+        values.put(Albums.NAME, "foo");
+        Uri albumUri = ContentUris.withAppendedId(Albums.CONTENT_URI, mAlbumId);
+        assertEquals(1, mResolver.update(albumUri, values, null, null));
+        String[] projection = {
+            Albums.NAME,
+        };
+        Cursor cursor = mResolver.query(albumUri, projection, null, null, null);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToNext());
+        assertEquals("foo", cursor.getString(0));
+        cursor.close();
+
+        // Update a row that doesn't exist.
+        Uri noAlbumUri = ContentUris.withAppendedId(Albums.CONTENT_URI, mAlbumId + 1);
+        values.put(Albums.NAME, "bar");
+        assertEquals(0, mResolver.update(noAlbumUri, values, null, null));
+
+        // Update a metadata value that exists.
+        ContentValues metadata = new ContentValues();
+        metadata.put(Metadata.PHOTO_ID, mPhotoId);
+        metadata.put(Metadata.KEY, META_KEY);
+        metadata.put(Metadata.VALUE, "new value");
+        assertEquals(1, mResolver.update(Metadata.CONTENT_URI, metadata, null, null));
+
+        projection = new String[] {
+            Metadata.VALUE,
+        };
+
+        String[] selectionArgs = {
+                String.valueOf(mPhotoId), META_KEY,
+        };
+
+        cursor = mResolver.query(Metadata.CONTENT_URI, projection, WHERE_METADATA, selectionArgs,
+                null);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToNext());
+        assertEquals("new value", cursor.getString(0));
+        cursor.close();
+
+        // Update a metadata value that doesn't exist.
+        metadata.put(Metadata.KEY, "other stuff");
+        assertEquals(1, mResolver.update(Metadata.CONTENT_URI, metadata, null, null));
+
+        selectionArgs[1] = "other stuff";
+        cursor = mResolver.query(Metadata.CONTENT_URI, projection, WHERE_METADATA, selectionArgs,
+                null);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToNext());
+        assertEquals("new value", cursor.getString(0));
+        cursor.close();
+
+        // Remove a metadata value using update.
+        metadata.putNull(Metadata.VALUE);
+        assertEquals(1, mResolver.update(Metadata.CONTENT_URI, metadata, null, null));
+        cursor = mResolver.query(Metadata.CONTENT_URI, projection, WHERE_METADATA, selectionArgs,
+                null);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+    }
+
+    public void testQuery() {
+        // Query a photo that exists.
+        Cursor cursor = mResolver.query(Photos.CONTENT_URI, PhotoDatabaseUtils.PROJECTION_PHOTOS,
+                null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToNext());
+        assertEquals(mPhotoId, cursor.getLong(0));
+        cursor.close();
+
+        // Query a photo that doesn't exist.
+        Uri noPhotoUri = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId + 1);
+        cursor = mResolver.query(noPhotoUri, PhotoDatabaseUtils.PROJECTION_PHOTOS, null, null,
+                null);
+        assertNotNull(cursor);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+
+        // Query a photo that exists using selection arguments.
+        String[] selectionArgs = {
+            String.valueOf(mPhotoId),
+        };
+
+        cursor = mResolver.query(Photos.CONTENT_URI, PhotoDatabaseUtils.PROJECTION_PHOTOS,
+                Photos._ID + " = ?", selectionArgs, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToNext());
+        assertEquals(mPhotoId, cursor.getLong(0));
+        cursor.close();
+    }
+
+    public void testUpdatePhotoNotification() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Photos.CONTENT_URI, true, observer);
+        try {
+            Uri photoUri = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId);
+            ContentValues values = new ContentValues();
+            values.put(Photos.MIME_TYPE, "not-a/mime-type");
+            mResolver.update(photoUri, values, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, photoUri);
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    public void testUpdateMetadataNotification() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Metadata.CONTENT_URI, true, observer);
+        try {
+            ContentValues values = new ContentValues();
+            values.put(Metadata.PHOTO_ID, mPhotoId);
+            values.put(Metadata.KEY, META_KEY);
+            values.put(Metadata.VALUE, "hello world");
+            mResolver.update(Metadata.CONTENT_URI, values, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, Metadata.CONTENT_URI);
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    public void testDeletePhotoNotification() {
+        WatchContentObserverThread observerThread = createObserverThread();
+        WatchContentObserver observer = observerThread.getObserver();
+        mResolver.registerContentObserver(Photos.CONTENT_URI, true, observer);
+        try {
+            Uri photoUri = ContentUris.withAppendedId(Photos.CONTENT_URI, mPhotoId);
+            mResolver.delete(photoUri, null, null);
+            assertTrue(observer.waitForNotification());
+            assertEquals(observer.mUri, photoUri);
+        } finally {
+            mResolver.unregisterContentObserver(observer);
+            observerThread.stopLooper();
+        }
+    }
+
+    private WatchContentObserverThread createObserverThread() {
+        WatchContentObserverThread thread = new WatchContentObserverThread();
+        thread.start();
+        try {
+            thread.waitForObserver();
+            return thread;
+        } catch (InterruptedException e) {
+            thread.stopLooper();
+            fail("Interruption while waiting for observer being created.");
+            return null;
+        }
+    }
+}