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 331666 851f85403d6e4a419b09ec1334ab439ee0b4e2d3
parent 331665 c3262228d62f8eb0545e28cb2ff3953ed8c28713
child 331667 cf418a927ed8a9081ecbe592e387f67ccd1ad328
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, rnewman
bugs1046709
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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();
 
         }