Bug 1130461 - Part 1: change schema for reading list. r=margaret, a=readinglist
authorRichard Newman <rnewman@mozilla.com>
Mon, 23 Feb 2015 10:35:11 -0800
changeset 262174 2a568085f0429a3a33f5587ffdbf64e4e16f6fb6
parent 262173 bcae3d28587355de5e426a9b5adcf045d4227ca3
child 262175 dfe8f55e047a8bfc89b604881f355e930732c778
push id1963
push userpaolo.mozmail@amadzone.org
push dateSun, 10 May 2015 10:26:20 +0000
reviewersmargaret, readinglist
bugs1130461
milestone38.0a2
Bug 1130461 - Part 1: change schema for reading list. r=margaret, a=readinglist This patch does several things, all in one commit because of a schema update: * Uses _id instead of guid when referring to reading list items, allowing the guid column to be null. * Reworks schema upgrading. * Completely revises the reading list schema itself. * Fixes the tests. * Cleans up how we do deletion: if an item hasn't yet been synced, it's simply deleted immediately. We can do this because the server allocates GUIDs. * Adds columns to manage sync-related metadata.
mobile/android/base/ReadingListHelper.java
mobile/android/base/db/BrowserContract.java
mobile/android/base/db/BrowserDatabaseHelper.java
mobile/android/base/db/LocalReadingListAccessor.java
mobile/android/base/db/ReadingListAccessor.java
mobile/android/base/db/ReadingListProvider.java
mobile/android/base/db/SharedBrowserDatabaseProvider.java
mobile/android/base/db/StubBrowserDB.java
mobile/android/base/tests/testReadingListProvider.java
--- a/mobile/android/base/ReadingListHelper.java
+++ b/mobile/android/base/ReadingListHelper.java
@@ -134,31 +134,60 @@ public final class ReadingListHelper imp
     /**
      * Creates reading list item content values from JS message.
      */
     private ContentValues getContentValues(NativeJSObject message) {
         final ContentValues values = new ContentValues();
         if (message.has("id")) {
             values.put(ReadingListItems._ID, message.getInt("id"));
         }
+
+        // url is actually required...
+        String url = null;
         if (message.has("url")) {
-            values.put(ReadingListItems.URL, message.getString("url"));
+            url = message.getString("url");
+            values.put(ReadingListItems.URL, url);
         }
+
+        String title = null;
         if (message.has("title")) {
-            values.put(ReadingListItems.TITLE, message.getString("title"));
+            title = message.getString("title");
+            values.put(ReadingListItems.TITLE, title);
         }
-        if (message.has("length")) {
-            values.put(ReadingListItems.LENGTH, message.getInt("length"));
+
+        // TODO: message actually has "length", but that's no use for us. See Bug 1127451.
+        if (message.has("word_count")) {
+            values.put(ReadingListItems.WORD_COUNT, message.getInt("word_count"));
         }
+
         if (message.has("excerpt")) {
             values.put(ReadingListItems.EXCERPT, message.getString("excerpt"));
         }
+
         if (message.has("status")) {
-            values.put(ReadingListItems.CONTENT_STATUS, message.getInt("status"));
+            final int status = message.getInt("status");
+            values.put(ReadingListItems.CONTENT_STATUS, status);
+            if (status == ReadingListItems.STATUS_FETCHED_ARTICLE) {
+                if (message.has("resolved_title")) {
+                    values.put(ReadingListItems.RESOLVED_TITLE, message.getString("resolved_title"));
+                } else {
+                    if (title != null) {
+                        values.put(ReadingListItems.RESOLVED_TITLE, title);
+                    }
+                }
+                if (message.has("resolved_url")) {
+                    values.put(ReadingListItems.RESOLVED_URL, message.getString("resolved_url"));
+                } else {
+                    if (url != null) {
+                        values.put(ReadingListItems.RESOLVED_URL, url);
+                    }
+                }
+            }
         }
+
         return values;
     }
 
     /**
      * Gecko (ReaderMode) requests the page favicon to append to the
      * document head for display.
      */
     private void handleReaderModeFaviconRequest(final EventCallback callback, final String url) {
--- a/mobile/android/base/db/BrowserContract.java
+++ b/mobile/android/base/db/BrowserContract.java
@@ -337,38 +337,87 @@ public class BrowserContract {
         public static final String CREATED = "created";
         public static final String FILTER = "filter";
 
         public static final String[] DEFAULT_PROJECTION =
             new String[] { _ID, DATASET_ID, URL, TITLE, DESCRIPTION, IMAGE_URL, FILTER };
     }
 
     @RobocopTarget
-    public static final class ReadingListItems implements CommonColumns, URLColumns, SyncColumns {
+    public static final class ReadingListItems implements CommonColumns, URLColumns {
+        public static final String EXCERPT = "excerpt";
+        public static final String CLIENT_LAST_MODIFIED = "client_last_modified";
+        public static final String GUID = "guid";
+        public static final String SERVER_LAST_MODIFIED = "last_modified";
+        public static final String SERVER_STORED_ON = "stored_on";
+        public static final String ADDED_ON = "added_on";
+        public static final String MARKED_READ_ON = "marked_read_on";
+        public static final String IS_DELETED = "is_deleted";
+        public static final String IS_ARCHIVED = "is_archived";
+        public static final String IS_UNREAD = "is_unread";
+        public static final String IS_ARTICLE = "is_article";
+        public static final String IS_FAVORITE = "is_favorite";
+        public static final String RESOLVED_URL = "resolved_url";
+        public static final String RESOLVED_TITLE = "resolved_title";
+        public static final String ADDED_BY = "added_by";
+        public static final String MARKED_READ_BY = "marked_read_by";
+        public static final String WORD_COUNT = "word_count";
+        public static final String READ_POSITION = "read_position";
+        public static final String CONTENT_STATUS = "content_status";
+
+        public static final String SYNC_STATUS = "sync_status";
+        public static final String SYNC_CHANGE_FLAGS = "sync_change_flags";
+
         private ReadingListItems() {}
         public static final Uri CONTENT_URI = Uri.withAppendedPath(READING_LIST_AUTHORITY_URI, "items");
 
         public static final String CONTENT_TYPE = "vnd.android.cursor.dir/readinglistitem";
         public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/readinglistitem";
 
-        public static final String EXCERPT = "excerpt";
-        public static final String READ = "read";
-        public static final String LENGTH = "length";
-
-        public static final String CONTENT_STATUS = "content_status";
-
         // CONTENT_STATUS represents the result of an attempt to fetch content for the reading list item.
         public static final int STATUS_UNFETCHED = 0;
         public static final int STATUS_FETCH_FAILED_TEMPORARY = 1;
         public static final int STATUS_FETCH_FAILED_PERMANENT = 2;
         public static final int STATUS_FETCH_FAILED_UNSUPPORTED_FORMAT = 3;
         public static final int STATUS_FETCHED_ARTICLE = 4;
 
-        public static final String DEFAULT_SORT_ORDER = DATE_MODIFIED + " DESC";
-        public static final String[] DEFAULT_PROJECTION = new String[] { _ID, URL, TITLE, EXCERPT, LENGTH };
+        // See https://github.com/mozilla-services/readinglist/wiki/Client-phases for how this is expected to work.
+        //
+        // If an item is SYNCED, it doesn't need to be uploaded.
+        //
+        // If its status is NEW, the entire record should be uploaded.
+        //
+        // If DELETED, the record should be deleted. A record can only move into this state from SYNCED; NEW records
+        // are deleted immediately.
+        //
+
+        public static final int SYNC_STATUS_SYNCED = 0;
+        public static final int SYNC_STATUS_NEW = 1;                      // Upload everything.
+        public static final int SYNC_STATUS_DELETED = 2;                  // Delete the record from the server.
+        public static final int SYNC_STATUS_MODIFIED = 3;                 // Consult SYNC_CHANGE_FLAGS.
+
+        // SYNC_CHANGE_FLAG represents the sets of fields that need to be uploaded.
+        // If its status is only UNREAD_CHANGED (and maybe FAVORITE_CHANGED?), then it can easily be uploaded
+        // in a fire-and-forget manner. This change can never conflict.
+        //
+        // If its status is RESOLVED, then one or more of the content-oriented fields has changed, and a full
+        // upload of those fields should occur. These can result in conflicts.
+        //
+        // Note that these are flags; they should be considered together when deciding on a course of action.
+        //
+        // These flags are meaningless for records in any state other than SYNCED. They can be safely altered in
+        // other states (to avoid having to query to pre-fill a ContentValues), but should be ignored.
+        public static final int SYNC_CHANGE_NONE = 0;
+        public static final int SYNC_CHANGE_UNREAD_CHANGED   = 1 << 0;    // => marked_read_{on,by}, is_unread
+        public static final int SYNC_CHANGE_FAVORITE_CHANGED = 1 << 1;    // => is_favorite
+        public static final int SYNC_CHANGE_RESOLVED = 1 << 2;            // => is_article, resolved_{url,title}, excerpt, word_count
+
+
+        public static final String DEFAULT_SORT_ORDER = CLIENT_LAST_MODIFIED + " DESC";
+        public static final String[] DEFAULT_PROJECTION = new String[] { _ID, URL, TITLE, EXCERPT, WORD_COUNT };
 
         // Minimum fields required to create a reading list item.
         public static final String[] REQUIRED_FIELDS = { Bookmarks.URL, Bookmarks.TITLE };
 
         public static final String TABLE_NAME = "reading_list";
     }
 
     @RobocopTarget
--- a/mobile/android/base/db/BrowserDatabaseHelper.java
+++ b/mobile/android/base/db/BrowserDatabaseHelper.java
@@ -27,19 +27,19 @@ import android.database.sqlite.SQLiteDat
 import android.database.sqlite.SQLiteException;
 import android.database.sqlite.SQLiteOpenHelper;
 import android.net.Uri;
 import android.os.Build;
 import android.util.Log;
 
 
 final class BrowserDatabaseHelper extends SQLiteOpenHelper {
+    private static final String LOGTAG = "GeckoBrowserDBHelper";
 
-    private static final String LOGTAG = "GeckoBrowserDBHelper";
-    public static final int DATABASE_VERSION = 22;
+    public static final int DATABASE_VERSION = 23;
     public static final String DATABASE_NAME = "browser.db";
 
     final protected Context mContext;
 
     static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME;
     static final String TABLE_HISTORY = History.TABLE_NAME;
     static final String TABLE_FAVICONS = Favicons.TABLE_NAME;
     static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME;
@@ -290,55 +290,83 @@ final class BrowserDatabaseHelper extend
         createBookmarksWithFaviconsView(db);
         createHistoryWithFaviconsView(db);
         createCombinedViewOn19(db);
 
         createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID,
             R.string.bookmarks_folder_places, 0);
 
         createOrUpdateAllSpecialFolders(db);
-        createReadingListTable(db);
         createSearchHistoryTable(db);
+        createReadingListTable(db, TABLE_READING_LIST);
+        didCreateCurrentReadingListTable = true;      // Mostly correct, in the absence of transactions.
+        createReadingListIndices(db, TABLE_READING_LIST);
     }
 
     private void createSearchHistoryTable(SQLiteDatabase db) {
         debug("Creating " + SearchHistory.TABLE_NAME + " table");
 
         db.execSQL("CREATE TABLE " + SearchHistory.TABLE_NAME + "(" +
                     SearchHistory._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
                     SearchHistory.QUERY + " TEXT UNIQUE NOT NULL, " +
                     SearchHistory.DATE_LAST_VISITED + " INTEGER, " +
                     SearchHistory.VISITS + " INTEGER ) ");
 
         db.execSQL("CREATE INDEX idx_search_history_last_visited ON " +
                 SearchHistory.TABLE_NAME + "(" + SearchHistory.DATE_LAST_VISITED + ")");
     }
 
-    private void createReadingListTable(SQLiteDatabase db) {
+    private boolean didCreateCurrentReadingListTable = false;
+    private void createReadingListTable(final SQLiteDatabase db, final String tableName) {
         debug("Creating " + TABLE_READING_LIST + " table");
 
-        db.execSQL("CREATE TABLE " + TABLE_READING_LIST + "(" +
-                    ReadingListItems._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
-                    ReadingListItems.URL + " TEXT NOT NULL, " +
-                    ReadingListItems.TITLE + " TEXT, " +
-                    ReadingListItems.EXCERPT + " TEXT, " +
-                    ReadingListItems.READ + " TINYINT DEFAULT 0, " +
-                    ReadingListItems.IS_DELETED + " TINYINT DEFAULT 0, " +
-                    ReadingListItems.GUID + " TEXT UNIQUE NOT NULL, " +
-                    ReadingListItems.DATE_MODIFIED + " INTEGER NOT NULL, " +
-                    ReadingListItems.DATE_CREATED + " INTEGER NOT NULL, " +
-                    ReadingListItems.LENGTH + " INTEGER DEFAULT 0, " +
-                    ReadingListItems.CONTENT_STATUS + " TINYINT DEFAULT " + ReadingListItems.STATUS_UNFETCHED + "); ");
+        db.execSQL("CREATE TABLE " + tableName + "(" +
+                   ReadingListItems._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                   ReadingListItems.GUID + " TEXT UNIQUE, " +                          // Server-assigned.
+
+                   ReadingListItems.CONTENT_STATUS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.STATUS_UNFETCHED + ", " +
+                   ReadingListItems.SYNC_STATUS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.SYNC_STATUS_NEW + ", " +
+                   ReadingListItems.SYNC_CHANGE_FLAGS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.SYNC_CHANGE_NONE + ", " +
+
+                   ReadingListItems.CLIENT_LAST_MODIFIED + " INTEGER NOT NULL, " +     // Client time.
+                   ReadingListItems.SERVER_LAST_MODIFIED + " INTEGER, " +              // Server-assigned.
+
+                   // Server-assigned.
+                   ReadingListItems.SERVER_STORED_ON + " INTEGER, " +
+                   ReadingListItems.ADDED_ON + " INTEGER, " +                   // Client time. Shouldn't be null, but not enforced. Formerly DATE_CREATED.
+                   ReadingListItems.MARKED_READ_ON + " INTEGER, " +
 
-        db.execSQL("CREATE INDEX reading_list_url ON " + TABLE_READING_LIST + "("
-                + ReadingListItems.URL + ")");
-        db.execSQL("CREATE UNIQUE INDEX reading_list_guid ON " + TABLE_READING_LIST + "("
-                + ReadingListItems.GUID + ")");
-        db.execSQL("CREATE INDEX reading_list_content_status ON " + TABLE_READING_LIST + "("
-                + ReadingListItems.CONTENT_STATUS + ")");
+                   // These boolean flags represent the server 'status', 'unread', 'is_article', and 'favorite' fields.
+                   ReadingListItems.IS_DELETED + " TINYINT NOT NULL DEFAULT 0, " +
+                   ReadingListItems.IS_ARCHIVED + " TINYINT NOT NULL DEFAULT 0, " +
+                   ReadingListItems.IS_UNREAD + " TINYINT NOT NULL DEFAULT 1, " +
+                   ReadingListItems.IS_ARTICLE + " TINYINT NOT NULL DEFAULT 0, " +
+                   ReadingListItems.IS_FAVORITE + " TINYINT NOT NULL DEFAULT 0, " +
+
+                   ReadingListItems.URL + " TEXT NOT NULL, " +
+                   ReadingListItems.TITLE + " TEXT, " +
+                   ReadingListItems.RESOLVED_URL + " TEXT, " +
+                   ReadingListItems.RESOLVED_TITLE + " TEXT, " +
+
+                   ReadingListItems.EXCERPT + " TEXT, " +
+
+                   ReadingListItems.ADDED_BY + " TEXT, " +
+                   ReadingListItems.MARKED_READ_BY + " TEXT, " +
+
+                   ReadingListItems.WORD_COUNT + " INTEGER DEFAULT 0, " +
+                   ReadingListItems.READ_POSITION + " INTEGER DEFAULT 0 " +
+                "); ");
+    }
+
+    private void createReadingListIndices(final SQLiteDatabase db, final String tableName) {
+        // No need to create an index on GUID; it's a UNIQUE column.
+        db.execSQL("CREATE INDEX reading_list_url ON " + tableName + "("
+                           + ReadingListItems.URL + ")");
+        db.execSQL("CREATE INDEX reading_list_content_status ON " + tableName + "("
+                           + ReadingListItems.CONTENT_STATUS + ")");
     }
 
     private void createOrUpdateAllSpecialFolders(SQLiteDatabase db) {
         createOrUpdateSpecialFolder(db, Bookmarks.MOBILE_FOLDER_GUID,
             R.string.bookmarks_folder_mobile, 0);
         createOrUpdateSpecialFolder(db, Bookmarks.TOOLBAR_FOLDER_GUID,
             R.string.bookmarks_folder_toolbar, 1);
         createOrUpdateSpecialFolder(db, Bookmarks.MENU_FOLDER_GUID,
@@ -682,73 +710,83 @@ final class BrowserDatabaseHelper extend
             db.execSQL("DELETE FROM " + TABLE_THUMBNAILS +
                     " WHERE length(" + Thumbnails.DATA + ") = 0");
         } catch (SQLException e) {
             Log.e(LOGTAG, "Error purging invalid favicons or thumbnails", e);
         }
     }
 
     /*
-     * Moves reading list items from 'bookmarks' table to 'reading_list' table. Uses the
-     * same item GUID.
+     * Moves reading list items from 'bookmarks' table to 'reading_list' table.
      */
     private void upgradeDatabaseFrom17to18(SQLiteDatabase db) {
         debug("Moving reading list items from 'bookmarks' table to 'reading_list' table");
 
         final String selection = Bookmarks.PARENT + " = ? AND " + Bookmarks.IS_DELETED + " = ? ";
         final String[] selectionArgs = { String.valueOf(Bookmarks.FIXED_READING_LIST_ID), "0" };
         final String[] projection = {   Bookmarks._ID,
                                         Bookmarks.GUID,
                                         Bookmarks.URL,
                                         Bookmarks.DATE_MODIFIED,
                                         Bookmarks.DATE_CREATED,
                                         Bookmarks.TITLE };
-        Cursor cursor = null;
+
         try {
-            // Start transaction
             db.beginTransaction();
 
-            // Create 'reading_list' table
-            createReadingListTable(db);
-
-            // Get all the reading list items from bookmarks table
-            cursor = db.query(TABLE_BOOKMARKS, projection, selection, selectionArgs,
-                         null, null, null);
+            // Create 'reading_list' table.
+            createReadingListTable(db, TABLE_READING_LIST);
 
-            // Insert reading list items into reading_list table
-            while (cursor.moveToNext()) {
-                debug(DatabaseUtils.dumpCurrentRowToString(cursor));
-                ContentValues values = new ContentValues();
-                DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.URL, values, ReadingListItems.URL);
-                DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.GUID, values, ReadingListItems.GUID);
-                DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.TITLE, values, ReadingListItems.TITLE);
-                DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_CREATED, values, ReadingListItems.DATE_CREATED);
-                DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_MODIFIED, values, ReadingListItems.DATE_MODIFIED);
+            // Get all the reading list items from bookmarks table.
+            final Cursor cursor = db.query(TABLE_BOOKMARKS, projection, selection, selectionArgs, null, null, null);
 
-                db.insertOrThrow(TABLE_READING_LIST, null, values);
+            if (cursor == null) {
+                // This should never happen.
+                db.setTransactionSuccessful();
+                return;
             }
 
-            // Delete reading list items from bookmarks table
+            try {
+                // Insert reading list items into reading_list table.
+                while (cursor.moveToNext()) {
+                    debug(DatabaseUtils.dumpCurrentRowToString(cursor));
+                    final ContentValues values = new ContentValues();
+
+                    // We don't preserve bookmark GUIDs.
+                    DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.URL, values, ReadingListItems.URL);
+                    DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.TITLE, values, ReadingListItems.TITLE);
+                    DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_CREATED, values, ReadingListItems.ADDED_ON);
+                    DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_MODIFIED, values, ReadingListItems.CLIENT_LAST_MODIFIED);
+
+                    db.insertOrThrow(TABLE_READING_LIST, null, values);
+                }
+            } finally {
+                cursor.close();
+            }
+
+            // Delete reading list items from bookmarks table.
             db.delete(TABLE_BOOKMARKS,
                       Bookmarks.PARENT + " = ? ",
                       new String[] { String.valueOf(Bookmarks.FIXED_READING_LIST_ID) });
 
-            // Delete reading list special folder
+            // Delete reading list special folder.
             db.delete(TABLE_BOOKMARKS,
                       Bookmarks._ID + " = ? ",
                       new String[] { String.valueOf(Bookmarks.FIXED_READING_LIST_ID) });
-            // Done
+
+            // Create indices.
+            createReadingListIndices(db, TABLE_READING_LIST);
+
+            // Done.
             db.setTransactionSuccessful();
+            didCreateCurrentReadingListTable = true;
 
         } catch (SQLException e) {
             Log.e(LOGTAG, "Error migrating reading list items", e);
         } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
             db.endTransaction();
         }
     }
 
     private void upgradeDatabaseFrom18to19(SQLiteDatabase db) {
         // Redefine the "combined" view...
         createV19CombinedView(db);
 
@@ -761,32 +799,91 @@ final class BrowserDatabaseHelper extend
                    " WHERE " + Bookmarks.TYPE + " IS NULL");
     }
 
     private void upgradeDatabaseFrom19to20(SQLiteDatabase db) {
         createSearchHistoryTable(db);
     }
 
     private void upgradeDatabaseFrom21to22(SQLiteDatabase db) {
+        if (didCreateCurrentReadingListTable) {
+            debug("No need to add CONTENT_STATUS to reading list; we just created with the current schema.");
+            return;
+        }
+
         debug("Adding CONTENT_STATUS column to reading list table.");
 
         try {
             db.execSQL("ALTER TABLE " + TABLE_READING_LIST +
                        " ADD COLUMN " + ReadingListItems.CONTENT_STATUS +
                        " TINYINT DEFAULT " + ReadingListItems.STATUS_UNFETCHED);
 
             db.execSQL("CREATE INDEX reading_list_content_status ON " + TABLE_READING_LIST + "("
                     + ReadingListItems.CONTENT_STATUS + ")");
         } catch (SQLiteException e) {
             // We're betting that an error here means that the table already has the column,
             // so we're failing due to the duplicate column name.
             Log.e(LOGTAG, "Error upgrading database from 21 to 22", e);
         }
     }
 
+    private void upgradeDatabaseFrom22to23(SQLiteDatabase db) {
+        if (didCreateCurrentReadingListTable) {
+            debug("No need to rev reading list schema; we just created with the current schema.");
+            return;
+        }
+
+        debug("Rewriting reading list table.");
+        createReadingListTable(db, "tmp_rl");
+
+        // Remove indexes. We don't need them now, and we'll be throwing away the table.
+        db.execSQL("DROP INDEX IF EXISTS reading_list_url");
+        db.execSQL("DROP INDEX IF EXISTS reading_list_guid");
+        db.execSQL("DROP INDEX IF EXISTS reading_list_content_status");
+
+        final String thisDevice = ReadingListProvider.PLACEHOLDER_THIS_DEVICE;
+        db.execSQL("INSERT INTO tmp_rl (" +
+                   // Here are the columns we can preserve.
+                   ReadingListItems._ID + ", " +
+                   ReadingListItems.URL + ", " +
+                   ReadingListItems.TITLE + ", " +
+                   ReadingListItems.RESOLVED_TITLE + ", " +       // = TITLE (if CONTENT_STATUS = STATUS_FETCHED_ARTICLE)
+                   ReadingListItems.RESOLVED_URL + ", " +         // = URL (if CONTENT_STATUS = STATUS_FETCHED_ARTICLE)
+                   ReadingListItems.EXCERPT + ", " +
+                   ReadingListItems.IS_UNREAD + ", " +            // = !READ
+                   ReadingListItems.IS_DELETED + ", " +           // = 0
+                   ReadingListItems.GUID + ", " +                 // = NULL
+                   ReadingListItems.CLIENT_LAST_MODIFIED + ", " + // = DATE_MODIFIED
+                   ReadingListItems.ADDED_ON + ", " +             // = DATE_CREATED
+                   ReadingListItems.CONTENT_STATUS + ", " +
+                   ReadingListItems.MARKED_READ_BY + ", " +       // if READ + ", = this device
+                   ReadingListItems.ADDED_BY +                    // = this device
+                   ") " +
+                   "SELECT " +
+                   "_id, url, title, " +
+                   "CASE content_status WHEN " + ReadingListItems.STATUS_FETCHED_ARTICLE + " THEN title ELSE NULL END, " +   // RESOLVED_TITLE.
+                   "CASE content_status WHEN " + ReadingListItems.STATUS_FETCHED_ARTICLE + " THEN url ELSE NULL END, " +     // RESOLVED_URL.
+                   "excerpt, " +
+                   "CASE read WHEN 1 THEN 0 ELSE 1 END, " +            // IS_UNREAD.
+                   "0, " +                                             // IS_DELETED.
+                   "NULL, modified, created, content_status, " +
+                   "CASE read WHEN 1 THEN ? ELSE NULL END, " +         // MARKED_READ_BY.
+                   "?" +                                               // ADDED_BY.
+                   " FROM " + TABLE_READING_LIST +
+                   " WHERE deleted = 0",
+                   new String[] {thisDevice, thisDevice});
+
+        // Now switch these tables over and recreate the indices.
+        db.execSQL("DROP TABLE " + TABLE_READING_LIST);
+        db.execSQL("ALTER TABLE tmp_rl RENAME TO " + TABLE_READING_LIST);
+
+        createReadingListIndices(db, TABLE_READING_LIST);
+    }
+
+
     private void createV19CombinedView(SQLiteDatabase db) {
         db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED);
         db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS);
 
         createCombinedViewOn19(db);
     }
 
     @Override
@@ -842,21 +939,21 @@ final class BrowserDatabaseHelper extend
                     upgradeDatabaseFrom18to19(db);
                     break;
 
                 case 20:
                     upgradeDatabaseFrom19to20(db);
                     break;
 
                 case 22:
-                    if (oldVersion <= 17) {
-                         // We just created the right table in 17to18. Do nothing here.
-                    } else {
-                         upgradeDatabaseFrom21to22(db);
-                    }
+                    upgradeDatabaseFrom21to22(db);
+                    break;
+
+                case 23:
+                    upgradeDatabaseFrom22to23(db);
                     break;
             }
         }
 
         for (Table table : BrowserProvider.sTables) {
             table.onUpgrade(db, oldVersion, newVersion);
         }
 
--- a/mobile/android/base/db/LocalReadingListAccessor.java
+++ b/mobile/android/base/db/LocalReadingListAccessor.java
@@ -1,128 +1,180 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * 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 android.content.ContentResolver;
+import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
 import android.net.Uri;
 import android.util.Log;
+import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
+import org.mozilla.gecko.mozglue.RobocopTarget;
 
+
+@RobocopTarget
 public class LocalReadingListAccessor implements ReadingListAccessor {
     private static final String LOG_TAG = "GeckoReadingListAcc";
 
+    private static final String NOT_DELETED = ReadingListItems.IS_DELETED + " = 0";
+    private static final String NEITHER_DELETED_NOR_ARCHIVED = ReadingListItems.IS_ARCHIVED + " = 0 AND " + ReadingListItems.IS_DELETED + " = 0";
+    private static final String ITEMS_TO_FETCH = ReadingListItems.CONTENT_STATUS + " = " + ReadingListItems.STATUS_UNFETCHED + " AND " + NEITHER_DELETED_NOR_ARCHIVED;
+    private static final String SORT_ORDER_RECENT_FIRST = "COALESCE(" + ReadingListItems.SERVER_STORED_ON + ", " + ReadingListItems.ADDED_ON + ") DESC";
+
     private final Uri mReadingListUriWithProfile;
 
     public LocalReadingListAccessor(final String profile) {
-        mReadingListUriWithProfile = DBUtils.appendProfile(profile, BrowserContract.ReadingListItems.CONTENT_URI);
+        mReadingListUriWithProfile = DBUtils.appendProfile(profile, ReadingListItems.CONTENT_URI);
     }
 
+    // Return a count of non-deleted items.
     @Override
     public int getCount(ContentResolver cr) {
-        final String[] columns = new String[]{BrowserContract.ReadingListItems._ID};
-        final Cursor cursor = cr.query(mReadingListUriWithProfile, columns, null, null, null);
-        int count = 0;
+        final String[] columns = new String[]{ReadingListItems._ID};
+        final Cursor cursor = cr.query(mReadingListUriWithProfile, columns, NOT_DELETED, null, null);
 
         try {
-            count = cursor.getCount();
+            return cursor.getCount();
         } finally {
             cursor.close();
         }
-
-        Log.d(LOG_TAG, "Got count " + count + " for reading list.");
-        return count;
     }
 
     @Override
     public Cursor getReadingList(ContentResolver cr) {
+        // Return non-deleted, non-archived items, ordered by either server stored data or local added date,
+        // descending.
+        // This isn't ideal -- it depends on upload order! -- but the alternative is that a client with a
+        // very skewed clock will force its items to the front or end of the list on other devices.
         return cr.query(mReadingListUriWithProfile,
-                        BrowserContract.ReadingListItems.DEFAULT_PROJECTION,
+                        ReadingListItems.DEFAULT_PROJECTION,
+                        NEITHER_DELETED_NOR_ARCHIVED,
                         null,
-                        null,
-                        null);
+                        SORT_ORDER_RECENT_FIRST);
     }
 
     @Override
     public Cursor getReadingListUnfetched(ContentResolver cr) {
+        // Return unfetched, non-deleted, non-archived items, sorted by date added, newest first.
+        // This allows us to fetch the top of the list first.
         return cr.query(mReadingListUriWithProfile,
-                        new String[] { BrowserContract.ReadingListItems._ID, BrowserContract.ReadingListItems.URL },
-                        BrowserContract.ReadingListItems.CONTENT_STATUS + " = " + BrowserContract.ReadingListItems.STATUS_UNFETCHED,
+                        new String[] { ReadingListItems._ID, ReadingListItems.URL },
+                        ITEMS_TO_FETCH,
                         null,
-                        null);
+                        SORT_ORDER_RECENT_FIRST);
     }
 
     @Override
     public boolean isReadingListItem(ContentResolver cr, String uri) {
         final Cursor c = cr.query(mReadingListUriWithProfile,
-                                  new String[] { BrowserContract.ReadingListItems._ID },
-                                  BrowserContract.ReadingListItems.URL + " = ? ",
-                                  new String[] { uri },
+                                  new String[] { ReadingListItems._ID },
+                                  ReadingListItems.URL + " = ? OR " + ReadingListItems.RESOLVED_URL + " = ?",
+                                  new String[] { uri, uri },
                                   null);
 
         if (c == null) {
             Log.e(LOG_TAG, "Null cursor in isReadingListItem");
             return false;
         }
 
         try {
-            return c.getCount() > 0;
+            return c.moveToNext();
         } finally {
             c.close();
         }
     }
 
 
     @Override
-    public void addReadingListItem(ContentResolver cr, ContentValues values) {
+    public long addReadingListItem(ContentResolver cr, ContentValues values) {
         // Check that required fields are present.
-        for (String field: BrowserContract.ReadingListItems.REQUIRED_FIELDS) {
+        for (String field: ReadingListItems.REQUIRED_FIELDS) {
             if (!values.containsKey(field)) {
                 throw new IllegalArgumentException("Missing required field for reading list item: " + field);
             }
         }
 
-        // Clear delete flag if necessary
-        values.put(BrowserContract.ReadingListItems.IS_DELETED, 0);
+        // We're adding locally, so we can specify these.
+        values.put(ReadingListItems.ADDED_ON, System.currentTimeMillis());
+        values.put(ReadingListItems.ADDED_BY, ReadingListProvider.PLACEHOLDER_THIS_DEVICE);
+
+        // We never un-delete (and we can't; we wipe as we go).
+        // Re-add if necessary and allow the server to resolve conflicts.
+        return ContentUris.parseId(cr.insert(mReadingListUriWithProfile, values));
+    }
 
-        // Restore deleted record if possible
-        final Uri insertUri = mReadingListUriWithProfile
-                .buildUpon()
-                .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
-                .build();
+    @Override
+    public long addBasicReadingListItem(ContentResolver cr, String url, String title) {
+        if (url == null) {
+            throw new IllegalArgumentException("URL must not be null.");
+        }
+        final ContentValues values = new ContentValues();
+        values.put(ReadingListItems.URL, url);
+        if (title != null) {
+            values.put(ReadingListItems.TITLE, title);
+        } else {
+            values.putNull(ReadingListItems.TITLE);
+        }
 
-        final int updated = cr.update(insertUri,
-                values,
-                BrowserContract.ReadingListItems.URL + " = ? ",
-                new String[] { values.getAsString(BrowserContract.ReadingListItems.URL) });
-
-        Log.d(LOG_TAG, "Updated " + updated + " rows to new modified time.");
+        return addReadingListItem(cr, values);
     }
 
     @Override
     public void updateReadingListItem(ContentResolver cr, ContentValues values) {
-        if (!values.containsKey(BrowserContract.ReadingListItems._ID)) {
+        if (!values.containsKey(ReadingListItems._ID)) {
             throw new IllegalArgumentException("Cannot update reading list item without an ID");
         }
 
         final int updated = cr.update(mReadingListUriWithProfile,
                                       values,
-                                      BrowserContract.ReadingListItems._ID + " = ? ",
-                                      new String[] { values.getAsString(BrowserContract.ReadingListItems._ID) });
+                                      ReadingListItems._ID + " = ? ",
+                                      new String[] { values.getAsString(ReadingListItems._ID) });
 
         Log.d(LOG_TAG, "Updated " + updated + " reading list rows.");
     }
 
     @Override
     public void removeReadingListItemWithURL(ContentResolver cr, String uri) {
-        cr.delete(mReadingListUriWithProfile, BrowserContract.ReadingListItems.URL + " = ? ", new String[]{uri});
+        cr.delete(mReadingListUriWithProfile,
+                  ReadingListItems.URL + " = ? OR " + ReadingListItems.RESOLVED_URL + " = ?",
+                  new String[]{ uri, uri });
+    }
+
+    @Override
+    public void deleteItem(ContentResolver cr, long itemID) {
+        cr.delete(ContentUris.appendId(mReadingListUriWithProfile.buildUpon(), itemID).build(),
+                  null, null);
     }
 
     @Override
     public void registerContentObserver(Context context, ContentObserver observer) {
         context.getContentResolver().registerContentObserver(mReadingListUriWithProfile, false, observer);
     }
+
+    @Override
+    public void markAsRead(ContentResolver cr, long itemID) {
+        final ContentValues values = new ContentValues();
+        values.put(ReadingListItems.MARKED_READ_BY, ReadingListProvider.PLACEHOLDER_THIS_DEVICE);
+        values.put(ReadingListItems.MARKED_READ_ON, System.currentTimeMillis());
+        values.put(ReadingListItems.IS_UNREAD, 0);
+
+        // The ContentProvider will take care of updating the sync metadata.
+        cr.update(mReadingListUriWithProfile, values, ReadingListItems._ID + " = " + itemID, null);
+    }
+
+    @Override
+    public void updateContent(ContentResolver cr, long itemID, String resolvedTitle, String resolvedURL, String excerpt) {
+        final ContentValues values = new ContentValues();
+        values.put(ReadingListItems.CONTENT_STATUS, ReadingListItems.STATUS_FETCHED_ARTICLE);
+        values.put(ReadingListItems.RESOLVED_URL, resolvedURL);
+        values.put(ReadingListItems.RESOLVED_TITLE, resolvedTitle);
+        values.put(ReadingListItems.EXCERPT, excerpt);
+
+        // The ContentProvider will take care of updating the sync metadata.
+        cr.update(mReadingListUriWithProfile, values, ReadingListItems._ID + " = " + itemID, null);
+    }
 }
--- a/mobile/android/base/db/ReadingListAccessor.java
+++ b/mobile/android/base/db/ReadingListAccessor.java
@@ -4,29 +4,39 @@
 
 package org.mozilla.gecko.db;
 
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
+import org.mozilla.gecko.mozglue.RobocopTarget;
 
+@RobocopTarget
 public interface ReadingListAccessor {
     /**
+     * Returns non-deleted, non-archived items.
+     * Fennec doesn't currently offer a way to display archived items.
+     *
      * Can return <code>null</code>.
      */
     Cursor getReadingList(ContentResolver cr);
 
     int getCount(ContentResolver cr);
 
     Cursor getReadingListUnfetched(ContentResolver cr);
 
     boolean isReadingListItem(ContentResolver cr, String uri);
 
-    void addReadingListItem(ContentResolver cr, ContentValues values);
+    long addReadingListItem(ContentResolver cr, ContentValues values);
+    long addBasicReadingListItem(ContentResolver cr, String url, String title);
 
     void updateReadingListItem(ContentResolver cr, ContentValues values);
 
     void removeReadingListItemWithURL(ContentResolver cr, String uri);
 
     void registerContentObserver(Context context, ContentObserver observer);
+
+    void markAsRead(ContentResolver cr, long itemID);
+    void updateContent(ContentResolver cr, long itemID, String resolvedTitle, String resolvedURL, String excerpt);
+    void deleteItem(ContentResolver cr, long itemID);
 }
--- a/mobile/android/base/db/ReadingListProvider.java
+++ b/mobile/android/base/db/ReadingListProvider.java
@@ -1,134 +1,272 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * 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 org.mozilla.gecko.db.BrowserContract.ReadingListItems;
-import org.mozilla.gecko.sync.Utils;
-
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.UriMatcher;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteQueryBuilder;
 import android.net.Uri;
 import android.text.TextUtils;
 
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.*;
+
 public class ReadingListProvider extends SharedBrowserDatabaseProvider {
-    static final String TABLE_READING_LIST = ReadingListItems.TABLE_NAME;
+    static final String TABLE_READING_LIST = TABLE_NAME;
 
     static final int ITEMS = 101;
     static final int ITEMS_ID = 102;
     static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
 
+    public static final String PLACEHOLDER_THIS_DEVICE = "$local";
+
     static {
         URI_MATCHER.addURI(BrowserContract.READING_LIST_AUTHORITY, "items", ITEMS);
         URI_MATCHER.addURI(BrowserContract.READING_LIST_AUTHORITY, "items/#", ITEMS_ID);
     }
 
     /**
      * Updates items that match the selection criteria. If no such items is found
      * one is inserted with the attributes passed in. Returns 0 if no item updated.
      *
+     * Only use this method for callers, not internally -- it futzes with the provided
+     * values to set syncing flags.
+     *
      * @return Number of items updated or inserted
      */
     public int updateOrInsertItem(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
-        int updated = updateItems(uri, values, selection, selectionArgs);
+        if (!values.containsKey(CLIENT_LAST_MODIFIED)) {
+            values.put(CLIENT_LAST_MODIFIED, System.currentTimeMillis());
+        }
+
+        if (isCallerSync(uri)) {
+            int updated = updateItemsWithFlags(uri, values, null, selection, selectionArgs);
+            if (updated > 0) {
+                return updated;
+            }
+            return insertItem(uri, values) != -1 ? 1 : 0;
+        }
+
+        // Assume updated.
+        final ContentValues flags = processChangeValues(values);
+
+        int updated = updateItemsWithFlags(uri, values, flags, selection, selectionArgs);
         if (updated <= 0) {
+            // Must be an insertion. Let's make sure we're NEW and discard any update flags.
+            values.put(SYNC_STATUS, SYNC_STATUS_NEW);
+            values.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_NONE);
             updated = insertItem(uri, values) != -1 ? 1 : 0;
         }
         return updated;
     }
 
     /**
+     * This method does two things:
+     * * Based on the values provided, it computes and returns an incremental status change
+     *   that can be applied to the database to track changes for syncing. This should be
+     *   applied with {@link org.mozilla.gecko.db.DBUtils.UpdateOperation#BITWISE_OR}.
+     * * It mutates the provided values to mark absolute field changes.
+     *
+     * @return null if no values were provided, or no change needs to be recorded.
+     */
+    private ContentValues processChangeValues(ContentValues values) {
+        if (values == null || values.size() == 0) {
+            return null;
+        }
+
+        // Otherwise, it must have been modified.
+        values.put(SYNC_STATUS, SYNC_STATUS_MODIFIED);
+
+        final ContentValues out = new ContentValues();
+        int flag = 0;
+        if (values.containsKey(MARKED_READ_BY) ||
+            values.containsKey(MARKED_READ_ON) ||
+            values.containsKey(IS_UNREAD)) {
+            flag |= SYNC_CHANGE_UNREAD_CHANGED;
+        }
+
+        if (values.containsKey(IS_FAVORITE)) {
+            flag |= SYNC_CHANGE_FAVORITE_CHANGED;
+        }
+
+        if (values.containsKey(RESOLVED_URL) ||
+            values.containsKey(RESOLVED_TITLE) ||
+            values.containsKey(EXCERPT)) {
+            flag |= SYNC_CHANGE_RESOLVED;
+        }
+
+        if (flag == 0) {
+            return null;
+        }
+
+        out.put(SYNC_CHANGE_FLAGS, flag);
+        return out;
+    }
+
+    /**
      * Updates items that match the selection criteria.
      *
      * @return Number of items updated or inserted
      */
-    public int updateItems(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+    public int updateItemsWithFlags(Uri uri, ContentValues values, ContentValues flags, String selection, String[] selectionArgs) {
         trace("Updating ReadingListItems on URI: " + uri);
         final SQLiteDatabase db = getWritableDatabase(uri);
-        if (!values.containsKey(ReadingListItems.DATE_MODIFIED)) {
-            values.put(ReadingListItems.DATE_MODIFIED, System.currentTimeMillis());
+        if (!values.containsKey(CLIENT_LAST_MODIFIED)) {
+            values.put(CLIENT_LAST_MODIFIED, System.currentTimeMillis());
         }
-        return db.update(TABLE_READING_LIST, values, selection, selectionArgs);
+
+        if (flags == null) {
+            // Dunno what we're doing with the DB that isn't changing anything we care about, but hey.
+            return db.update(TABLE_READING_LIST, values, selection, selectionArgs);
+        }
+
+        // Otherwise, we need to do smart updating to change flags.
+        final ContentValues[] valuesAndFlags = {values, flags};
+        final DBUtils.UpdateOperation[] ops = {DBUtils.UpdateOperation.ASSIGN, DBUtils.UpdateOperation.BITWISE_OR};
+
+        return DBUtils.updateArrays(db, TABLE_READING_LIST, valuesAndFlags, ops, selection, selectionArgs);
     }
 
     /**
-     * Inserts a new item into the DB. DATE_CREATED, DATE_MODIFIED
-     * and GUID fields are generated if they are not specified.
+     * Inserts a new item into the DB. CLIENT_LAST_MODIFIED is generated if it is not specified.
+     *
+     * Non-Sync callers will have ADDED_ON and ADDED_BY set appropriately if they are missing;
+     * the assumption is that this is a new item added on this device.
      *
      * @return ID of the newly inserted item
      */
-    long insertItem(Uri uri, ContentValues values) {
-        long now = System.currentTimeMillis();
-        if (!values.containsKey(ReadingListItems.DATE_CREATED)) {
-            values.put(ReadingListItems.DATE_CREATED, now);
+    private long insertItem(Uri uri, ContentValues values) {
+        if (!values.containsKey(CLIENT_LAST_MODIFIED)) {
+            values.put(CLIENT_LAST_MODIFIED, System.currentTimeMillis());
+        }
+
+        // We trust the syncing code to specify SYNC_STATUS_SYNCED.
+        if (!isCallerSync(uri)) {
+            values.put(SYNC_STATUS, SYNC_STATUS_NEW);
+            if (!values.containsKey(ADDED_ON)) {
+                values.put(ADDED_ON, System.currentTimeMillis());
+            }
+            if (!values.containsKey(ADDED_BY)) {
+                values.put(ADDED_BY, PLACEHOLDER_THIS_DEVICE);
+            }
         }
 
-        if (!values.containsKey(ReadingListItems.DATE_MODIFIED)) {
-            values.put(ReadingListItems.DATE_MODIFIED, now);
-        }
+        final String url = values.getAsString(URL);
+        debug("Inserting item in database with URL: " + url);
+        return getWritableDatabase(uri).insertOrThrow(TABLE_READING_LIST, null, values);
+    }
+
+    private static final ContentValues DELETED_VALUES;
+    static {
+        final ContentValues values = new ContentValues();
+        values.put(IS_DELETED, 1);
 
-        if (!values.containsKey(ReadingListItems.GUID)) {
-            values.put(ReadingListItems.GUID, Utils.generateGuid());
-        }
+        values.put(URL, "");             // Non-null column.
+        values.putNull(RESOLVED_URL);
+        values.putNull(RESOLVED_TITLE);
+        values.putNull(TITLE);
+        values.putNull(EXCERPT);
+        values.putNull(ADDED_BY);
+        values.putNull(MARKED_READ_BY);
 
-        String url = values.getAsString(ReadingListItems.URL);
-        debug("Inserting item in database with URL: " + url);
-        return getWritableDatabase(uri)
-                .insertOrThrow(TABLE_READING_LIST, null, values);
+        // Mark it as deleted for sync purposes.
+        values.put(SYNC_STATUS, SYNC_STATUS_DELETED);
+        values.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_NONE);
+        DELETED_VALUES = values;
     }
 
     /**
      * Deletes items. Item is marked as 'deleted' so that sync can
      * detect the change.
      *
+     * It's the caller's responsibility to handle both original and resolved URLs.
      * @return Number of deleted items
      */
-    int deleteItems(Uri uri, String selection, String[] selectionArgs) {
+    int deleteItems(final Uri uri, String selection, String[] selectionArgs) {
         debug("Deleting item entry for URI: " + uri);
         final SQLiteDatabase db = getWritableDatabase(uri);
 
+        // TODO: also ensure that we delete affected items from the disk cache. Bug 1133158.
         if (isCallerSync(uri)) {
+            debug("Directly deleting from reading list.");
             return db.delete(TABLE_READING_LIST, selection, selectionArgs);
         }
 
-        debug("Marking item entry as deleted for URI: " + uri);
-        ContentValues values = new ContentValues();
-        values.put(ReadingListItems.IS_DELETED, 1);
+        // If we don't have a GUID for this item, then it hasn't made it
+        // to the server. Just delete it.
+        // If we do have a GUID, blank the row and mark it as deleted.
+        int total = 0;
+        final String whereNullGUID = DBUtils.concatenateWhere(selection, GUID + " IS NULL");
+        final String whereNotNullGUID = DBUtils.concatenateWhere(selection, GUID + " IS NOT NULL");
+
+        total += db.delete(TABLE_READING_LIST, whereNullGUID, selectionArgs);
+        total += updateItemsWithFlags(uri, DELETED_VALUES, null, whereNotNullGUID, selectionArgs);
+
+        return total;
+    }
+
+    int deleteItemByID(final Uri uri, long id) {
+        debug("Deleting item entry for ID: " + id);
+        final SQLiteDatabase db = getWritableDatabase(uri);
 
-        cleanUpSomeDeletedRecords(uri, TABLE_READING_LIST);
-        return updateItems(uri, values, selection, selectionArgs);
+        // TODO: also ensure that we delete affected items from the disk cache. Bug 1133158.
+        if (isCallerSync(uri)) {
+            debug("Directly deleting from reading list.");
+            final String selection = _ID + " = " + id;
+            return db.delete(TABLE_READING_LIST, selection, null);
+        }
+
+        // If we don't have a GUID for this item, then it hasn't made it
+        // to the server. Just delete it.
+        final String whereNullGUID = _ID + " = " + id + " AND " + GUID + " IS NULL";
+        final int raw = db.delete(TABLE_READING_LIST, whereNullGUID, null);
+        if (raw > 0) {
+            // _ID is unique, so this should only ever be 1, but it definitely means
+            // we don't need to try the second part.
+            return raw;
+        }
+
+        // If we do have a GUID, blank the row and mark it as deleted.
+        final String whereNotNullGUID = _ID + " = " + id + " AND " + GUID + " IS NOT NULL";
+        final ContentValues values = new ContentValues(DELETED_VALUES);
+        values.put(CLIENT_LAST_MODIFIED, System.currentTimeMillis());
+        return updateItemsWithFlags(uri, values, null, whereNotNullGUID, null);
     }
 
     @Override
     @SuppressWarnings("fallthrough")
-    public int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+    public int updateInTransaction(final Uri uri, ContentValues values, String selection, String[] selectionArgs) {
         trace("Calling update in transaction on URI: " + uri);
 
         int updated = 0;
         int match = URI_MATCHER.match(uri);
 
         switch (match) {
             case ITEMS_ID:
                 debug("Update on ITEMS_ID: " + uri);
                 selection = DBUtils.concatenateWhere(selection, TABLE_READING_LIST + "._id = ?");
                 selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
                         new String[] { Long.toString(ContentUris.parseId(uri)) });
 
             case ITEMS: {
                 debug("Updating ITEMS: " + uri);
-                updated = shouldUpdateOrInsert(uri) ?
-                          updateOrInsertItem(uri, values, selection, selectionArgs) :
-                          updateItems(uri, values, selection, selectionArgs);
+                if (shouldUpdateOrInsert(uri)) {
+                    // updateOrInsertItem handles change flags for us.
+                    updated = updateOrInsertItem(uri, values, selection, selectionArgs);
+                } else {
+                    // Don't use flags if we're inserting from sync.
+                    ContentValues flags = isCallerSync(uri) ? null : processChangeValues(values);
+                    updated = updateItemsWithFlags(uri, values, flags, selection, selectionArgs);
+                }
                 break;
             }
 
             default:
                 throw new UnsupportedOperationException("Unknown update URI " + uri);
         }
 
         debug("Updated " + updated + " rows for URI: " + uri);
@@ -136,25 +274,28 @@ public class ReadingListProvider extends
     }
 
 
     @Override
     @SuppressWarnings("fallthrough")
     public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
         trace("Calling delete in transaction on URI: " + uri);
 
+        // This will never clean up any items that we're about to delete, so we
+        // might as well run it first!
+        cleanUpSomeDeletedRecords(uri, TABLE_READING_LIST);
+
         int numDeleted = 0;
         int match = URI_MATCHER.match(uri);
 
         switch (match) {
             case ITEMS_ID:
                 debug("Deleting on ITEMS_ID: " + uri);
-                selection = DBUtils.concatenateWhere(selection, TABLE_READING_LIST + "._id = ?");
-                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
-                        new String[] { Long.toString(ContentUris.parseId(uri)) });
+                numDeleted = deleteItemByID(uri, ContentUris.parseId(uri));
+                break;
 
             case ITEMS:
                 debug("Deleting ITEMS: " + uri);
                 numDeleted = deleteItems(uri, selection, selectionArgs);
                 break;
 
             default:
                 throw new UnsupportedOperationException("Unknown update URI " + uri);
@@ -195,32 +336,33 @@ public class ReadingListProvider extends
         SQLiteDatabase db = getReadableDatabase(uri);
         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
         String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
 
         final int match = URI_MATCHER.match(uri);
         switch (match) {
             case ITEMS_ID:
                 trace("Query on ITEMS_ID: " + uri);
-                selection = DBUtils.concatenateWhere(selection, ReadingListItems._ID + " = ?");
+                selection = DBUtils.concatenateWhere(selection, _ID + " = ?");
                 selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
                         new String[] { Long.toString(ContentUris.parseId(uri)) });
 
             case ITEMS:
                 trace("Query on ITEMS: " + uri);
-                if (!shouldShowDeleted(uri))
-                    selection = DBUtils.concatenateWhere(ReadingListItems.IS_DELETED + " = 0", selection);
+                if (!shouldShowDeleted(uri)) {
+                    selection = DBUtils.concatenateWhere(IS_DELETED + " = 0", selection);
+                }
                 break;
 
             default:
                 throw new UnsupportedOperationException("Unknown query URI " + uri);
         }
 
         if (TextUtils.isEmpty(sortOrder)) {
-            sortOrder = ReadingListItems.DEFAULT_SORT_ORDER;
+            sortOrder = DEFAULT_SORT_ORDER;
         }
 
         trace("Running built query.");
         qb.setTables(TABLE_READING_LIST);
         Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit);
         cursor.setNotificationUri(getContext().getContentResolver(), uri);
 
         return cursor;
@@ -229,19 +371,27 @@ public class ReadingListProvider extends
     @Override
     public String getType(Uri uri) {
         trace("Getting URI type: " + uri);
 
         final int match = URI_MATCHER.match(uri);
         switch (match) {
             case ITEMS:
                 trace("URI is ITEMS: " + uri);
-                return ReadingListItems.CONTENT_TYPE;
+                return CONTENT_TYPE;
 
             case ITEMS_ID:
                 trace("URI is ITEMS_ID: " + uri);
-                return ReadingListItems.CONTENT_ITEM_TYPE;
+                return CONTENT_ITEM_TYPE;
         }
 
         debug("URI has unrecognized type: " + uri);
         return null;
     }
+
+    @Override
+    protected String getDeletedItemSelection(long earlierThan) {
+        if (earlierThan == -1L) {
+            return IS_DELETED + " = 1";
+        }
+        return IS_DELETED + " = 1 AND " + CLIENT_LAST_MODIFIED + " <= " + earlierThan;
+    }
 }
--- a/mobile/android/base/db/SharedBrowserDatabaseProvider.java
+++ b/mobile/android/base/db/SharedBrowserDatabaseProvider.java
@@ -97,26 +97,32 @@ public abstract class SharedBrowserDatab
         final long MAX_AGE_OF_DELETED_RECORDS = 86400000 * 20;
 
         // Number of records marked as deleted to be removed
         final long DELETED_RECORDS_PURGE_LIMIT = 5;
 
         // Android SQLite doesn't have LIMIT on DELETE. Instead, query for the
         // IDs of matching rows, then delete them in one go.
         final long now = System.currentTimeMillis();
-        final String selection = SyncColumns.IS_DELETED + " = 1 AND " +
-                                 SyncColumns.DATE_MODIFIED + " <= " +
-                                 (now - MAX_AGE_OF_DELETED_RECORDS);
+        final String selection = getDeletedItemSelection(now - MAX_AGE_OF_DELETED_RECORDS);
 
         final String profile = fromUri.getQueryParameter(BrowserContract.PARAM_PROFILE);
         final SQLiteDatabase db = getWritableDatabaseForProfile(profile, isTest(fromUri));
         final String limit = Long.toString(DELETED_RECORDS_PURGE_LIMIT, 10);
         final Cursor cursor = db.query(tableName, new String[] { CommonColumns._ID }, selection, null, null, null, null, limit);
         final String inClause;
         try {
             inClause = DBUtils.computeSQLInClauseFromLongs(cursor, CommonColumns._ID);
         } finally {
             cursor.close();
         }
 
         db.delete(tableName, inClause, null);
     }
+
+    // Override this, or override cleanUpSomeDeletedRecords.
+    protected String getDeletedItemSelection(long earlierThan) {
+        if (earlierThan == -1L) {
+            return SyncColumns.IS_DELETED + " = 1";
+        }
+        return SyncColumns.IS_DELETED + " = 1 AND " + SyncColumns.DATE_MODIFIED + " <= " + earlierThan;
+    }
 }
--- a/mobile/android/base/db/StubBrowserDB.java
+++ b/mobile/android/base/db/StubBrowserDB.java
@@ -43,32 +43,47 @@ class StubReadingListAccessor implements
     }
 
     @Override
     public boolean isReadingListItem(ContentResolver cr, String uri) {
         return false;
     }
 
     @Override
-    public void addReadingListItem(ContentResolver cr, ContentValues values) {
+    public long addReadingListItem(ContentResolver cr, ContentValues values) {
+        return 0L;
+    }
 
+    @Override
+    public long addBasicReadingListItem(ContentResolver cr, String url, String title) {
+        return 0L;
     }
 
     @Override
     public void updateReadingListItem(ContentResolver cr, ContentValues values) {
-
     }
 
     @Override
     public void removeReadingListItemWithURL(ContentResolver cr, String uri) {
-
     }
 
     @Override
     public void registerContentObserver(Context context, ContentObserver observer) {
+    }
+
+    @Override
+    public void markAsRead(ContentResolver cr, long itemID) {
+    }
+
+    @Override
+    public void updateContent(ContentResolver cr, long itemID, String resolvedTitle, String resolvedURL, String excerpt) {
+    }
+
+    @Override
+    public void deleteItem(ContentResolver cr, long itemID) {
 
     }
 }
 
 class StubSearches implements Searches {
     public StubSearches() {
     }
 
--- a/mobile/android/base/tests/testReadingListProvider.java
+++ b/mobile/android/base/tests/testReadingListProvider.java
@@ -3,44 +3,69 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.tests;
 
 import java.util.HashSet;
 import java.util.Random;
 import java.util.concurrent.Callable;
 
+import android.content.ContentResolver;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
+import org.mozilla.gecko.db.ReadingListAccessor;
 import org.mozilla.gecko.db.ReadingListProvider;
 
 import android.content.ContentProvider;
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.net.Uri;
 
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.*;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.ADDED_ON;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.CLIENT_LAST_MODIFIED;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.EXCERPT;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.GUID;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.IS_UNREAD;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.RESOLVED_TITLE;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.RESOLVED_URL;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SERVER_LAST_MODIFIED;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SERVER_STORED_ON;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_FLAGS;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_NONE;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_RESOLVED;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_CHANGE_UNREAD_CHANGED;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS_MODIFIED;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS_NEW;
+import static org.mozilla.gecko.db.BrowserContract.ReadingListItems.SYNC_STATUS_SYNCED;
+import static org.mozilla.gecko.db.BrowserContract.URLColumns.TITLE;
+import static org.mozilla.gecko.db.BrowserContract.URLColumns.URL;
+
 public class testReadingListProvider extends ContentProviderTest {
 
     private static final String DB_NAME = "browser.db";
 
     // List of tests to be run sorted by dependency.
-    private final TestCase[] TESTS_TO_RUN = { new TestInsertItems(),
-                                              new TestDeleteItems(),
-                                              new TestUpdateItems(),
-                                              new TestBatchOperations(),
-                                              new TestBrowserProviderNotifications() };
+    private final TestCase[] TESTS_TO_RUN = {
+            new TestInsertItems(),
+            new TestDeleteItems(),
+            new TestUpdateItems(),
+            new TestBatchOperations(),
+            new TestBrowserProviderNotifications(),
+            new TestStateSequencing(),
+    };
 
     // Columns used to test for item equivalence.
-    final String[] TEST_COLUMNS = { ReadingListItems.TITLE,
-                                    ReadingListItems.URL,
-                                    ReadingListItems.EXCERPT,
-                                    ReadingListItems.LENGTH,
-                                    ReadingListItems.DATE_CREATED };
+    final String[] TEST_COLUMNS = { TITLE,
+                                    URL,
+                                    EXCERPT,
+                                    ADDED_ON };
 
     // Indicates that insertions have been tested. ContentProvider.insert
     // has been proven to work.
     private boolean mContentProviderInsertTested = false;
 
     // Indicates that updates have been tested. ContentProvider.update
     // has been proven to work.
     private boolean mContentProviderUpdateTested = false;
@@ -80,220 +105,220 @@ public class testReadingListProvider ext
 
     /**
      * Verify that we can insert a reading list item into the DB.
      */
     private class TestInsertItems extends TestCase {
         @Override
         public void test() throws Exception {
             ContentValues b = createFillerReadingListItem();
-            long id = ContentUris.parseId(mProvider.insert(ReadingListItems.CONTENT_URI, b));
+            long id = ContentUris.parseId(mProvider.insert(CONTENT_URI, b));
             Cursor c = getItemById(id);
 
             try {
                 mAsserter.ok(c.moveToFirst(), "Inserted item found", "");
                 assertRowEqualsContentValues(c, b);
 
-                mAsserter.is(c.getInt(c.getColumnIndex(ReadingListItems.CONTENT_STATUS)),
-                             ReadingListItems.STATUS_UNFETCHED,
+                mAsserter.is(c.getInt(c.getColumnIndex(CONTENT_STATUS)),
+                             STATUS_UNFETCHED,
                              "Inserted item has correct default content status");
             } finally {
                 c.close();
             }
 
-            testInsertWithNullCol(ReadingListItems.GUID);
+            testInsertWithNullCol(URL);
             mContentProviderInsertTested = true;
         }
 
         /**
          * Test that insertion fails when a required column
          * is null.
          */
         private void testInsertWithNullCol(String colName) {
             ContentValues b = createFillerReadingListItem();
             b.putNull(colName);
 
             try {
-                ContentUris.parseId(mProvider.insert(ReadingListItems.CONTENT_URI, b));
+                ContentUris.parseId(mProvider.insert(CONTENT_URI, b));
                 // If we get to here, the flawed insertion succeeded. Fail the test.
                 mAsserter.ok(false, "Insertion did not succeed with " + colName + " == null", "");
             } catch (NullPointerException e) {
                 // Indicates test was successful.
             }
         }
     }
 
     /**
      * Verify that we can remove a reading list item from the DB.
      */
     private class TestDeleteItems extends TestCase {
 
         @Override
         public void test() throws Exception {
-            long id = insertAnItemWithAssertion();
-            // Test that the item is only marked as deleted and
+            final long one = insertAnItemWithAssertion();
+            final long two = insertAnItemWithAssertion();
+
+            assignGUID(one);
+
+            // Test that the item with a GUID is only marked as deleted and
             // not removed from the database.
-            testNonFirefoxSyncDelete(id);
+            testNonSyncDelete(one, true);
+
+            // The item without a GUID is just deleted immediately.
+            testNonSyncDelete(two, false);
 
-            // Test that the item is removed from the database.
-            testFirefoxSyncDelete(id);
+            // Test that the item with a GUID is removed from the database when deleted by Sync.
+            testSyncDelete(one);
+
+            final long three = insertAnItemWithAssertion();
 
-            id = insertAnItemWithAssertion();
             // Test that deleting works with only a URI.
-            testDeleteWithItemURI(id);
+            testDeleteWithItemURI(three);
+        }
+
+        private void assignGUID(final long id) {
+            final ContentValues values = new ContentValues();
+            values.put(GUID, "abcdefghi");
+            mProvider.update(CONTENT_URI, values, _ID + " = " + id, null);
         }
 
         /**
          * Delete an item with PARAM_IS_SYNC unset and verify that item was only marked
          * as deleted and not actually removed from the database. Also verify that the item
          * marked as deleted doesn't show up in a query.
          *
+         * Note that items are deleted immediately if they don't have a GUID.
+         *
          * @param id of the item to be deleted
+         * @param hasGUID if true, we expect the item to stick around and be marked.
          */
-        private void testNonFirefoxSyncDelete(long id) {
-            final int deleted = mProvider.delete(ReadingListItems.CONTENT_URI,
-                    ReadingListItems._ID + " = ?",
-                    new String[] { String.valueOf(id) });
+        private void testNonSyncDelete(long id, boolean hasGUID) {
+            final int deleted = mProvider.delete(CONTENT_URI,
+                                                 _ID + " = " + id,
+                                                 null);
 
             mAsserter.is(deleted, 1, "Inserted item was deleted");
 
             // PARAM_SHOW_DELETED in the URI allows items marked as deleted to be
             // included in the query.
-            Uri uri = appendUriParam(ReadingListItems.CONTENT_URI, BrowserContract.PARAM_SHOW_DELETED, "1");
-            assertItemExistsByID(uri, id, "Deleted item was only marked as deleted");
+            Uri uri = appendUriParam(CONTENT_URI, BrowserContract.PARAM_SHOW_DELETED, "1");
+            if (hasGUID) {
+                assertItemExistsByID(uri, id, "Deleted item was only marked as deleted");
+            } else {
+                assertItemDoesNotExistByID(uri, id, "Deleted item had no GUID, so was really deleted.");
+            }
 
             // Test that the 'deleted' item does not show up in a query when PARAM_SHOW_DELETED
             // is not specified in the URI.
             assertItemDoesNotExistByID(id, "Inserted item can't be found after deletion");
         }
 
         /**
          * Delete an item with PARAM_IS_SYNC=1 and verify that item
          * was actually removed from the database.
          *
          * @param id of the item to be deleted
          */
-        private void testFirefoxSyncDelete(long id) {
-            final int deleted = mProvider.delete(appendUriParam(ReadingListItems.CONTENT_URI, BrowserContract.PARAM_IS_SYNC, "1"),
-                    ReadingListItems._ID + " = ?",
-                    new String[] { String.valueOf(id) });
+        private void testSyncDelete(long id) {
+            final int deleted = mProvider.delete(appendUriParam(CONTENT_URI, BrowserContract.PARAM_IS_SYNC, "1"),
+                    _ID + " = " + id,
+                    null);
 
             mAsserter.is(deleted, 1, "Inserted item was deleted");
 
-            Uri uri = appendUriParam(ReadingListItems.CONTENT_URI, BrowserContract.PARAM_SHOW_DELETED, "1");
+            Uri uri = appendUriParam(CONTENT_URI, BrowserContract.PARAM_SHOW_DELETED, "1");
             assertItemDoesNotExistByID(uri, id, "Inserted item is now actually deleted");
         }
 
         /**
          * Delete an item with its URI and verify that the item
          * was actually removed from the database.
          *
          * @param id of the item to be deleted
          */
         private void testDeleteWithItemURI(long id) {
-            final int deleted = mProvider.delete(ContentUris.withAppendedId(ReadingListItems.CONTENT_URI, id), null, null);
+            final int deleted = mProvider.delete(ContentUris.withAppendedId(CONTENT_URI, id), null, null);
             mAsserter.is(deleted, 1, "Inserted item was deleted using URI with id");
         }
     }
 
     /**
      * Verify that we can update reading list items.
      */
     private class TestUpdateItems extends TestCase {
 
         @Override
         public void test() throws Exception {
             // We should be able to insert into the DB.
             ensureCanInsert();
 
             ContentValues original = createFillerReadingListItem();
-            long id = ContentUris.parseId(mProvider.insert(ReadingListItems.CONTENT_URI, original));
+            long id = ContentUris.parseId(mProvider.insert(CONTENT_URI, original));
             int updated = 0;
             Long originalDateCreated = null;
             Long originalDateModified = null;
             ContentValues updates = new ContentValues();
             Cursor c = getItemById(id);
             try {
                 mAsserter.ok(c.moveToFirst(), "Inserted item found", "");
 
-                originalDateCreated = c.getLong(c.getColumnIndex(ReadingListItems.DATE_CREATED));
-                originalDateModified = c.getLong(c.getColumnIndex(ReadingListItems.DATE_MODIFIED));
+                originalDateCreated = c.getLong(c.getColumnIndex(ADDED_ON));
+                originalDateModified = c.getLong(c.getColumnIndex(CLIENT_LAST_MODIFIED));
 
-                updates.put(ReadingListItems.TITLE, original.getAsString(ReadingListItems.TITLE) + "CHANGED");
-                updates.put(ReadingListItems.URL, original.getAsString(ReadingListItems.URL) + "/more/stuff");
-                updates.put(ReadingListItems.EXCERPT, original.getAsString(ReadingListItems.EXCERPT) + "CHANGED");
+                updates.put(TITLE, original.getAsString(TITLE) + "CHANGED");
+                updates.put(URL, original.getAsString(URL) + "/more/stuff");
+                updates.put(EXCERPT, original.getAsString(EXCERPT) + "CHANGED");
 
-                updated = mProvider.update(ReadingListItems.CONTENT_URI, updates,
-                                               ReadingListItems._ID + " = ?",
-                                               new String[] { String.valueOf(id) });
+                updated = mProvider.update(CONTENT_URI, updates,
+                                           _ID + " = ?",
+                                           new String[] { String.valueOf(id) });
 
                 mAsserter.is(updated, 1, "Inserted item was updated");
             } finally {
                 c.close();
             }
 
             // Name change for clarity. These values will be compared with the
             // current cursor row.
-            ContentValues expectedValues = updates;
+            final ContentValues expectedValues = updates;
             c = getItemById(id);
             try {
                 mAsserter.ok(c.moveToFirst(), "Updated item found", "");
-                mAsserter.isnot(c.getLong(c.getColumnIndex(ReadingListItems.DATE_MODIFIED)),
+                mAsserter.isnot(c.getLong(c.getColumnIndex(CLIENT_LAST_MODIFIED)),
                 originalDateModified,
                 "Date modified should have changed");
 
-                // DATE_CREATED and LENGTH should equal old values since they weren't updated.
-                expectedValues.put(ReadingListItems.DATE_CREATED, originalDateCreated);
-                expectedValues.put(ReadingListItems.LENGTH, original.getAsString(ReadingListItems.LENGTH));
-                assertRowEqualsContentValues(c, expectedValues, /* compareDateModified */ false);
+                // ADDED_ON shouldn't have changed.
+                expectedValues.put(ADDED_ON, originalDateCreated);
+                assertRowEqualsContentValues(c, expectedValues, /* compareDateModified */ false, TEST_COLUMNS);
             } finally {
                 c.close();
             }
 
             // Test that updates on an item that doesn't exist does not modify any rows.
             testUpdateWithInvalidID();
 
-            // Test that update fails when a GUID is null.
-            testUpdateWithNullCol(id, ReadingListItems.GUID);
-
             mContentProviderUpdateTested = true;
         }
 
         /**
          * Test that updates on an item that doesn't exist does
          * not modify any rows.
-         *
-         * @param id of the item to be deleted
          */
         private void testUpdateWithInvalidID() {
             ensureEmptyDatabase();
             final ContentValues b = createFillerReadingListItem();
-            final long id = ContentUris.parseId(mProvider.insert(ReadingListItems.CONTENT_URI, b));
+            final long id = ContentUris.parseId(mProvider.insert(CONTENT_URI, b));
             final long INVALID_ID = id + 1;
             final ContentValues updates = new ContentValues();
-            updates.put(ReadingListItems.TITLE, b.getAsString(ReadingListItems.TITLE) + "CHANGED");
-            final int updated = mProvider.update(ReadingListItems.CONTENT_URI, updates,
-                                               ReadingListItems._ID + " = ?",
-                                               new String[] { String.valueOf(INVALID_ID) });
-            mAsserter.is(updated, 0, "Should not be able to update item with an invalid GUID");
-        }
-
-        /**
-         * Test that update fails when a required column is null.
-         */
-        private int testUpdateWithNullCol(long id, String colName) {
-            ContentValues updates = new ContentValues();
-            updates.putNull(colName);
-
-            int updated = mProvider.update(ReadingListItems.CONTENT_URI, updates,
-                                           ReadingListItems._ID + " = ?",
-                                           new String[] { String.valueOf(id) });
-
-            mAsserter.is(updated, 0, "Should not be able to update item with " + colName + " == null ");
-            return updated;
+            updates.put(TITLE, b.getAsString(TITLE) + "CHANGED");
+            final int updated = mProvider.update(CONTENT_URI, updates,
+                                                 _ID + " = ?",
+                                                 new String[] { String.valueOf(INVALID_ID) });
+            mAsserter.is(updated, 0, "Should not be able to update item with an invalid ID");
         }
     }
 
     private class TestBatchOperations extends TestCase {
         private static final int ITEM_COUNT = 10;
 
         /**
          * Insert a bunch of items into the DB with the bulkInsert
@@ -301,33 +326,32 @@ public class testReadingListProvider ext
          */
         private void testBulkInsert() {
             ensureEmptyDatabase();
             final ContentValues allVals[] = new ContentValues[ITEM_COUNT];
             final HashSet<String> urls = new HashSet<String>();
             for (int i = 0; i < ITEM_COUNT; i++) {
                 final String url =  "http://www.test.org/" + i;
                 allVals[i] = new ContentValues();
-                allVals[i].put(ReadingListItems.TITLE, "Test" + i);
-                allVals[i].put(ReadingListItems.URL, url);
-                allVals[i].put(ReadingListItems.EXCERPT, "EXCERPT" + i);
-                allVals[i].put(ReadingListItems.LENGTH, i);
+                allVals[i].put(TITLE, "Test" + i);
+                allVals[i].put(URL, url);
+                allVals[i].put(EXCERPT, "EXCERPT" + i);
                 urls.add(url);
             }
 
-            int inserts = mProvider.bulkInsert(ReadingListItems.CONTENT_URI, allVals);
+            int inserts = mProvider.bulkInsert(CONTENT_URI, allVals);
             mAsserter.is(inserts, ITEM_COUNT, "Excepted number of inserts matches");
 
-            Cursor c = mProvider.query(ReadingListItems.CONTENT_URI, null,
-                               null,
-                               null,
-                               null);
+            final Cursor c = mProvider.query(CONTENT_URI, null,
+                                             null,
+                                             null,
+                                             null);
             try {
                 while (c.moveToNext()) {
-                    final String url = c.getString(c.getColumnIndex(ReadingListItems.URL));
+                    final String url = c.getString(c.getColumnIndex(URL));
                     mAsserter.ok(urls.contains(url), "Bulk inserted item with url == " + url + " was found in the DB", "");
                     // We should only be seeing each item once. Remove from set to prevent dups.
                     urls.remove(url);
                 }
             } finally {
                 c.close();
             }
         }
@@ -364,20 +388,20 @@ public class testReadingListProvider ext
             mAsserter.isnot(id,
                             -1L,
                             "Inserted item has valid id");
 
             ensureOnlyChangeNotifiedStartsWith(CONTENT_URI, "insert");
 
             // Update
             mResolver.notifyChangeList.clear();
-            h.put(ReadingListItems.TITLE, "http://newexample.com");
+            h.put(TITLE, "http://newexample.com");
 
             long numUpdated = mProvider.update(ReadingListItems.CONTENT_URI, h,
-                                               ReadingListItems._ID + " = ?",
+                                               _ID + " = ?",
                                                new String[] { String.valueOf(id) });
 
             mAsserter.is(numUpdated,
                          1L,
                          "Correct number of items are updated");
 
             ensureOnlyChangeNotifiedStartsWith(CONTENT_URI, "update");
 
@@ -418,82 +442,277 @@ public class testReadingListProvider ext
                             "Notification from " + operation + " was valid");
 
             mAsserter.ok(uri.toString().startsWith(expectedUri),
                          "Content observer was notified exactly once by " + operation,
                          "");
         }
     }
 
+    private class TestStateSequencing extends TestCase {
+        @Override
+        protected void test() throws Exception {
+            final ReadingListAccessor accessor = getTestProfile().getDB().getReadingListAccessor();
+            final ContentResolver cr = getActivity().getContentResolver();
+            final Uri syncURI = CONTENT_URI.buildUpon()
+                                           .appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "1")
+                                           .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1")
+                                           .build();
+
+            mAsserter.ok(accessor != null, "We have an accessor.", null);
+
+            // Verify that the accessor thinks we're empty.
+            mAsserter.ok(0 == accessor.getCount(cr), "We have no items.", null);
+
+            // Insert an item via the accessor.
+            final long addedItem = accessor.addBasicReadingListItem(cr, "http://example.org/", "Example A");
+
+            mAsserter.ok(1 == accessor.getCount(cr), "We have one item.", null);
+            final Cursor cursor = accessor.getReadingList(cr);
+            try {
+                mAsserter.ok(cursor.moveToNext(), "The cursor isn't empty.", null);
+                mAsserter.ok(1 == cursor.getCount(), "The cursor agrees.", null);
+            } finally {
+                cursor.close();
+            }
+
+            // Verify that it has no GUID, that its state is NEW, etc.
+            // This requires fetching more fields than the accessor uses.
+            final Cursor all = getEverything(syncURI);
+            try {
+                mAsserter.ok(all.moveToNext(), "The cursor isn't empty.", null);
+                mAsserter.ok(1 == all.getCount(), "The cursor agrees.", null);
+
+                ContentValues expected = new ContentValues();
+                expected.putNull(GUID);
+                expected.put(SYNC_STATUS, SYNC_STATUS_NEW);
+                expected.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_NONE);
+                expected.put(URL, "http://example.org/");
+                expected.put(TITLE, "Example A");
+                expected.putNull(RESOLVED_URL);
+                expected.putNull(RESOLVED_TITLE);
+
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS, URL, TITLE, RESOLVED_URL, RESOLVED_TITLE, URL, TITLE};
+                assertRowEqualsContentValues(all, expected, false, testColumns);
+            } finally {
+                all.close();
+            }
+
+            // Pretend that it was just synced.
+            final long serverTime = System.currentTimeMillis();
+            final ContentValues wasSynced = new ContentValues();
+            wasSynced.put(GUID, "eeeeeeeeeeeeee");
+            wasSynced.put(SYNC_STATUS, SYNC_STATUS_SYNCED);
+            wasSynced.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_NONE);
+            wasSynced.put(SERVER_STORED_ON, serverTime);
+            wasSynced.put(SERVER_LAST_MODIFIED, serverTime);
+
+            mAsserter.ok(1 == mProvider.update(syncURI, wasSynced, _ID + " = " + addedItem, null), "Updated one item.", null);
+            final Cursor afterSync = getEverything(syncURI);
+            try {
+                mAsserter.ok(afterSync.moveToNext(), "The cursor isn't empty.", null);
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS, SERVER_STORED_ON, SERVER_LAST_MODIFIED};
+                assertRowEqualsContentValues(afterSync, wasSynced, false, testColumns);
+            } finally {
+                afterSync.close();
+            }
+
+            // Make changes to the record that exercise the various change flags, verifying that
+            // the correct flags are set.
+            final long beforeMarkedRead = System.currentTimeMillis();
+            accessor.markAsRead(cr, addedItem);
+            final ContentValues markedAsRead = new ContentValues();
+            markedAsRead.put(GUID, "eeeeeeeeeeeeee");
+            markedAsRead.put(SYNC_STATUS, SYNC_STATUS_MODIFIED);
+            markedAsRead.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_UNREAD_CHANGED);
+            markedAsRead.put(IS_UNREAD, 0);
+
+            final Cursor afterMarkedRead = getEverything(syncURI);
+            try {
+                mAsserter.ok(afterMarkedRead.moveToNext(), "The cursor isn't empty.", null);
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS, IS_UNREAD};
+                assertRowEqualsContentValues(afterMarkedRead, markedAsRead, false, testColumns);
+                assertModifiedInRange(afterMarkedRead, beforeMarkedRead);
+            } finally {
+                afterMarkedRead.close();
+            }
+
+            // Now our content is here!
+            final long beforeContentUpdated = System.currentTimeMillis();
+            accessor.updateContent(cr, addedItem, "New title", "http://www.example.com/article", "The excerpt is long.");
+
+            // After this the content status should have changed, and we should be flagged to sync.
+            final ContentValues contentUpdated = new ContentValues();
+            contentUpdated.put(GUID, "eeeeeeeeeeeeee");
+            contentUpdated.put(SYNC_STATUS, SYNC_STATUS_MODIFIED);
+            contentUpdated.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_UNREAD_CHANGED | SYNC_CHANGE_RESOLVED);
+            contentUpdated.put(IS_UNREAD, 0);
+            contentUpdated.put(CONTENT_STATUS, STATUS_FETCHED_ARTICLE);
+
+            final Cursor afterContentUpdated = getEverything(syncURI);
+            try {
+                mAsserter.ok(afterContentUpdated.moveToNext(), "The cursor isn't empty.", null);
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS, IS_UNREAD, CONTENT_STATUS};
+                assertRowEqualsContentValues(afterContentUpdated, contentUpdated, false, testColumns);
+                assertModifiedInRange(afterContentUpdated, beforeContentUpdated);
+            } finally {
+                afterContentUpdated.close();
+            }
+
+            // Delete the record, and verify that its Sync state is DELETED.
+            final long beforeDeletion = System.currentTimeMillis();
+            accessor.deleteItem(cr, addedItem);
+            final ContentValues itemDeleted = new ContentValues();
+            itemDeleted.put(GUID, "eeeeeeeeeeeeee");
+            itemDeleted.put(SYNC_STATUS, SYNC_STATUS_DELETED);
+            itemDeleted.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_NONE);
+            // TODO: CONTENT_STATUS on deletion?
+
+            final Cursor afterDeletion = getEverything(syncURI);
+            try {
+                mAsserter.ok(afterDeletion.moveToNext(), "The cursor isn't empty.", null);
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS};
+                assertRowEqualsContentValues(afterDeletion, itemDeleted, false, testColumns);
+                assertModifiedInRange(afterDeletion, beforeDeletion);
+            } finally {
+                afterDeletion.close();
+            }
+
+            // The accessor will no longer return the record.
+            mAsserter.ok(0 == accessor.getCount(cr), "No items found.", null);
+
+            // Add a new record as Sync -- it should start in state SYNCED.
+            final ContentValues newRecord = new ContentValues();
+            final long newServerTime = System.currentTimeMillis() - 50000;
+            newRecord.put(GUID, "ffeeeeeeeeeeee");
+            newRecord.put(SYNC_STATUS, SYNC_STATUS_SYNCED);
+            newRecord.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_NONE);
+            newRecord.put(SERVER_STORED_ON, newServerTime);
+            newRecord.put(SERVER_LAST_MODIFIED, newServerTime);
+            newRecord.put(CLIENT_LAST_MODIFIED, System.currentTimeMillis());
+            newRecord.put(URL, "http://www.mozilla.org/");
+            newRecord.put(TITLE, "Mozilla");
+
+            final long newID = ContentUris.parseId(cr.insert(syncURI, newRecord));
+            mAsserter.ok(newID > 0, "New ID is greater than 0.", null);
+            mAsserter.ok(newID != addedItem, "New ID differs from last ID.", null);
+
+            final Cursor afterNewInsert = getEverything(syncURI);
+            try {
+                mAsserter.ok(afterNewInsert.moveToNext(), "The cursor isn't empty.", null);
+                mAsserter.ok(2 == afterNewInsert.getCount(), "The cursor has two rows.", null);
+
+                // Default sort order means newest first.
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS, SERVER_STORED_ON, SERVER_LAST_MODIFIED, CLIENT_LAST_MODIFIED, URL, TITLE};
+                assertRowEqualsContentValues(afterNewInsert, newRecord, false, testColumns);
+            } finally {
+                afterNewInsert.close();
+            }
+
+            // Make a change to it. Verify that it's now changed with the right flags.
+            final long beforeNewRead = System.currentTimeMillis();
+            accessor.markAsRead(cr, newID);
+            newRecord.put(SYNC_STATUS, SYNC_STATUS_MODIFIED);
+            newRecord.put(SYNC_CHANGE_FLAGS, SYNC_CHANGE_UNREAD_CHANGED);
+
+            final Cursor afterNewRead = getEverything(syncURI);
+            try {
+                mAsserter.ok(afterNewRead.moveToNext(), "The cursor isn't empty.", null);
+                mAsserter.ok(2 == afterNewRead.getCount(), "The cursor has two rows.", null);
+
+                // Default sort order means newest first.
+                final String[] testColumns = {GUID, SYNC_STATUS, SYNC_CHANGE_FLAGS, SERVER_STORED_ON, SERVER_LAST_MODIFIED, URL, TITLE};
+                assertRowEqualsContentValues(afterNewRead, newRecord, false, testColumns);
+                assertModifiedInRange(afterNewRead, beforeNewRead);
+            } finally {
+                afterNewRead.close();
+            }
+        }
+
+        private void assertModifiedInRange(Cursor cursor, long earliest) {
+            final long dbModified = cursor.getLong(cursor.getColumnIndexOrThrow(CLIENT_LAST_MODIFIED));
+            mAsserter.ok(dbModified >= earliest, "DB timestamp is at least as late as earliest.", null);
+            mAsserter.ok(dbModified <= System.currentTimeMillis(), "DB timestamp is earlier than now.", null);
+        }
+    }
+
     /**
      * Removes all items from the DB.
      */
     private void ensureEmptyDatabase() {
-        Uri uri = appendUriParam(ReadingListItems.CONTENT_URI, BrowserContract.PARAM_IS_SYNC, "1");
-        getWritableDatabase(uri).delete(ReadingListItems.TABLE_NAME, null, null);
+        getWritableDatabase(CONTENT_URI).delete(TABLE_NAME, null, null);
     }
 
 
     private SQLiteDatabase getWritableDatabase(Uri uri) {
         Uri testUri = appendUriParam(uri, BrowserContract.PARAM_IS_TEST, "1");
         DelegatingTestContentProvider delegateProvider = (DelegatingTestContentProvider) mProvider;
         ReadingListProvider readingListProvider = (ReadingListProvider) delegateProvider.getTargetProvider();
         return readingListProvider.getWritableDatabaseForTesting(testUri);
     }
 
     /**
      * Checks that the values in the cursor's current row match those
      * in the ContentValues object.
      *
-     * @param cursor over the row to be checked
-     * @param values to be checked
+     * @param testColumns
+     * @param cursorWithActual over the row to be checked
+     * @param expectedValues to be checked
      */
-    private void assertRowEqualsContentValues(Cursor cursorWithActual, ContentValues expectedValues, boolean compareDateModified) {
-        for (String column: TEST_COLUMNS) {
+    private void assertRowEqualsContentValues(Cursor cursorWithActual, ContentValues expectedValues, boolean compareDateModified, String[] testColumns) {
+        for (String column: testColumns) {
             String expected = expectedValues.getAsString(column);
             String actual = cursorWithActual.getString(cursorWithActual.getColumnIndex(column));
             mAsserter.is(actual, expected, "Item has correct " + column);
         }
 
         if (compareDateModified) {
-            String expected = expectedValues.getAsString(ReadingListItems.DATE_MODIFIED);
-            String actual = cursorWithActual.getString(cursorWithActual.getColumnIndex(ReadingListItems.DATE_MODIFIED));
-            mAsserter.is(actual, expected, "Item has correct " + ReadingListItems.DATE_MODIFIED);
+            String expected = expectedValues.getAsString(CLIENT_LAST_MODIFIED);
+            String actual = cursorWithActual.getString(cursorWithActual.getColumnIndex(CLIENT_LAST_MODIFIED));
+            mAsserter.is(actual, expected, "Item has correct " + CLIENT_LAST_MODIFIED);
         }
     }
 
     private void assertRowEqualsContentValues(Cursor cursorWithActual, ContentValues expectedValues) {
-        assertRowEqualsContentValues(cursorWithActual, expectedValues, true);
+        assertRowEqualsContentValues(cursorWithActual, expectedValues, true, TEST_COLUMNS);
     }
 
     private ContentValues fillContentValues(String title, String url, String excerpt) {
         ContentValues values = new ContentValues();
 
-        values.put(ReadingListItems.TITLE, title);
-        values.put(ReadingListItems.URL, url);
-        values.put(ReadingListItems.EXCERPT, excerpt);
-        values.put(ReadingListItems.LENGTH, excerpt.length());
+        values.put(TITLE, title);
+        values.put(URL, url);
+        values.put(EXCERPT, excerpt);
+        values.put(ADDED_ON, System.currentTimeMillis());
 
         return values;
     }
 
     private ContentValues createFillerReadingListItem() {
         Random rand = new Random();
         return fillContentValues("Example", "http://example.com/?num=" + rand.nextInt(), "foo bar");
     }
 
+    private Cursor getEverything(Uri uri) {
+        return mProvider.query(uri,
+                               null,
+                               null,
+                               null,
+                               null);
+    }
+
     private Cursor getItemById(Uri uri, long id, String[] projection) {
         return mProvider.query(uri, projection,
-                               ReadingListItems._ID + " = ?",
+                               _ID + " = ?",
                                new String[] { String.valueOf(id) },
                                null);
     }
 
     private Cursor getItemById(long id) {
-        return getItemById(ReadingListItems.CONTENT_URI, id, null);
+        return getItemById(CONTENT_URI, id, null);
     }
 
     private Cursor getItemById(Uri uri, long id) {
         return getItemById(uri, id, null);
     }
 
     /**
      * Verifies that ContentProvider insertions have been tested.
@@ -513,17 +732,17 @@ public class testReadingListProvider ext
         }
     }
 
     private long insertAnItemWithAssertion() {
         // We should be able to insert into the DB.
         ensureCanInsert();
 
         ContentValues v = createFillerReadingListItem();
-        long id = ContentUris.parseId(mProvider.insert(ReadingListItems.CONTENT_URI, v));
+        long id = ContentUris.parseId(mProvider.insert(CONTENT_URI, v));
 
         assertItemExistsByID(id, "Inserted item found");
         return id;
     }
 
     private void assertItemExistsByID(Uri uri, long id, String msg) {
         Cursor c = getItemById(uri, id);
         try {