Bug 1046709 - Part 2: CRUD for Visits - query/insert/delete; tests. r=nalexander,rnewman
authorGrigory Kruglov <gkruglov@mozilla.com>
Sat, 16 Apr 2016 02:19:53 -0700
changeset 317569 851f85403d6e4a419b09ec1334ab439ee0b4e2d3
parent 317568 c3262228d62f8eb0545e28cb2ff3953ed8c28713
child 317570 cf418a927ed8a9081ecbe592e387f67ccd1ad328
push id9480
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 17:12:58 +0000
treeherdermozilla-aurora@0d6a91c76a9e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, rnewman
bugs1046709
milestone48.0a1
Bug 1046709 - Part 2: CRUD for Visits - query/insert/delete; tests. r=nalexander,rnewman Note: need to set package name in robolectric.properties so that Robolectric reads correct resources MozReview-Commit-ID: 6wrh8kzJlXI
mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderVisitsTest.java
mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testBrowserProvider.java
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
@@ -3,26 +3,28 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.db;
 
 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.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.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.DBUtils.UpdateOperation;
 import org.mozilla.gecko.sync.Utils;
 
@@ -58,16 +60,17 @@ public class BrowserProvider extends Sha
 
     // Minimum duration to keep when expiring.
     static final long DEFAULT_EXPIRY_PRESERVE_WINDOW = 1000L * 60L * 60L * 24L * 28L;     // Four weeks.
     // Minimum number of thumbnails to keep around.
     static final int DEFAULT_EXPIRY_THUMBNAIL_COUNT = 15;
 
     static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME;
     static final String TABLE_HISTORY = History.TABLE_NAME;
+    static final String TABLE_VISITS = Visits.TABLE_NAME;
     static final String TABLE_FAVICONS = Favicons.TABLE_NAME;
     static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME;
     static final String TABLE_TABS = Tabs.TABLE_NAME;
     static final String TABLE_URL_ANNOTATIONS = UrlAnnotations.TABLE_NAME;
 
     static final String VIEW_COMBINED = Combined.VIEW_NAME;
     static final String VIEW_BOOKMARKS_WITH_FAVICONS = Bookmarks.VIEW_WITH_FAVICONS;
     static final String VIEW_BOOKMARKS_WITH_ANNOTATIONS = Bookmarks.VIEW_WITH_ANNOTATIONS;
@@ -105,31 +108,35 @@ public class BrowserProvider extends Sha
     // Thumbnail matches
     static final int THUMBNAILS = 800;
     static final int THUMBNAIL_ID = 801;
 
     static final int URL_ANNOTATIONS = 900;
 
     static final int TOPSITES = 1000;
 
+    static final int VISITS = 1100;
+
     static final String DEFAULT_BOOKMARKS_SORT_ORDER = Bookmarks.TYPE
             + " ASC, " + Bookmarks.POSITION + " ASC, " + Bookmarks._ID
             + " ASC";
 
     static final String DEFAULT_HISTORY_SORT_ORDER = History.DATE_LAST_VISITED + " DESC";
+    static final String DEFAULT_VISITS_SORT_ORDER = Visits.DATE_VISITED + " DESC";
 
     static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
 
     static final Map<String, String> BOOKMARKS_PROJECTION_MAP;
     static final Map<String, String> HISTORY_PROJECTION_MAP;
     static final Map<String, String> COMBINED_PROJECTION_MAP;
     static final Map<String, String> SCHEMA_PROJECTION_MAP;
     static final Map<String, String> FAVICONS_PROJECTION_MAP;
     static final Map<String, String> THUMBNAILS_PROJECTION_MAP;
     static final Map<String, String> URL_ANNOTATIONS_PROJECTION_MAP;
+    static final Map<String, String> VISIT_PROJECTION_MAP;
     static final Table[] sTables;
 
     static {
         sTables = new Table[] {
             // See awful shortcut assumption hack in getURLMetadataTable.
             new URLMetadataTable()
         };
         // We will reuse this.
@@ -176,16 +183,27 @@ public class BrowserProvider extends Sha
         map.put(History.VISITS, History.VISITS);
         map.put(History.DATE_LAST_VISITED, History.DATE_LAST_VISITED);
         map.put(History.DATE_CREATED, History.DATE_CREATED);
         map.put(History.DATE_MODIFIED, History.DATE_MODIFIED);
         map.put(History.GUID, History.GUID);
         map.put(History.IS_DELETED, History.IS_DELETED);
         HISTORY_PROJECTION_MAP = Collections.unmodifiableMap(map);
 
+        // Visits
+        URI_MATCHER.addURI(BrowserContract.AUTHORITY, "visits", VISITS);
+
+        map = new HashMap<String, String>();
+        map.put(Visits._ID, Visits._ID);
+        map.put(Visits.HISTORY_GUID, Visits.HISTORY_GUID);
+        map.put(Visits.VISIT_TYPE, Visits.VISIT_TYPE);
+        map.put(Visits.DATE_VISITED, Visits.DATE_VISITED);
+        map.put(Visits.IS_LOCAL, Visits.IS_LOCAL);
+        VISIT_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
         // Favicons
         URI_MATCHER.addURI(BrowserContract.AUTHORITY, "favicons", FAVICONS);
         URI_MATCHER.addURI(BrowserContract.AUTHORITY, "favicons/#", FAVICON_ID);
 
         map = new HashMap<String, String>();
         map.put(Favicons._ID, Favicons._ID);
         map.put(Favicons.URL, Favicons.URL);
         map.put(Favicons.DATA, Favicons.DATA);
@@ -418,21 +436,37 @@ public class BrowserProvider extends Sha
 
                 selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?");
                 selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
                         new String[] { Long.toString(ContentUris.parseId(uri)) });
                 // fall through
             case HISTORY: {
                 trace("Deleting history: " + uri);
                 beginWrite(db);
+                /**
+                 * Deletes from Sync are actual DELETE statements, which will cascade delete relevant visits.
+                 * Fennec's deletes mark records as deleted and wipe out all information (except for GUID).
+                 * Eventually, Fennec will purge history records that were marked as deleted for longer than some
+                 * period of time (e.g. 20 days).
+                 * See {@link SharedBrowserDatabaseProvider#cleanUpSomeDeletedRecords(Uri, String)}.
+                 */
+                if (!isCallerSync(uri)) {
+                    deleteVisitsForHistory(uri, selection, selectionArgs);
+                }
                 deleted = deleteHistory(uri, selection, selectionArgs);
                 deleteUnusedImages(uri);
                 break;
             }
 
+            case VISITS:
+                trace("Deleting visits: " + uri);
+                beginWrite(db);
+                deleted = deleteVisits(uri, selection, selectionArgs);
+                break;
+
             case HISTORY_OLD: {
                 String priority = uri.getQueryParameter(BrowserContract.PARAM_EXPIRE_PRIORITY);
                 long keepAfter = System.currentTimeMillis() - DEFAULT_EXPIRY_PRESERVE_WINDOW;
                 int retainCount = DEFAULT_EXPIRY_RETAIN_COUNT;
 
                 if (BrowserContract.ExpirePriority.AGGRESSIVE.toString().equals(priority)) {
                     keepAfter = 0;
                     retainCount = AGGRESSIVE_EXPIRY_RETAIN_COUNT;
@@ -507,16 +541,22 @@ public class BrowserProvider extends Sha
             }
 
             case HISTORY: {
                 trace("Insert on HISTORY: " + uri);
                 id = insertHistory(uri, values);
                 break;
             }
 
+            case VISITS: {
+                trace("Insert on VISITS: " + uri);
+                id = insertVisit(uri, values);
+                break;
+            }
+
             case FAVICONS: {
                 trace("Insert on FAVICONS: " + uri);
                 id = insertFavicon(uri, values);
                 break;
             }
 
             case THUMBNAILS: {
                 trace("Insert on THUMBNAILS: " + uri);
@@ -612,16 +652,19 @@ public class BrowserProvider extends Sha
                 // fall through
             case HISTORY: {
                 debug("Updating history: " + uri);
                 if (shouldUpdateOrInsert(uri)) {
                     updated = updateOrInsertHistory(uri, values, selection, selectionArgs);
                 } else {
                     updated = updateHistory(uri, values, selection, selectionArgs);
                 }
+                if (shouldIncrementVisits(uri)) {
+                    insertVisitForHistory(uri, values, selection, selectionArgs);
+                }
                 break;
             }
 
             case FAVICONS: {
                 debug("Update on FAVICONS: " + uri);
 
                 String url = values.getAsString(Favicons.URL);
                 String faviconSelection = null;
@@ -1034,16 +1077,26 @@ public class BrowserProvider extends Sha
                 if (hasFaviconsInProjection(projection))
                     qb.setTables(VIEW_HISTORY_WITH_FAVICONS);
                 else
                     qb.setTables(TABLE_HISTORY);
 
                 break;
             }
 
+            case VISITS:
+                debug("Query is on visits: " + uri);
+                qb.setProjectionMap(VISIT_PROJECTION_MAP);
+                qb.setTables(TABLE_VISITS);
+
+                if (TextUtils.isEmpty(sortOrder)) {
+                    sortOrder = DEFAULT_VISITS_SORT_ORDER;
+                }
+                break;
+
             case FAVICON_ID:
                 selection = DBUtils.concatenateWhere(selection, Favicons._ID + " = ?");
                 selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
                         new String[] { Long.toString(ContentUris.parseId(uri)) });
                 // fall through
             case FAVICONS: {
                 debug("Query is on favicons: " + uri);
 
@@ -1359,42 +1412,95 @@ public class BrowserProvider extends Sha
         // Use the simple code path for easy updates.
         if (!shouldIncrementVisits(uri)) {
             trace("Updating history meta data only");
             return db.update(TABLE_HISTORY, values, selection, selectionArgs);
         }
 
         trace("Updating history meta data and incrementing visits");
 
-        // We might be altering the ContentValues, so let's use a copy.
-        final ContentValues valuesForUpdate = new ContentValues(values);
-
         // Update data and increment visits by 1.
-        long incVisits = 1;
-        if (valuesForUpdate.containsKey(History.VISITS)) {
-            // Use a given visit count, if found.
-            Long additional = valuesForUpdate.getAsLong(History.VISITS);
-            if (additional != null) {
-                incVisits = additional;
-            }
-            // Remove the visits from this set of values so we can pass the visits
-            // as an expression.
-            valuesForUpdate.remove(History.VISITS);
-        }
+        final long incVisits = 1;
 
         // Create a separate set of values that will be updated as an expression.
         final ContentValues visits = new ContentValues();
         visits.put(History.VISITS, History.VISITS + " + " + incVisits);
 
-        final ContentValues[] valuesAndVisits = { valuesForUpdate,  visits };
+        final ContentValues[] valuesAndVisits = { values,  visits };
         final UpdateOperation[] ops = { UpdateOperation.ASSIGN, UpdateOperation.EXPRESSION };
 
         return DBUtils.updateArrays(db, TABLE_HISTORY, valuesAndVisits, ops, selection, selectionArgs);
     }
 
+    private long insertVisitForHistory(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        trace("Inserting visit for history on URI: " + uri);
+
+        final SQLiteDatabase db = getReadableDatabase(uri);
+
+        final Cursor cursor = db.query(
+                History.TABLE_NAME, new String[] {History.GUID}, selection, selectionArgs,
+                null, null, null);
+        if (cursor == null) {
+            Log.e(LOGTAG, "Null cursor while trying to insert visit for history URI: " + uri);
+            return 0;
+        }
+        final ContentValues[] visitValues;
+        try {
+            visitValues = new ContentValues[cursor.getCount()];
+
+            if (!cursor.moveToFirst()) {
+                Log.e(LOGTAG, "No history records found while inserting visit(s) for history URI: " + uri);
+                return 0;
+            }
+
+            // Sync works in microseconds, so we store visit timestamps in microseconds as well.
+            // History timestamps are in milliseconds.
+            // This is the conversion point for locally generated visits.
+            final long visitDate;
+            if (values.containsKey(History.DATE_LAST_VISITED)) {
+                visitDate = values.getAsLong(History.DATE_LAST_VISITED) * 1000;
+            } else {
+                visitDate = System.currentTimeMillis() * 1000;
+            }
+
+            final int guidColumn = cursor.getColumnIndexOrThrow(History.GUID);
+            while (!cursor.isAfterLast()) {
+                final ContentValues visit = new ContentValues();
+                visit.put(Visits.HISTORY_GUID, cursor.getString(guidColumn));
+                visit.put(Visits.DATE_VISITED, visitDate);
+                visitValues[cursor.getPosition()] = visit;
+                cursor.moveToNext();
+            }
+        } finally {
+            cursor.close();
+        }
+
+        if (visitValues.length == 1) {
+            return insertVisit(Visits.CONTENT_URI, visitValues[0]);
+        } else {
+            return bulkInsert(Visits.CONTENT_URI, visitValues);
+        }
+    }
+
+    private long insertVisit(Uri uri, ContentValues values) {
+        final SQLiteDatabase db = getWritableDatabase(uri);
+
+        debug("Inserting history in database with URL: " + uri);
+        beginWrite(db);
+
+        // We ignore insert conflicts here to simplify inserting visits records coming in from Sync.
+        // Visits table has a unique index on (history_guid,date), so a conflict might arise when we're
+        // trying to insert history record visits coming in from sync which are already present locally
+        // as a result of previous sync operations.
+        // An alternative to doing this is to filter out already present records when we're doing history inserts
+        // from Sync, which is a costly operation to do en masse.
+        return db.insertWithOnConflict(
+                TABLE_VISITS, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+    }
+
     private void updateFaviconIdsForUrl(SQLiteDatabase db, String pageUrl, Long faviconId) {
         ContentValues updateValues = new ContentValues(1);
         updateValues.put(FaviconColumns.FAVICON_ID, faviconId);
         db.update(TABLE_HISTORY,
                   updateValues,
                   History.URL + " = ?",
                   new String[] { pageUrl });
         db.update(TABLE_BOOKMARKS,
@@ -1620,16 +1726,71 @@ public class BrowserProvider extends Sha
             cleanUpSomeDeletedRecords(uri, TABLE_HISTORY);
         } catch (Exception e) {
             // We don't care.
             Log.e(LOGTAG, "Unable to clean up deleted history records: ", e);
         }
         return updated;
     }
 
+    private int deleteVisitsForHistory(Uri uri, String selection, String[] selectionArgs) {
+        final SQLiteDatabase db = getWritableDatabase(uri);
+
+        final Cursor cursor = db.query(
+                History.TABLE_NAME, new String[] {History.GUID}, selection, selectionArgs,
+                null, null, null);
+        if (cursor == null) {
+            Log.e(LOGTAG, "Null cursor while trying to delete visits for history URI: " + uri);
+            return 0;
+        }
+
+        ArrayList<String> historyGUIDs = new ArrayList<>();
+        try {
+            if (!cursor.moveToFirst()) {
+                trace("No history items for which to remove visits matched for URI: " + uri);
+                return 0;
+            }
+            final int historyColumn = cursor.getColumnIndexOrThrow(History.GUID);
+            while (!cursor.isAfterLast()) {
+                historyGUIDs.add(cursor.getString(historyColumn));
+                cursor.moveToNext();
+            }
+        } finally {
+            cursor.close();
+        }
+
+        // Due to SQLite's maximum variable limitation, we need to chunk our delete statements.
+        // For example, if there were 1200 GUIDs, this will perform 2 delete statements.
+        int deleted = 0;
+        for (int chunk = 0; chunk <= historyGUIDs.size() / DBUtils.SQLITE_MAX_VARIABLE_NUMBER; chunk++) {
+            final int chunkStart = chunk * DBUtils.SQLITE_MAX_VARIABLE_NUMBER;
+            int chunkEnd = (chunk + 1) * DBUtils.SQLITE_MAX_VARIABLE_NUMBER;
+            if (chunkEnd > historyGUIDs.size()) {
+                chunkEnd = historyGUIDs.size();
+            }
+            final List<String> chunkGUIDs = historyGUIDs.subList(chunkStart, chunkEnd);
+            deleted += db.delete(
+                    Visits.TABLE_NAME,
+                    DBUtils.computeSQLInClause(chunkGUIDs.size(), Visits.HISTORY_GUID),
+                    chunkGUIDs.toArray(new String[chunkGUIDs.size()])
+            );
+        }
+
+        return deleted;
+    }
+
+    private int deleteVisits(Uri uri, String selection, String[] selectionArgs) {
+        debug("Deleting visits for URI: " + uri);
+
+        final SQLiteDatabase db = getWritableDatabase(uri);
+
+        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);
 
         if (isCallerSync(uri)) {
             beginWrite(db);
             return db.delete(TABLE_BOOKMARKS, selection, selectionArgs);
--- a/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java
@@ -22,16 +22,18 @@ import android.util.Log;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.Telemetry;
 
 import java.util.Map;
 
 public class DBUtils {
     private static final String LOGTAG = "GeckoDBUtils";
 
+    public static final int SQLITE_MAX_VARIABLE_NUMBER = 999;
+
     public static final String qualifyColumn(String table, String column) {
         return table + "." + column;
     }
 
     // This is available in Android >= 11. Implemented locally to be
     // compatible with older versions.
     public static String concatenateWhere(String a, String b) {
         if (TextUtils.isEmpty(a)) {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/BrowserContractHelpers.java
@@ -28,16 +28,17 @@ public class BrowserContractHelpers exte
         .appendQueryParameter(PARAM_IS_SYNC, "true")
         .build();
   }
 
   public static final Uri BOOKMARKS_CONTENT_URI            = withSyncAndDeletedAndProfile(Bookmarks.CONTENT_URI);
   public static final Uri BOOKMARKS_PARENTS_CONTENT_URI    = withSyncAndDeletedAndProfile(Bookmarks.PARENTS_CONTENT_URI);
   public static final Uri BOOKMARKS_POSITIONS_CONTENT_URI  = withSyncAndDeletedAndProfile(Bookmarks.POSITIONS_CONTENT_URI);
   public static final Uri HISTORY_CONTENT_URI              = withSyncAndDeletedAndProfile(History.CONTENT_URI);
+  public static final Uri VISITS_CONTENT_URI               = withSyncAndDeletedAndProfile(Visits.CONTENT_URI);
   public static final Uri SCHEMA_CONTENT_URI               = withSyncAndDeletedAndProfile(Schema.CONTENT_URI);
   public static final Uri PASSWORDS_CONTENT_URI            = withSyncAndDeletedAndProfile(Passwords.CONTENT_URI);
   public static final Uri DELETED_PASSWORDS_CONTENT_URI    = withSyncAndDeletedAndProfile(DeletedPasswords.CONTENT_URI);
   public static final Uri FORM_HISTORY_CONTENT_URI         = withSyncAndProfile(FormHistory.CONTENT_URI);
   public static final Uri DELETED_FORM_HISTORY_CONTENT_URI = withSyncAndProfile(DeletedFormHistory.CONTENT_URI);
   public static final Uri TABS_CONTENT_URI                 = withSyncAndProfile(Tabs.CONTENT_URI);
   public static final Uri CLIENTS_CONTENT_URI              = withSyncAndProfile(Clients.CONTENT_URI);
   public static final Uri LOGINS_CONTENT_URI               = withSyncAndProfile(Logins.CONTENT_URI);
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTest.java
@@ -0,0 +1,338 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import org.mozilla.gecko.db.BrowserContract.History;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+/**
+ * Testing insertion/deletion of visits as by-product of updating history records through BrowserProvider
+ */
+public class BrowserProviderHistoryVisitsTest extends BrowserProviderHistoryVisitsTestBase {
+    @Test
+    /**
+     * Testing updating history records without affecting visits
+     */
+    public void testUpdateNoVisit() throws Exception {
+        insertHistoryItem("https://www.mozilla.org", "testGUID");
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+
+        ContentValues historyUpdate = new ContentValues();
+        historyUpdate.put(History.TITLE, "Mozilla!");
+        assertEquals(1,
+                historyClient.update(
+                        historyTestUri, historyUpdate, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+                )
+        );
+
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+
+        ContentValues historyToInsert = new ContentValues();
+        historyToInsert.put(History.URL, "https://www.eff.org");
+        assertEquals(1,
+                historyClient.update(
+                        historyTestUri.buildUpon()
+                                .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+                        historyToInsert, null, null
+                )
+        );
+
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+    }
+
+    @Test
+    /**
+     * Testing INCREMENT_VISITS flag for multiple history records at once
+     */
+    public void testUpdateMultipleHistoryIncrementVisit() throws Exception {
+        insertHistoryItem("https://www.mozilla.org", "testGUID");
+        insertHistoryItem("https://www.mozilla.org", "testGUID2");
+
+        // test that visits get inserted when updating existing history records
+        assertEquals(2, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+
+        Cursor cursor = visitsClient.query(
+                visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(2, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+
+        String guid1 = cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID));
+        cursor.moveToNext();
+        String guid2 = cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID));
+        cursor.close();
+
+        assertNotEquals(guid1, guid2);
+
+        assertTrue(guid1.equals("testGUID") || guid1.equals("testGUID2"));
+    }
+
+    @Test
+    /**
+     * Testing INCREMENT_VISITS flag and its interplay with INSERT_IF_NEEDED
+     */
+    public void testUpdateHistoryIncrementVisit() throws Exception {
+        insertHistoryItem("https://www.mozilla.org", "testGUID");
+
+        // test that visit gets inserted when updating an existing histor record
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+            new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+
+        Cursor cursor = visitsClient.query(
+                visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+        assertEquals(
+                "testGUID",
+                cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID))
+        );
+        cursor.close();
+
+        // test that visit gets inserted when updatingOrInserting a new history record
+        ContentValues historyItem = new ContentValues();
+        historyItem.put(History.URL, "https://www.eff.org");
+
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+                        .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+                historyItem, null, null
+        ));
+
+        cursor = historyClient.query(
+                historyTestUri,
+                new String[] {History.GUID}, History.URL + " = ?", new String[] {"https://www.eff.org"}, null
+        );
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+        String insertedGUID = cursor.getString(cursor.getColumnIndex(History.GUID));
+        cursor.close();
+
+        cursor = visitsClient.query(
+                visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(2, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+        assertEquals(insertedGUID,
+                cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID))
+        );
+        cursor.close();
+    }
+
+    @Test
+    /**
+     * Test that for locally generated visits, we store their timestamps in microseconds, and not in
+     * milliseconds like history does.
+     */
+    public void testTimestampConversionOnInsertion() throws Exception {
+        insertHistoryItem("https://www.mozilla.org", "testGUID");
+
+        Long lastVisited = System.currentTimeMillis();
+        ContentValues updatedVisitedTime = new ContentValues();
+        updatedVisitedTime.put(History.DATE_LAST_VISITED, lastVisited);
+
+        // test with last visited date passed in
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                updatedVisitedTime, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, new String[] {BrowserContract.Visits.DATE_VISITED}, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+
+        assertEquals(lastVisited * 1000, cursor.getLong(cursor.getColumnIndex(BrowserContract.Visits.DATE_VISITED)));
+        cursor.close();
+
+        // test without last visited date
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+
+        cursor = visitsClient.query(visitsTestUri, new String[] {BrowserContract.Visits.DATE_VISITED}, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(2, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+
+        // CP should generate time off of current time upon insertion and convert to microseconds.
+        // This also tests correct ordering (DESC on date).
+        assertTrue(lastVisited * 1000 < cursor.getLong(cursor.getColumnIndex(BrowserContract.Visits.DATE_VISITED)));
+        cursor.close();
+    }
+
+    @Test
+    /**
+     * This should perform `DELETE FROM visits WHERE history_guid in IN (?, ?, ?, ..., ?)` sort of statement
+     * SQLite has a variable count limit (999 by default), so we're testing here that our deletion
+     * code does the right thing and chunks deletes to account for this limitation.
+     */
+    public void testDeletingLotsOfHistory() throws Exception {
+        Uri incrementUri = historyTestUri.buildUpon()
+                .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build();
+
+        // insert bunch of history records, and for each insert a visit
+        for (int i = 0; i < 2100; i++) {
+            final String url = "https://www.mozilla" + i + ".org";
+            insertHistoryItem(url, "testGUID" + i);
+            assertEquals(1, historyClient.update(incrementUri, new ContentValues(), History.URL + " = ?", new String[] {url}));
+        }
+
+        // sanity check
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(2100, cursor.getCount());
+        cursor.close();
+
+        // delete all of the history items - this will trigger chunked deletion of visits as well
+        assertEquals(2100,
+                historyClient.delete(historyTestUri, null, null)
+        );
+
+        // check that all visits where deleted
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+    }
+
+    @Test
+    /**
+     * Test visit deletion as by-product of history deletion - both explicit (from outside of Sync),
+     * and implicit (cascaded, from Sync).
+     */
+    public void testDeletingHistory() throws Exception {
+        insertHistoryItem("https://www.mozilla.org", "testGUID");
+        insertHistoryItem("https://www.eff.org", "testGUID2");
+
+        // insert some visits
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.eff.org"}
+        ));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(3, cursor.getCount());
+        cursor.close();
+
+        // test that corresponding visit records are deleted if Sync isn't involved
+        assertEquals(1,
+                historyClient.delete(historyTestUri, History.URL + " = ?", new String[] {"https://www.mozilla.org"})
+        );
+
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        cursor.close();
+
+        // test that corresponding visit records are deleted if Sync is involved
+        // insert some more visits
+        ContentValues moz = new ContentValues();
+        moz.put(History.URL, "https://www.mozilla.org");
+        moz.put(History.GUID, "testGUID3");
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+                        .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+                moz, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+                        .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.eff.org"}
+        ));
+
+        assertEquals(1,
+                historyClient.delete(
+                        historyTestUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "true").build(),
+                        History.URL + " = ?", new String[] {"https://www.eff.org"})
+        );
+
+        cursor = visitsClient.query(visitsTestUri, new String[] {BrowserContract.Visits.HISTORY_GUID}, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+        assertEquals("testGUID3", cursor.getString(cursor.getColumnIndex(BrowserContract.Visits.HISTORY_GUID)));
+        cursor.close();
+    }
+
+    @Test
+    /**
+     * Test that changes to History GUID are cascaded to individual visits.
+     * See UPDATE CASCADED on Visit's HISTORY_GUID foreign key.
+     */
+    public void testHistoryGUIDUpdate() throws Exception {
+        insertHistoryItem("https://www.mozilla.org", "testGUID");
+        insertHistoryItem("https://www.eff.org", "testGUID2");
+
+        // insert some visits
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+        assertEquals(1, historyClient.update(
+                historyTestUri.buildUpon()
+                        .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true").build(),
+                new ContentValues(), History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+
+        // change testGUID -> testGUIDNew
+        ContentValues newGuid = new ContentValues();
+        newGuid.put(History.GUID, "testGUIDNew");
+        assertEquals(1, historyClient.update(
+                historyTestUri, newGuid, History.URL + " = ?", new String[] {"https://www.mozilla.org"}
+        ));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, BrowserContract.Visits.HISTORY_GUID + " = ?", new String[] {"testGUIDNew"}, null);
+        assertNotNull(cursor);
+        assertEquals(2, cursor.getCount());
+        cursor.close();
+    }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
@@ -0,0 +1,55 @@
+/* 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.ContentValues;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
+import org.robolectric.shadows.ShadowContentResolver;
+
+public class BrowserProviderHistoryVisitsTestBase {
+    protected BrowserProvider provider;
+    protected ContentProviderClient historyClient;
+    protected ContentProviderClient visitsClient;
+    protected Uri historyTestUri;
+    protected Uri visitsTestUri;
+
+    @Before
+    public void setUp() throws Exception {
+        provider = new BrowserProvider();
+        provider.onCreate();
+        ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY_URI.toString(), provider);
+
+        final ShadowContentResolver cr = new ShadowContentResolver();
+        historyClient = cr.acquireContentProviderClient(BrowserContractHelpers.HISTORY_CONTENT_URI);
+        visitsClient = cr.acquireContentProviderClient(BrowserContractHelpers.VISITS_CONTENT_URI);
+
+        historyTestUri = testUri(BrowserContract.History.CONTENT_URI);
+        visitsTestUri = testUri(BrowserContract.Visits.CONTENT_URI);
+    }
+
+    @After
+    public void tearDown() {
+        historyClient.release();
+        visitsClient.release();
+        provider.shutdown();
+    }
+
+    protected Uri testUri(Uri baseUri) {
+        return baseUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_TEST, "1").build();
+    }
+
+    protected Uri insertHistoryItem(String url, String guid) throws RemoteException {
+        ContentValues historyItem = new ContentValues();
+        historyItem.put(BrowserContract.History.URL, url);
+        historyItem.put(BrowserContract.History.GUID, guid);
+
+        return historyClient.insert(historyTestUri, historyItem);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderVisitsTest.java
@@ -0,0 +1,301 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.db.BrowserContract.Visits;
+
+@RunWith(TestRunner.class)
+/**
+ * Testing direct interactions with visits through BrowserProvider
+ */
+public class BrowserProviderVisitsTest extends BrowserProviderHistoryVisitsTestBase {
+    @Test
+    /**
+     * Test that default visit parameters are set on insert.
+     */
+    public void testDefaultVisit() throws RemoteException {
+        String url = "https://www.mozilla.org";
+        String guid = "testGuid";
+
+        assertNotNull(insertHistoryItem(url, guid));
+
+        ContentValues visitItem = new ContentValues();
+        Long visitedDate = System.currentTimeMillis();
+        visitItem.put(Visits.HISTORY_GUID, guid);
+        visitItem.put(Visits.DATE_VISITED, visitedDate);
+        Uri insertedVisitUri = visitsClient.insert(visitsTestUri, visitItem);
+        assertNotNull(insertedVisitUri);
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        try {
+            assertTrue(cursor.moveToFirst());
+            String insertedGuid = cursor.getString(cursor.getColumnIndex(Visits.HISTORY_GUID));
+            assertEquals(guid, insertedGuid);
+
+            Long insertedDate = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+            assertEquals(visitedDate, insertedDate);
+
+            Integer insertedType = cursor.getInt(cursor.getColumnIndex(Visits.VISIT_TYPE));
+            assertEquals(insertedType, Integer.valueOf(1));
+
+            Integer insertedIsLocal = cursor.getInt(cursor.getColumnIndex(Visits.IS_LOCAL));
+            assertEquals(insertedIsLocal, Integer.valueOf(1));
+        } finally {
+            cursor.close();
+        }
+    }
+
+    @Test
+    /**
+     * Test that we can't insert visit for non-existing GUID.
+     */
+    public void testMissingHistoryGuid() throws RemoteException {
+        ContentValues visitItem = new ContentValues();
+        visitItem.put(Visits.HISTORY_GUID, "blah");
+        visitItem.put(Visits.DATE_VISITED, System.currentTimeMillis());
+        assertNull(visitsClient.insert(visitsTestUri, visitItem));
+    }
+
+    @Test
+    /**
+     * Test that visit insert uses non-conflict insert.
+     */
+    public void testNonConflictInsert() throws RemoteException {
+        String url = "https://www.mozilla.org";
+        String guid = "testGuid";
+
+        assertNotNull(insertHistoryItem(url, guid));
+
+        ContentValues visitItem = new ContentValues();
+        Long visitedDate = System.currentTimeMillis();
+        visitItem.put(Visits.HISTORY_GUID, guid);
+        visitItem.put(Visits.DATE_VISITED, visitedDate);
+        Uri insertedVisitUri = visitsClient.insert(visitsTestUri, visitItem);
+        assertNotNull(insertedVisitUri);
+
+        Uri insertedVisitUri2 = visitsClient.insert(visitsTestUri, visitItem);
+        assertEquals(insertedVisitUri, insertedVisitUri2);
+    }
+
+    @Test
+    /**
+     * Test that non-default visit parameters won't get overridden.
+     */
+    public void testNonDefaultInsert() throws RemoteException {
+        assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+
+        Integer typeToInsert = 5;
+        Integer isLocalToInsert = 0;
+
+        ContentValues visitItem = new ContentValues();
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        visitItem.put(Visits.DATE_VISITED, System.currentTimeMillis());
+        visitItem.put(Visits.VISIT_TYPE, typeToInsert);
+        visitItem.put(Visits.IS_LOCAL, isLocalToInsert);
+
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        try {
+            assertTrue(cursor.moveToFirst());
+
+            Integer insertedVisitType = cursor.getInt(cursor.getColumnIndex(Visits.VISIT_TYPE));
+            assertEquals(typeToInsert, insertedVisitType);
+
+            Integer insertedIsLocal = cursor.getInt(cursor.getColumnIndex(Visits.IS_LOCAL));
+            assertEquals(isLocalToInsert, insertedIsLocal);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    @Test
+    /**
+     * Test that default sorting order (DATE_VISITED DESC) is set if we don't specify any sorting params
+     */
+    public void testDefaultSortingOrder() throws RemoteException {
+        assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+
+        Long time1 = System.currentTimeMillis();
+        Long time2 = time1 + 100;
+        Long time3 = time1 + 200;
+
+        ContentValues visitItem = new ContentValues();
+        visitItem.put(Visits.DATE_VISITED, time1);
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem.put(Visits.DATE_VISITED, time3);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem.put(Visits.DATE_VISITED, time2);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        try {
+            assertEquals(3, cursor.getCount());
+            assertTrue(cursor.moveToFirst());
+
+            Long timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+            assertEquals(time3, timeInserted);
+
+            cursor.moveToNext();
+
+            timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+            assertEquals(time2, timeInserted);
+
+            cursor.moveToNext();
+
+            timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+            assertEquals(time1, timeInserted);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    @Test
+    /**
+     * Test that if we pass sorting params, they're not overridden
+     */
+    public void testNonDefaultSortingOrder() throws RemoteException {
+        assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+
+        Long time1 = System.currentTimeMillis();
+        Long time2 = time1 + 100;
+        Long time3 = time1 + 200;
+
+        ContentValues visitItem = new ContentValues();
+        visitItem.put(Visits.DATE_VISITED, time1);
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem.put(Visits.DATE_VISITED, time3);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem.put(Visits.DATE_VISITED, time2);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, Visits.DATE_VISITED + " ASC");
+        assertNotNull(cursor);
+        assertEquals(3, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+
+        Long timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+        assertEquals(time1, timeInserted);
+
+        cursor.moveToNext();
+
+        timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+        assertEquals(time2, timeInserted);
+
+        cursor.moveToNext();
+
+        timeInserted = cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED));
+        assertEquals(time3, timeInserted);
+
+        cursor.close();
+    }
+
+    @Test
+    /**
+     * Tests deletion of all visits, and by some selection (GUID, IS_LOCAL)
+     */
+    public void testVisitDeletion() throws RemoteException {
+        assertNotNull(insertHistoryItem("https://www.mozilla.org", "testGuid"));
+        assertNotNull(insertHistoryItem("https://www.eff.org", "testGuid2"));
+
+        Long time1 = System.currentTimeMillis();
+
+        ContentValues visitItem = new ContentValues();
+        visitItem.put(Visits.DATE_VISITED, time1);
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem = new ContentValues();
+        visitItem.put(Visits.DATE_VISITED, time1 + 100);
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        ContentValues visitItem2 = new ContentValues();
+        visitItem2.put(Visits.DATE_VISITED, time1);
+        visitItem2.put(Visits.HISTORY_GUID, "testGuid2");
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem2));
+
+        Cursor cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(3, cursor.getCount());
+        cursor.close();
+
+        assertEquals(3, visitsClient.delete(visitsTestUri, null, null));
+
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(0, cursor.getCount());
+        cursor.close();
+
+        // test selective deletion - by IS_LOCAL
+        visitItem = new ContentValues();
+        visitItem.put(Visits.DATE_VISITED, time1);
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        visitItem.put(Visits.IS_LOCAL, 0);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem = new ContentValues();
+        visitItem.put(Visits.DATE_VISITED, time1 + 100);
+        visitItem.put(Visits.HISTORY_GUID, "testGuid");
+        visitItem.put(Visits.IS_LOCAL, 1);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem));
+
+        visitItem2 = new ContentValues();
+        visitItem2.put(Visits.DATE_VISITED, time1);
+        visitItem2.put(Visits.HISTORY_GUID, "testGuid2");
+        visitItem2.put(Visits.IS_LOCAL, 0);
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem2));
+
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(3, cursor.getCount());
+        cursor.close();
+
+        assertEquals(2,
+                visitsClient.delete(visitsTestUri, Visits.IS_LOCAL + " = ?", new String[]{"0"}));
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+        assertEquals(time1 + 100, cursor.getLong(cursor.getColumnIndex(Visits.DATE_VISITED)));
+        assertEquals("testGuid", cursor.getString(cursor.getColumnIndex(Visits.HISTORY_GUID)));
+        assertEquals(1, cursor.getInt(cursor.getColumnIndex(Visits.IS_LOCAL)));
+        cursor.close();
+
+        // test selective deletion - by HISTORY_GUID
+        assertNotNull(visitsClient.insert(visitsTestUri, visitItem2));
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(2, cursor.getCount());
+        cursor.close();
+
+        assertEquals(1,
+                visitsClient.delete(visitsTestUri, Visits.HISTORY_GUID + " = ?", new String[]{"testGuid"}));
+        cursor = visitsClient.query(visitsTestUri, null, null, null, null);
+        assertNotNull(cursor);
+        assertEquals(1, cursor.getCount());
+        assertTrue(cursor.moveToFirst());
+        assertEquals("testGuid2", cursor.getString(cursor.getColumnIndex(Visits.HISTORY_GUID)));
+        cursor.close();
+    }
+}
\ No newline at end of file
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testBrowserProvider.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testBrowserProvider.java
@@ -1319,34 +1319,37 @@ public class testBrowserProvider extends
             dateCreated = c.getLong(c.getColumnIndex(BrowserContract.History.DATE_CREATED));
             dateModified = c.getLong(c.getColumnIndex(BrowserContract.History.DATE_MODIFIED));
 
             mAsserter.is(c.getLong(c.getColumnIndex(BrowserContract.History.VISITS)), 10L,
                          "Inserted history entry has correct specified number of visits");
             mAsserter.is(c.getString(c.getColumnIndex(BrowserContract.History.TITLE)), TEST_TITLE,
                          "Inserted history entry has correct specified title");
 
-            // Update the history entry, specifying additional visit count
+            // Update the history entry, specifying additional visit count.
+            // The expectation is that the value is ignored, and count is bumped by 1 only.
+            // At the same time, a visit is inserted into the visits table.
+            // See junit4 tests in BrowserProviderHistoryVisitsTest.
             values = new ContentValues();
             values.put(BrowserContract.History.VISITS, 10);
 
             updated = mProvider.update(updateOrInsertHistoryUri, values,
                                        BrowserContract.History._ID + " = ?",
                                        new String[] { String.valueOf(id) });
             mAsserter.is((updated == 1), true, "Inserted history entry was updated");
             c.close();
 
             c = getHistoryEntryById(id);
             mAsserter.is(c.moveToFirst(), true, "Updated history entry found");
 
             mAsserter.is(c.getString(c.getColumnIndex(BrowserContract.History.TITLE)), TEST_TITLE,
                          "Updated history entry has correct unchanged title");
             mAsserter.is(c.getString(c.getColumnIndex(BrowserContract.History.URL)), TEST_URL_2,
                          "Updated history entry has correct unchanged URL");
-            mAsserter.is(c.getLong(c.getColumnIndex(BrowserContract.History.VISITS)), 20L,
+            mAsserter.is(c.getLong(c.getColumnIndex(BrowserContract.History.VISITS)), 11L,
                          "Updated history entry has correct number of visits");
             mAsserter.is(c.getLong(c.getColumnIndex(BrowserContract.History.DATE_CREATED)), dateCreated,
                          "Updated history entry has same creation date");
             mAsserter.isnot(c.getLong(c.getColumnIndex(BrowserContract.History.DATE_MODIFIED)), dateModified,
                             "Updated history entry has new modification date");
             c.close();
 
         }