Bug 1232439 - Part 2: Support get/update/remove bookmark record by ID in BrowserDB. r=Grisha
authorJing-wei Wu <topwu.tw@gmail.com>
Wed, 05 Apr 2017 14:03:16 +0800
changeset 566311 59af50c06ae546baa5124dcdf814260acf4ff311
parent 566310 3b8079a1c1356a0d23f7c7e2907f4f12cdf8228c
child 566312 5a0b0722bb130a9fc1c696ae0f538725eebe3843
push id55180
push userjjong@mozilla.com
push dateFri, 21 Apr 2017 09:36:13 +0000
reviewersGrisha
bugs1232439
milestone55.0a1
Bug 1232439 - Part 2: Support get/update/remove bookmark record by ID in BrowserDB. r=Grisha MozReview-Commit-ID: 6RNKCUiZE8K
mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/LocalBrowserDBTest.java
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -45,16 +45,17 @@ public class BrowserContract {
     public static final String PARAM_PROFILE = "profile";
     public static final String PARAM_PROFILE_PATH = "profilePath";
     public static final String PARAM_LIMIT = "limit";
     public static final String PARAM_SUGGESTEDSITES_LIMIT = "suggestedsites_limit";
     public static final String PARAM_TOPSITES_EXCLUDE_REMOTE_ONLY = "topsites_exclude_remote_only";
     public static final String PARAM_IS_SYNC = "sync";
     public static final String PARAM_SHOW_DELETED = "show_deleted";
     public static final String PARAM_IS_TEST = "test";
+    public static final String PARAM_OLD_BOOKMARK_PARENT = "old_bookmark_parent";
     public static final String PARAM_INSERT_IF_NEEDED = "insert_if_needed";
     public static final String PARAM_INCREMENT_VISITS = "increment_visits";
     public static final String PARAM_INCREMENT_REMOTE_AGGREGATES = "increment_remote_aggregates";
     public static final String PARAM_NON_POSITIONED_PINS = "non_positioned_pins";
     public static final String PARAM_EXPIRE_PRIORITY = "priority";
     public static final String PARAM_DATASET_ID = "dataset_id";
     public static final String PARAM_GROUP_BY = "group_by";
 
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
@@ -17,16 +17,17 @@ import org.mozilla.gecko.icons.decoders.
 
 import android.content.ContentProviderClient;
 import android.content.ContentProviderOperation;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
 import android.graphics.drawable.BitmapDrawable;
+import android.net.Uri;
 import android.support.v4.content.CursorLoader;
 
 /**
  * Interface for interactions with all databases. If you want an instance
  * that implements this, you should go through GeckoProfile. E.g.,
  * <code>BrowserDB.from(context)</code>.
  */
 public abstract class BrowserDB {
@@ -101,21 +102,25 @@ public abstract class BrowserDB {
 
     public abstract void clearHistory(ContentResolver cr, boolean clearSearchHistory);
 
 
     public abstract String getUrlForKeyword(ContentResolver cr, String keyword);
 
     public abstract boolean isBookmark(ContentResolver cr, String uri);
     public abstract boolean addBookmark(ContentResolver cr, String title, String uri);
+    public abstract Uri addBookmarkFolder(ContentResolver cr, String title, long parentId);
     public abstract Cursor getBookmarkForUrl(ContentResolver cr, String url);
     public abstract Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl);
+    public abstract Cursor getBookmarkById(ContentResolver cr, long id);
     public abstract void removeBookmarksWithURL(ContentResolver cr, String uri);
+    public abstract void removeBookmarkWithId(ContentResolver cr, long id);
     public abstract void registerBookmarkObserver(ContentResolver cr, ContentObserver observer);
-    public abstract void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword);
+    public abstract void updateBookmark(ContentResolver cr, long id, String uri, String title, String keyword);
+    public abstract void updateBookmark(ContentResolver cr, long id, String uri, String title, String keyword, long newParentId, long oldParentId);
     public abstract boolean hasBookmarkWithGuid(ContentResolver cr, String guid);
 
     public abstract boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON);
     public abstract int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl);
     /**
      * Can return <code>null</code>.
      */
     public abstract Cursor getBookmarksInFolder(ContentResolver cr, long folderId);
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
@@ -10,33 +10,31 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import org.mozilla.gecko.AboutPages;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
-import org.mozilla.gecko.activitystream.ranking.HighlightsRanking;
 import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist;
 import org.mozilla.gecko.db.BrowserContract.Bookmarks;
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserContract.FaviconColumns;
 import org.mozilla.gecko.db.BrowserContract.Favicons;
 import org.mozilla.gecko.db.BrowserContract.Highlights;
 import org.mozilla.gecko.db.BrowserContract.History;
 import org.mozilla.gecko.db.BrowserContract.Visits;
 import org.mozilla.gecko.db.BrowserContract.Schema;
 import org.mozilla.gecko.db.BrowserContract.Tabs;
 import org.mozilla.gecko.db.BrowserContract.Thumbnails;
 import org.mozilla.gecko.db.BrowserContract.TopSites;
 import org.mozilla.gecko.db.BrowserContract.UrlAnnotations;
 import org.mozilla.gecko.db.BrowserContract.PageMetadata;
 import org.mozilla.gecko.db.DBUtils.UpdateOperation;
-import org.mozilla.gecko.home.activitystream.model.Highlight;
 import org.mozilla.gecko.icons.IconsHelper;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.BroadcastReceiver;
 import android.content.ContentProviderOperation;
 import android.content.ContentProviderResult;
@@ -45,17 +43,16 @@ import android.content.ContentValues;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.OperationApplicationException;
 import android.content.UriMatcher;
 import android.database.Cursor;
 import android.database.DatabaseUtils;
 import android.database.MatrixCursor;
-import android.database.MergeCursor;
 import android.database.SQLException;
 import android.database.sqlite.SQLiteConstraintException;
 import android.database.sqlite.SQLiteCursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteQueryBuilder;
 import android.database.sqlite.SQLiteStatement;
 import android.net.Uri;
 import android.os.Bundle;
@@ -1500,17 +1497,22 @@ public class BrowserProvider extends Sha
         return processCount;
     }
 
     /**
      * Construct an update expression that will modify the parents of any records
      * that match.
      */
     private int updateBookmarkParents(SQLiteDatabase db, ContentValues values, String selection, String[] selectionArgs) {
-        trace("Updating bookmark parents of " + selection + " (" + selectionArgs[0] + ")");
+        if (selectionArgs != null) {
+            trace("Updating bookmark parents of " + selection + " (" + selectionArgs[0] + ")");
+        } else {
+            trace("Updating bookmark parents of " + selection);
+        }
+
         String where = Bookmarks._ID + " IN (" +
                        " SELECT DISTINCT " + Bookmarks.PARENT +
                        " FROM " + TABLE_BOOKMARKS +
                        " WHERE " + selection + " )";
         return db.update(TABLE_BOOKMARKS, values, where, selectionArgs);
     }
 
     private long insertBookmark(Uri uri, ContentValues values) {
@@ -1541,17 +1543,42 @@ public class BrowserProvider extends Sha
             values.put(Bookmarks.TITLE, "");
         }
 
         String url = values.getAsString(Bookmarks.URL);
 
         debug("Inserting bookmark in database with URL: " + url);
         final SQLiteDatabase db = getWritableDatabase(uri);
         beginWrite(db);
-        return db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, values);
+        final long insertedId = db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, values);
+
+        if (insertedId == -1) {
+            Log.e(LOGTAG, "Unable to insert bookmark in database with URL: " + url);
+            return insertedId;
+        }
+
+        if (isCallerSync(uri)) {
+            // Sync will handle timestamps on its own, so we don't perform the update here.
+            return insertedId;
+        }
+
+        // Bump parent's lastModified timestamp.
+        final long lastModified = values.getAsLong(Bookmarks.DATE_MODIFIED);
+        final ContentValues parentValues = new ContentValues();
+        parentValues.put(Bookmarks.DATE_MODIFIED, lastModified);
+
+        // The ContentValues should have parentId, or the insertion above would fail because of
+        // database schema foreign key constraint.
+        final long parentId = values.getAsLong(Bookmarks.PARENT);
+        db.update(TABLE_BOOKMARKS,
+                  parentValues,
+                  Bookmarks._ID + " = ?",
+                  new String[] { String.valueOf(parentId) });
+
+        return insertedId;
     }
 
 
     private int updateOrInsertBookmark(Uri uri, ContentValues values, String selection,
             String[] selectionArgs) {
         int updated = updateBookmarks(uri, values, selection, selectionArgs);
         if (updated > 0) {
             return updated;
@@ -1590,17 +1617,63 @@ public class BrowserProvider extends Sha
         final String inClause;
         try {
             inClause = DBUtils.computeSQLInClauseFromLongs(cursor, Bookmarks._ID);
         } finally {
             cursor.close();
         }
 
         beginWrite(db);
-        return db.update(TABLE_BOOKMARKS, values, inClause, null);
+
+        final int updated = db.update(TABLE_BOOKMARKS, values, inClause, null);
+        if (updated == 0) {
+            trace("No update on URI: " + uri);
+            return updated;
+        }
+
+        if (isCallerSync(uri)) {
+            // Sync will handle timestamps on its own, so we don't perform the update here.
+            return updated;
+        }
+
+        final long oldParentId = getOldParentIdIfParentChanged(uri);
+        if (oldParentId == -1) {
+            // Parent isn't changed, don't bump its timestamps.
+            return updated;
+        }
+
+        final long newParentId = values.getAsLong(Bookmarks.PARENT);
+        final long lastModified = values.getAsLong(Bookmarks.DATE_MODIFIED);
+        final ContentValues parentValues = new ContentValues();
+        parentValues.put(Bookmarks.DATE_MODIFIED, lastModified);
+
+        // Bump old/new parent's lastModified timestamps.
+        db.update(TABLE_BOOKMARKS, parentValues,
+                  Bookmarks._ID + " in (?, ?)",
+                  new String[] { String.valueOf(oldParentId), String.valueOf(newParentId) });
+
+        return updated;
+    }
+
+    /**
+     * Use the query key {@link BrowserContract#PARAM_OLD_BOOKMARK_PARENT} to check if parent is changed or not.
+     *
+     * @return old parent id if uri has the key, or -1 otherwise.
+     */
+    private long getOldParentIdIfParentChanged(Uri uri) {
+        final String oldParentId = uri.getQueryParameter(BrowserContract.PARAM_OLD_BOOKMARK_PARENT);
+        if (TextUtils.isEmpty(oldParentId)) {
+            return -1;
+        }
+
+        try {
+            return Long.parseLong(oldParentId);
+        } catch (NumberFormatException ignored) {
+            return -1;
+        }
     }
 
     private long insertHistory(Uri uri, ContentValues values) {
         final long now = System.currentTimeMillis();
         values.put(History.DATE_CREATED, now);
         values.put(History.DATE_MODIFIED, now);
 
         // Generate GUID for new history entry. Don't override specified GUIDs.
@@ -2093,25 +2166,30 @@ public class BrowserProvider extends Sha
         beginWrite(db);
         return db.delete(TABLE_VISITS, selection, selectionArgs);
     }
 
     private int deleteBookmarks(Uri uri, String selection, String[] selectionArgs) {
         debug("Deleting bookmarks for URI: " + uri);
 
         final SQLiteDatabase db = getWritableDatabase(uri);
+        beginWrite(db);
 
         if (isCallerSync(uri)) {
-            beginWrite(db);
             return db.delete(TABLE_BOOKMARKS, selection, selectionArgs);
         }
 
         debug("Marking bookmarks as deleted for URI: " + uri);
 
-        ContentValues values = new ContentValues();
+        // Bump parent's lastModified timestamp before record deleted.
+        final ContentValues parentValues = new ContentValues();
+        parentValues.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
+        updateBookmarkParents(db, parentValues, selection, selectionArgs);
+
+        final ContentValues values = new ContentValues();
         values.put(Bookmarks.IS_DELETED, 1);
         values.put(Bookmarks.POSITION, 0);
         values.putNull(Bookmarks.PARENT);
         values.putNull(Bookmarks.URL);
         values.putNull(Bookmarks.TITLE);
         values.putNull(Bookmarks.DESCRIPTION);
         values.putNull(Bookmarks.KEYWORD);
         values.putNull(Bookmarks.TAGS);
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
@@ -31,17 +31,16 @@ import org.mozilla.gecko.db.BrowserContr
 import org.mozilla.gecko.db.BrowserContract.Bookmarks;
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserContract.ExpirePriority;
 import org.mozilla.gecko.db.BrowserContract.Favicons;
 import org.mozilla.gecko.db.BrowserContract.History;
 import org.mozilla.gecko.db.BrowserContract.SyncColumns;
 import org.mozilla.gecko.db.BrowserContract.Thumbnails;
 import org.mozilla.gecko.db.BrowserContract.TopSites;
-import org.mozilla.gecko.db.BrowserContract.Highlights;
 import org.mozilla.gecko.db.BrowserContract.PageMetadata;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.icons.decoders.FaviconDecoder;
 import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.restrictions.Restrictions;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.util.GeckoJarReader;
@@ -1116,30 +1115,16 @@ public class LocalBrowserDB extends Brow
             final long id = c.getLong(col);
             mFolderIdMap.put(guid, id);
             return id;
         } finally {
             c.close();
         }
     }
 
-    /**
-     * Find parents of records that match the provided criteria, and bump their
-     * modified timestamp.
-     */
-    protected void bumpParents(ContentResolver cr, String param, String value) {
-        ContentValues values = new ContentValues();
-        values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
-
-        String where  = param + " = ?";
-        String[] args = new String[] { value };
-        int updated  = cr.update(mParentsUriWithProfile, values, where, args);
-        debug("Updated " + updated + " rows to new modified time.");
-    }
-
     private void addBookmarkItem(ContentResolver cr, String title, String uri, long folderId) {
         final long now = System.currentTimeMillis();
         ContentValues values = new ContentValues();
         if (title != null) {
             values.put(Bookmarks.TITLE, title);
         }
 
         values.put(Bookmarks.URL, uri);
@@ -1170,42 +1155,32 @@ public class LocalBrowserDB extends Brow
                                           .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
                                           .build();
         cr.update(bookmarksWithInsert,
                   values,
                   Bookmarks.URL + " = ? AND " +
                   Bookmarks.PARENT + " = " + folderId,
                   new String[] { uri });
 
-        // Bump parent modified time using its ID.
-        debug("Bumping parent modified time for addition to: " + folderId);
-        final String where  = Bookmarks._ID + " = ?";
-        final String[] args = new String[] { String.valueOf(folderId) };
-
-        ContentValues bumped = new ContentValues();
-        bumped.put(Bookmarks.DATE_MODIFIED, now);
-
-        final int updated = cr.update(mBookmarksUriWithProfile, bumped, where, args);
-        debug("Updated " + updated + " rows to new modified time.");
+        // BrowserProvider will handle updating parent's lastModified timestamp, nothing else to do.
     }
 
     @Override
     @RobocopTarget
     public boolean addBookmark(ContentResolver cr, String title, String uri) {
         long folderId = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID);
         if (isBookmarkForUrlInFolder(cr, uri, folderId)) {
             // Bookmark added already.
             return false;
         }
 
         // Add a new bookmark.
         addBookmarkItem(cr, title, uri, folderId);
         return true;
     }
-
     private boolean isBookmarkForUrlInFolder(ContentResolver cr, String uri, long folderId) {
         final Cursor c = cr.query(bookmarksUriWithLimit(1),
                                   new String[] { Bookmarks._ID },
                                   Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " = ? AND " + Bookmarks.IS_DELETED + " == 0",
                                   new String[] { uri, String.valueOf(folderId) },
                                   Bookmarks.URL);
 
         if (c == null) {
@@ -1215,50 +1190,88 @@ public class LocalBrowserDB extends Brow
         try {
             return c.getCount() > 0;
         } finally {
             c.close();
         }
     }
 
     @Override
+    public Uri addBookmarkFolder(ContentResolver cr, String title, long parentId) {
+        final ContentValues values = new ContentValues();
+        final long now = System.currentTimeMillis();
+        values.put(Bookmarks.DATE_CREATED, now);
+        values.put(Bookmarks.DATE_MODIFIED, now);
+        values.put(Bookmarks.GUID, Utils.generateGuid());
+        values.put(Bookmarks.PARENT, parentId);
+        values.put(Bookmarks.TITLE, title);
+        values.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER);
+
+        // BrowserProvider will bump parent's lastModified timestamp after successful insertion.
+        return cr.insert(mBookmarksUriWithProfile, values);
+    }
+
+    @Override
     @RobocopTarget
     public void removeBookmarksWithURL(ContentResolver cr, String uri) {
-        Uri contentUri = mBookmarksUriWithProfile;
-
-        // Do this now so that the items still exist!
-        bumpParents(cr, Bookmarks.URL, uri);
+        // BrowserProvider will bump parent's lastModified timestamp after successful deletion.
+        cr.delete(mBookmarksUriWithProfile,
+                  Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " != ? ",
+                  new String[] { uri, String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) });
+    }
 
-        final String[] urlArgs = new String[] { uri, String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) };
-        final String urlEquals = Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " != ? ";
-
-        cr.delete(contentUri, urlEquals, urlArgs);
+    @Override
+    public void removeBookmarkWithId(ContentResolver cr, long id) {
+        // BrowserProvider will bump parent's lastModified timestamp after successful deletion.
+        cr.delete(mBookmarksUriWithProfile,
+                  Bookmarks._ID + " = ? AND " + Bookmarks.PARENT + " != ? ",
+                  new String[] { String.valueOf(id), String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) });
     }
 
     @Override
     public void registerBookmarkObserver(ContentResolver cr, ContentObserver observer) {
         cr.registerContentObserver(mBookmarksUriWithProfile, false, observer);
     }
 
     @Override
     @RobocopTarget
-    public void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword) {
+    public void updateBookmark(ContentResolver cr, long id, String uri, String title, String keyword) {
         ContentValues values = new ContentValues();
         values.put(Bookmarks.TITLE, title);
         values.put(Bookmarks.URL, uri);
         values.put(Bookmarks.KEYWORD, keyword);
         values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
 
         cr.update(mBookmarksUriWithProfile,
                   values,
                   Bookmarks._ID + " = ?",
                   new String[] { String.valueOf(id) });
     }
 
     @Override
+    public void updateBookmark(ContentResolver cr, long id, String uri, String title, String keyword,
+                               long newParentId, long oldParentId) {
+        final ContentValues values = new ContentValues();
+        values.put(Bookmarks.TITLE, title);
+        values.put(Bookmarks.URL, uri);
+        values.put(Bookmarks.KEYWORD, keyword);
+        values.put(Bookmarks.PARENT, newParentId);
+        values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
+
+        final Uri contentUri = mBookmarksUriWithProfile.buildUpon()
+                                .appendQueryParameter(BrowserContract.PARAM_OLD_BOOKMARK_PARENT,
+                                                      String.valueOf(oldParentId))
+                                .build();
+        cr.update(contentUri,
+                  values,
+                  Bookmarks._ID + " = ?",
+                  new String[] { String.valueOf(id) });
+    }
+
+    @Override
     public boolean hasBookmarkWithGuid(ContentResolver cr, String guid) {
         Cursor c = cr.query(bookmarksUriWithLimit(1),
                 new String[] { Bookmarks.GUID },
                 Bookmarks.GUID + " = ?",
                 new String[] { guid },
                 null);
 
         try {
@@ -1780,30 +1793,55 @@ public class LocalBrowserDB extends Brow
 
     @Override
     @RobocopTarget
     public Cursor getBookmarkForUrl(ContentResolver cr, String url) {
         Cursor c = cr.query(bookmarksUriWithLimit(1),
                             new String[] { Bookmarks._ID,
                                            Bookmarks.URL,
                                            Bookmarks.TITLE,
+                                           Bookmarks.TYPE,
+                                           Bookmarks.PARENT,
+                                           Bookmarks.GUID,
                                            Bookmarks.KEYWORD },
                             Bookmarks.URL + " = ?",
                             new String[] { url },
                             null);
 
         if (c != null && c.getCount() == 0) {
             c.close();
             c = null;
         }
 
         return c;
     }
 
     @Override
+    public Cursor getBookmarkById(ContentResolver cr, long id) {
+        final Cursor c = cr.query(mBookmarksUriWithProfile,
+                                  new String[] { Bookmarks._ID,
+                                                 Bookmarks.URL,
+                                                 Bookmarks.TITLE,
+                                                 Bookmarks.TYPE,
+                                                 Bookmarks.PARENT,
+                                                 Bookmarks.GUID,
+                                                 Bookmarks.KEYWORD },
+                                  Bookmarks._ID + " = ?",
+                                  new String[] { String.valueOf(id) },
+                                  null);
+
+        if (c != null && c.getCount() == 0) {
+            c.close();
+            return null;
+        }
+
+        return c;
+    }
+
+    @Override
     public Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl) {
         Cursor c = cr.query(mBookmarksUriWithProfile,
                 new String[] { Bookmarks.GUID, Bookmarks._ID, Bookmarks.URL },
                 Bookmarks.URL + " LIKE '%" + partialUrl + "%'", // TODO: Escaping!
                 null,
                 null);
 
         if (c != null && c.getCount() == 0) {
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/LocalBrowserDBTest.java
@@ -0,0 +1,345 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.db.DelegatingTestContentProvider;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(TestRunner.class)
+public class LocalBrowserDBTest {
+    private static final long INVALID_ID = -1;
+    private final String BOOKMARK_URL = "https://www.mozilla.org";
+    private final String BOOKMARK_TITLE = "mozilla";
+
+    private final String UPDATE_URL = "https://bugzilla.mozilla.org";
+    private final String UPDATE_TITLE = "bugzilla";
+
+    private final String FOLDER_NAME = "folder1";
+
+    private Context context;
+    private BrowserProvider provider;
+    private ContentProviderClient bookmarkClient;
+
+    @Before
+    public void setUp() throws Exception {
+        context = RuntimeEnvironment.application;
+        provider = new BrowserProvider();
+        provider.onCreate();
+        ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
+
+        ShadowContentResolver contentResolver = new ShadowContentResolver();
+        bookmarkClient = contentResolver.acquireContentProviderClient(BrowserContractHelpers.BOOKMARKS_CONTENT_URI);
+    }
+
+    @After
+    public void tearDown() {
+        bookmarkClient.release();
+        provider.shutdown();
+    }
+
+    @Test
+    public void testRemoveBookmarkWithURL() {
+        BrowserDB db = new LocalBrowserDB("default");
+        ContentResolver cr = context.getContentResolver();
+
+        db.addBookmark(cr, BOOKMARK_TITLE, BOOKMARK_URL);
+        Cursor cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNotNull(cursor);
+
+        long parentId = INVALID_ID;
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(title, BOOKMARK_TITLE);
+
+            final String url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.URL));
+            assertEquals(url, BOOKMARK_URL);
+
+            parentId = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.PARENT));
+        } finally {
+            cursor.close();
+        }
+        assertNotEquals(parentId, INVALID_ID);
+
+        final long lastModifiedBeforeRemove = getModifiedDate(parentId);
+
+        // Remove bookmark record
+        db.removeBookmarksWithURL(cr, BOOKMARK_URL);
+
+        // Check the record has been removed
+        cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNull(cursor);
+
+        // Check parent's lastModified timestamp is updated
+        final long lastModifiedAfterRemove = getModifiedDate(parentId);
+        assertTrue(lastModifiedAfterRemove > lastModifiedBeforeRemove);
+    }
+
+    @Test
+    public void testRemoveBookmarkWithId() {
+        BrowserDB db = new LocalBrowserDB("default");
+        ContentResolver cr = context.getContentResolver();
+
+        db.addBookmark(cr, BOOKMARK_TITLE, BOOKMARK_URL);
+        Cursor cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNotNull(cursor);
+
+        long bookmarkId = INVALID_ID;
+        long parentId = INVALID_ID;
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            bookmarkId = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID));
+            parentId = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.PARENT));
+        } finally {
+            cursor.close();
+        }
+        assertNotEquals(bookmarkId, INVALID_ID);
+        assertNotEquals(parentId, INVALID_ID);
+
+        final long lastModifiedBeforeRemove = getModifiedDate(parentId);
+
+        // Remove bookmark record
+        db.removeBookmarkWithId(cr, bookmarkId);
+
+        cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNull(cursor);
+
+        // Check parent's lastModified timestamp is updated
+        final long lastModifiedAfterRemove = getModifiedDate(parentId);
+        assertTrue(lastModifiedAfterRemove > lastModifiedBeforeRemove);
+    }
+
+    @Test
+    public void testUpdateBookmark() throws Exception {
+        BrowserDB db = new LocalBrowserDB("default");
+        ContentResolver cr = context.getContentResolver();
+
+        db.addBookmark(cr, BOOKMARK_TITLE, BOOKMARK_URL);
+        Cursor cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNotNull(cursor);
+
+        long bookmarkId = INVALID_ID;
+        long parentId = getBookmarkIdFromGuid(BrowserContract.Bookmarks.MOBILE_FOLDER_GUID);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String insertedUrl = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.URL));
+            assertEquals(insertedUrl, BOOKMARK_URL);
+
+            final String insertedTitle = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(insertedTitle, BOOKMARK_TITLE);
+
+            bookmarkId = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID));
+        } finally {
+            cursor.close();
+        }
+        assertNotEquals(bookmarkId, INVALID_ID);
+
+        final long parentLastModifiedBeforeUpdate = getModifiedDate(parentId);
+
+        // Update bookmark record
+        db.updateBookmark(cr, bookmarkId, UPDATE_URL, UPDATE_TITLE, "");
+        cursor = db.getBookmarkById(cr, bookmarkId);
+        assertNotNull(cursor);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String updatedUrl = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.URL));
+            assertEquals(updatedUrl, UPDATE_URL);
+
+            final String updatedTitle = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(updatedTitle, UPDATE_TITLE);
+        } finally {
+            cursor.close();
+        }
+
+        // Check parent's lastModified timestamp isn't changed
+        final long parentLastModifiedAfterUpdate = getModifiedDate(parentId);
+        assertTrue(parentLastModifiedAfterUpdate == parentLastModifiedBeforeUpdate);
+    }
+
+    @Test
+    public void testUpdateBookmarkWithParentChange() throws Exception {
+        BrowserDB db = new LocalBrowserDB("default");
+        ContentResolver cr = context.getContentResolver();
+
+        db.addBookmark(cr, BOOKMARK_TITLE, BOOKMARK_URL);
+        Cursor cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNotNull(cursor);
+
+        long bookmarkId = INVALID_ID;
+        long originalParentId = getBookmarkIdFromGuid(BrowserContract.Bookmarks.MOBILE_FOLDER_GUID);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String insertedUrl = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.URL));
+            assertEquals(insertedUrl, BOOKMARK_URL);
+
+            final String insertedTitle = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(insertedTitle, BOOKMARK_TITLE);
+
+            bookmarkId = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID));
+        } finally {
+            cursor.close();
+        }
+        assertNotEquals(bookmarkId, INVALID_ID);
+
+        // Create a folder
+        final Uri newFolderUri = db.addBookmarkFolder(cr, FOLDER_NAME, originalParentId);
+        // Get id from Uri
+        final long newParentId = Long.valueOf(newFolderUri.getLastPathSegment());
+
+        final long originalParentLastModifiedBeforeUpdate = getModifiedDate(originalParentId);
+        final long newParentLastModifiedBeforeUpdate = getModifiedDate(newParentId);
+
+        // Update bookmark record
+        db.updateBookmark(cr, bookmarkId, UPDATE_URL, UPDATE_TITLE, "", newParentId, originalParentId);
+        cursor = db.getBookmarkById(cr, bookmarkId);
+        assertNotNull(cursor);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String updatedUrl = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.URL));
+            assertEquals(updatedUrl, UPDATE_URL);
+
+            final String updatedTitle = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(updatedTitle, UPDATE_TITLE);
+
+            final long parentId = cursor.getLong(cursor.getColumnIndex(BrowserContract.Bookmarks.PARENT));
+            assertEquals(parentId, newParentId);
+        } finally {
+            cursor.close();
+        }
+
+        // Check parent's lastModified timestamp
+        final long originalParentLastModifiedAfterUpdate = getModifiedDate(originalParentId);
+        assertTrue(originalParentLastModifiedAfterUpdate > originalParentLastModifiedBeforeUpdate);
+
+        final long newParentLastModifiedAfterUpdate = getModifiedDate(newParentId);
+        assertTrue(newParentLastModifiedAfterUpdate > newParentLastModifiedBeforeUpdate);
+    }
+
+    @Test
+    public void testAddBookmarkFolder() throws Exception {
+        BrowserDB db = new LocalBrowserDB("default");
+        ContentResolver cr = context.getContentResolver();
+
+        // Add a bookmark folder record
+        final long rootFolderId = getBookmarkIdFromGuid(BrowserContract.Bookmarks.MOBILE_FOLDER_GUID);
+        final long lastModifiedBeforeAdd = getModifiedDate(rootFolderId);
+        final Uri folderUri = db.addBookmarkFolder(cr, FOLDER_NAME, rootFolderId);
+        assertNotNull(folderUri);
+
+        // Get id from Uri
+        long folderId = Long.valueOf(folderUri.getLastPathSegment());
+
+        final Cursor cursor = db.getBookmarkById(cr, folderId);
+        assertNotNull(cursor);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String name = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(name, FOLDER_NAME);
+
+            final long parent = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.PARENT));
+            assertEquals(parent, rootFolderId);
+        } finally {
+            cursor.close();
+        }
+
+        // Check parent's lastModified timestamp is updated
+        final long lastModifiedAfterAdd = getModifiedDate(rootFolderId);
+        assertTrue(lastModifiedAfterAdd > lastModifiedBeforeAdd);
+    }
+
+    @Test
+    public void testAddBookmark() throws Exception {
+        BrowserDB db = new LocalBrowserDB("default");
+        ContentResolver cr = context.getContentResolver();
+
+        final long rootFolderId = getBookmarkIdFromGuid(BrowserContract.Bookmarks.MOBILE_FOLDER_GUID);
+        final long lastModifiedBeforeAdd = getModifiedDate(rootFolderId);
+
+        // Add a bookmark
+        db.addBookmark(cr, BOOKMARK_TITLE, BOOKMARK_URL);
+
+        final Cursor cursor = db.getBookmarkForUrl(cr, BOOKMARK_URL);
+        assertNotNull(cursor);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            final String name = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TITLE));
+            assertEquals(name, BOOKMARK_TITLE);
+
+            final String url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.URL));
+            assertEquals(url, BOOKMARK_URL);
+        } finally {
+            cursor.close();
+        }
+
+        // Check parent's lastModified timestamp is updated
+        final long lastModifiedAfterAdd = getModifiedDate(rootFolderId);
+        assertTrue(lastModifiedAfterAdd > lastModifiedBeforeAdd);
+    }
+
+    private long getBookmarkIdFromGuid(String guid) throws RemoteException {
+        Cursor cursor = bookmarkClient.query(BrowserContract.Bookmarks.CONTENT_URI,
+                                             new String[] { BrowserContract.Bookmarks._ID },
+                                             BrowserContract.Bookmarks.GUID + " = ?",
+                                             new String[] { guid },
+                                             null);
+        assertNotNull(cursor);
+
+        long id = INVALID_ID;
+        try {
+            assertTrue(cursor.moveToFirst());
+            id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID));
+        } finally {
+            cursor.close();
+        }
+        assertNotEquals(id, INVALID_ID);
+        return id;
+    }
+
+    private long getModifiedDate(long id) {
+        Cursor cursor = provider.query(BrowserContract.Bookmarks.CONTENT_URI,
+                                       new String[] { BrowserContract.Bookmarks.DATE_MODIFIED },
+                                       BrowserContract.Bookmarks._ID + " = ?",
+                                       new String[] { String.valueOf(id) },
+                                       null);
+        assertNotNull(cursor);
+
+        long modified = -1;
+        try {
+            assertTrue(cursor.moveToFirst());
+            modified = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.DATE_MODIFIED));
+        } finally {
+            cursor.close();
+        }
+        assertNotEquals(modified, -1);
+        return modified;
+    }
+}