Implement a basic content provider for tags.

Hookup content change notifications so the tag
list updates when you delete a tag.

A few small optimizations in TagList.

Change-Id: I342ba98c77705a393ca9d84f5b2ff14437fb1d0b
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 4349180..7c55121 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -50,5 +50,12 @@
 
         <service android:name="TagService" />
 
+        <provider android:name=".provider.TagProvider"
+            android:authorities="com.android.apps.tag"
+            android:syncable="false"
+            android:multiprocess="false"
+            android:exported="false"
+        />
+
     </application>
 </manifest>
diff --git a/src/com/android/apps/tag/TagAdapter.java b/src/com/android/apps/tag/TagAdapter.java
deleted file mode 100644
index 8fbf064..0000000
--- a/src/com/android/apps/tag/TagAdapter.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright (C) 2010 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.apps.tag;
-
-import com.android.apps.tag.TagDBHelper.NdefMessagesTable;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.text.format.DateUtils;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Adapter;
-import android.widget.CursorAdapter;
-import android.widget.TextView;
-
-/**
- * A custom {@link Adapter} that renders tag entries for a list.
- */
-public class TagAdapter extends CursorAdapter {
-
-    private final LayoutInflater mInflater;
-
-    public TagAdapter(Context context) {
-        super(context, null, false);
-        mInflater = LayoutInflater.from(context);
-    }
-
-    @Override
-    public void bindView(View view, Context context, Cursor cursor) {
-        TextView mainLine = (TextView) view.findViewById(R.id.title);
-        TextView dateLine = (TextView) view.findViewById(R.id.date);
-
-        mainLine.setText(cursor.getString(cursor.getColumnIndex(NdefMessagesTable.TITLE)));
-        dateLine.setText(DateUtils.getRelativeTimeSpanString(
-                context, cursor.getLong(cursor.getColumnIndex(NdefMessagesTable.DATE))));
-    }
-
-    @Override
-    public View newView(Context context, Cursor cursor, ViewGroup parent) {
-        return mInflater.inflate(R.layout.tag_list_item, null);
-    }
-}
diff --git a/src/com/android/apps/tag/TagDBHelper.java b/src/com/android/apps/tag/TagDBHelper.java
deleted file mode 100644
index 9c91afb..0000000
--- a/src/com/android/apps/tag/TagDBHelper.java
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * Copyright (C) 2010 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.apps.tag;
-
-import android.content.Context;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteOpenHelper;
-import android.database.sqlite.SQLiteStatement;
-import android.net.Uri;
-import android.nfc.FormatException;
-import android.nfc.NdefMessage;
-import android.nfc.NdefRecord;
-
-import java.util.Locale;
-
-import com.android.apps.tag.message.NdefMessageParser;
-import com.android.apps.tag.message.ParsedNdefMessage;
-import com.google.common.annotations.VisibleForTesting;
-
-/**
- * Database utilities for the saved tags.
- */
-public class TagDBHelper extends SQLiteOpenHelper {
-
-    private static final String DATABASE_NAME = "tags.db";
-    private static final int DATABASE_VERSION = 5;
-
-    public interface NdefMessagesTable {
-        public static final String TABLE_NAME = "nedf_msg";
-
-        public static final String _ID = "_id";
-        public static final String TITLE = "title";
-        public static final String BYTES = "bytes";
-        public static final String DATE = "date";
-        public static final String STARRED = "starred";
-    }
-
-    private static TagDBHelper sInstance;
-
-    private Context mContext;
-
-    public static synchronized TagDBHelper getInstance(Context context) {
-        if (sInstance == null) {
-            sInstance = new TagDBHelper(context.getApplicationContext());
-        }
-        return sInstance;
-    }
-
-    private TagDBHelper(Context context) {
-        this(context, DATABASE_NAME);
-        mContext = context;
-    }
-
-    @VisibleForTesting
-    TagDBHelper(Context context, String dbFile) {
-        super(context, dbFile, null, DATABASE_VERSION);
-        mContext = context;
-    }
-
-    @Override
-    public void onCreate(SQLiteDatabase db) {
-        db.execSQL("CREATE TABLE " + NdefMessagesTable.TABLE_NAME + " (" +
-                NdefMessagesTable._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
-                NdefMessagesTable.TITLE + " TEXT NOT NULL DEFAULT ''," +
-                NdefMessagesTable.BYTES + " BLOB NOT NULL, " +
-                NdefMessagesTable.DATE + " INTEGER NOT NULL, " +
-                NdefMessagesTable.STARRED + " INTEGER NOT NULL DEFAULT 0" +  // boolean
-                ");");
-
-        db.execSQL("CREATE INDEX msgIndex ON " + NdefMessagesTable.TABLE_NAME + " (" +
-                NdefMessagesTable.DATE + " DESC, " +
-                NdefMessagesTable.STARRED + " ASC" +
-                ")");
-
-        addTestData(db);
-    }
-
-    @Override
-    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
-        // Drop everything and recreate it for now
-        db.execSQL("DROP TABLE IF EXISTS " + NdefMessagesTable.TABLE_NAME);
-        onCreate(db);
-    }
-
-    private void addTestData(SQLiteDatabase db) {
-        // A fake message containing 1 URL
-        NdefMessage msg1 = new NdefMessage(new NdefRecord[] {
-                NdefUtil.toUriRecord(Uri.parse("http://www.google.com"))
-        });
-
-        // A fake message containing 2 URLs
-        NdefMessage msg2 = new NdefMessage(new NdefRecord[] {
-                NdefUtil.toUriRecord(Uri.parse("http://www.youtube.com")),
-                NdefUtil.toUriRecord(Uri.parse("http://www.android.com"))
-        });
-
-        insertNdefMessage(db, msg1, false);
-        insertNdefMessage(db, msg2, true);
-
-        try {
-            // insert some real messages we found in the field.
-            for (byte[] msg : MockNdefMessages.ALL_MOCK_MESSAGES) {
-                NdefMessage msg3 = new NdefMessage(msg);
-                insertNdefMessage(db, msg3, false);
-            }
-        } catch (FormatException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    public void insertNdefMessage(SQLiteDatabase db, NdefMessage msg, boolean isStarred) {
-        ParsedNdefMessage parsedMsg = NdefMessageParser.parse(msg);
-        SQLiteStatement stmt = null;
-        try {
-            stmt = db.compileStatement("INSERT INTO " + NdefMessagesTable.TABLE_NAME +
-                    "(" + NdefMessagesTable.BYTES + ", " + NdefMessagesTable.DATE + ", " +
-                    NdefMessagesTable.STARRED + "," + NdefMessagesTable.TITLE + ") " +
-                    "values (?, ?, ?, ?)");
-            stmt.bindBlob(1, msg.toByteArray());
-            stmt.bindLong(2, System.currentTimeMillis());
-            stmt.bindLong(3, isStarred ? 1 : 0);
-            stmt.bindString(4, parsedMsg.getSnippet(mContext, Locale.getDefault()));
-            stmt.executeInsert();
-        } finally {
-            if (stmt != null) stmt.close();
-        }
-    }
-}
diff --git a/src/com/android/apps/tag/TagList.java b/src/com/android/apps/tag/TagList.java
index 71e3097..f7eef94 100644
--- a/src/com/android/apps/tag/TagList.java
+++ b/src/com/android/apps/tag/TagList.java
@@ -16,69 +16,52 @@
 
 package com.android.apps.tag;
 
+import com.android.apps.tag.provider.TagContract;
+import com.android.apps.tag.provider.TagContract.NdefMessages;
+
 import android.app.Activity;
-import android.app.AlertDialog;
-import android.app.Dialog;
 import android.app.ListActivity;
-import android.content.DialogInterface;
+import android.content.Context;
 import android.content.Intent;
+import android.database.CharArrayBuffer;
 import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
 import android.nfc.FormatException;
 import android.nfc.NdefMessage;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.text.format.DateUtils;
 import android.util.Log;
-import android.view.Menu;
+import android.view.LayoutInflater;
 import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CursorAdapter;
 import android.widget.ListView;
-
-import com.android.apps.tag.TagDBHelper.NdefMessagesTable;
+import android.widget.TextView;
 
 /**
  * An {@link Activity} that displays a flat list of tags that can be "opened".
  */
-public class TagList extends ListActivity implements DialogInterface.OnClickListener {
+public class TagList extends ListActivity {
     static final String TAG = "TagList";
 
     static final String EXTRA_SHOW_STARRED_ONLY = "show_starred_only";
 
-    SQLiteDatabase mDatabase;
     TagAdapter mAdapter;
+    boolean mShowStarredOnly;
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
-        boolean showStarredOnly = getIntent().getBooleanExtra(EXTRA_SHOW_STARRED_ONLY, false);
-        mDatabase = TagDBHelper.getInstance(this).getReadableDatabase();
-        String selection = showStarredOnly ? NdefMessagesTable.STARRED + "=1" : null;
+        mShowStarredOnly = getIntent().getBooleanExtra(EXTRA_SHOW_STARRED_ONLY, false);
 
-        new TagLoaderTask().execute(selection);
+        new TagLoaderTask().execute((Void[]) null);
         mAdapter = new TagAdapter(this);
         setListAdapter(mAdapter);
         registerForContextMenu(getListView());
     }
 
     @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        super.onCreateOptionsMenu(menu);
-        menu.add("hello world");
-        return true;
-    }
-
-    @Override
-    protected Dialog onCreateDialog(int id, Bundle args) {
-        String[] stuff = new String[] { "a", "b" };
-        return new AlertDialog.Builder(this)
-                .setTitle("blah")
-                .setItems(stuff, this)
-                .setPositiveButton("Delete", null)
-                .setNegativeButton("Cancel", null)
-                .create();
-    }
-
-    @Override
     protected void onDestroy() {
         if (mAdapter != null) {
             mAdapter.changeCursor(null);
@@ -90,7 +73,7 @@
     protected void onListItemClick(ListView l, View v, int position, long id) {
         Cursor cursor = mAdapter.getCursor();
         cursor.moveToPosition(position);
-        byte[] tagBytes = cursor.getBlob(cursor.getColumnIndexOrThrow(NdefMessagesTable.BYTES));
+        byte[] tagBytes = cursor.getBlob(cursor.getColumnIndexOrThrow(TagContract.NdefMessages.BYTES));
         try {
             NdefMessage msg = new NdefMessage(tagBytes);
             Intent intent = new Intent(this, TagViewer.class);
@@ -103,23 +86,31 @@
         }
     }
 
-    @Override
-    public void onClick(DialogInterface dialog, int which) {
+    interface TagQuery {
+        static final String[] PROJECTION = new String[] {
+                NdefMessages._ID, // 0
+                NdefMessages.DATE, // 1
+                NdefMessages.TITLE, // 2
+        };
+
+        static final int COLUMN_ID = 0;
+        static final int COLUMN_DATE = 1;
+        static final int COLUMN_TITLE = 2;
     }
 
-    final class TagLoaderTask extends AsyncTask<String, Void, Cursor> {
+    /**
+     * Asynchronously loads the tag info from the database.
+     */
+    final class TagLoaderTask extends AsyncTask<Void, Void, Cursor> {
         @Override
-        public Cursor doInBackground(String... args) {
-            String selection = args[0];
-            Cursor cursor = mDatabase.query(
-                    NdefMessagesTable.TABLE_NAME,
-                    new String[] { 
-                            NdefMessagesTable._ID,
-                            NdefMessagesTable.BYTES,
-                            NdefMessagesTable.DATE,
-                            NdefMessagesTable.TITLE },
+        public Cursor doInBackground(Void... args) {
+            String selection = mShowStarredOnly ? NdefMessages.STARRED + "=1" : null;
+            Cursor cursor = getContentResolver().query(
+                    NdefMessages.CONTENT_URI,
+                    TagQuery.PROJECTION,
                     selection,
-                    null, null, null, NdefMessagesTable.DATE + " DESC");
+                    null, NdefMessages.DATE + " DESC");
+            if (cursor != null)
             cursor.getCount();
             return cursor;
         }
@@ -129,4 +120,57 @@
             mAdapter.changeCursor(cursor);
         }
     }
+
+    /**
+     * Struct to hold pointers to views in the list items to save time at view binding time.
+     */
+    static final class ViewHolder {
+        public CharArrayBuffer titleBuffer;
+        public TextView mainLine;
+        public TextView dateLine;
+    }
+
+    /**
+     * Adapter to display the tag entries.
+     */
+    public class TagAdapter extends CursorAdapter {
+        private final LayoutInflater mInflater;
+
+        public TagAdapter(Context context) {
+            super(context, null, false);
+            mInflater = LayoutInflater.from(context);
+        }
+
+        @Override
+        public void bindView(View view, Context context, Cursor cursor) {
+            ViewHolder holder = (ViewHolder) view.getTag();
+
+            CharArrayBuffer buf = holder.titleBuffer;
+            cursor.copyStringToBuffer(TagQuery.COLUMN_TITLE, buf);
+            holder.mainLine.setText(buf.data, 0, buf.sizeCopied);
+
+            holder.dateLine.setText(DateUtils.getRelativeTimeSpanString(
+                    context, cursor.getLong(TagQuery.COLUMN_DATE)));
+        }
+
+        @Override
+        public View newView(Context context, Cursor cursor, ViewGroup parent) {
+            View view = mInflater.inflate(R.layout.tag_list_item, null);
+
+            // Cache items for the view
+            ViewHolder holder = new ViewHolder();
+            holder.titleBuffer = new CharArrayBuffer(64);
+            holder.mainLine = (TextView) view.findViewById(R.id.title);
+            holder.dateLine = (TextView) view.findViewById(R.id.date);
+            view.setTag(holder);
+
+            return view;
+        }
+
+        @Override
+        public void onContentChanged() {
+            // Kick off an async query to refresh the list
+            new TagLoaderTask().execute((Void[]) null);
+        }
+    }
 }
diff --git a/src/com/android/apps/tag/TagService.java b/src/com/android/apps/tag/TagService.java
index 8748a55..c43142a 100644
--- a/src/com/android/apps/tag/TagService.java
+++ b/src/com/android/apps/tag/TagService.java
@@ -16,15 +16,25 @@
 
 package com.android.apps.tag;
 
-import com.android.apps.tag.TagDBHelper.NdefMessagesTable;
+import com.android.apps.tag.provider.TagContract;
+import com.android.apps.tag.provider.TagContract.NdefMessages;
 
 import android.app.IntentService;
+import android.content.ContentProviderOperation;
+import android.content.ContentUris;
+import android.content.ContentValues;
 import android.content.Intent;
-import android.database.sqlite.SQLiteDatabase;
+import android.content.OperationApplicationException;
 import android.nfc.NdefMessage;
 import android.os.Parcelable;
+import android.os.RemoteException;
+import android.util.Log;
+
+import java.util.ArrayList;
 
 public class TagService extends IntentService {
+    private static final String TAG = "TagService";
+
     public static final String EXTRA_SAVE_MSGS = "msgs";
     public static final String EXTRA_DELETE_ID = "delete";
 
@@ -34,24 +44,27 @@
 
     @Override
     public void onHandleIntent(Intent intent) {
-        TagDBHelper helper = TagDBHelper.getInstance(this);
-        SQLiteDatabase db = helper.getWritableDatabase();
         if (intent.hasExtra(EXTRA_SAVE_MSGS)) {
             Parcelable[] parcels = intent.getParcelableArrayExtra(EXTRA_SAVE_MSGS);
-            db.beginTransaction();
+            ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
+            for (Parcelable parcel : parcels) {
+                ContentValues values = NdefMessages.ndefMessageToValues(this, (NdefMessage) parcel
+                        , false);
+                ops.add(ContentProviderOperation.newInsert(NdefMessages.CONTENT_URI)
+                        .withValues(values).build());
+            }
             try {
-                for (Parcelable parcel : parcels) {
-                    helper.insertNdefMessage(db, (NdefMessage) parcel, false);
-                }
-                db.setTransactionSuccessful();
-            } finally {
-                db.endTransaction();
+                getContentResolver().applyBatch(TagContract.AUTHORITY, ops);
+            } catch (OperationApplicationException e) {
+                Log.e(TAG, "Failed to save messages", e);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Failed to save messages", e);
             }
             return;
         } else if (intent.hasExtra(EXTRA_DELETE_ID)) {
             long id = intent.getLongExtra(EXTRA_DELETE_ID, 0);
-            db.delete(NdefMessagesTable.TABLE_NAME, NdefMessagesTable._ID + "=?",
-                    new String[] { Long.toString(id) });
+            getContentResolver().delete(ContentUris.withAppendedId(NdefMessages.CONTENT_URI, id),
+                    null, null);
             return;
         }
     }
diff --git a/src/com/android/apps/tag/provider/SQLiteContentProvider.java b/src/com/android/apps/tag/provider/SQLiteContentProvider.java
new file mode 100644
index 0000000..ade9eaf
--- /dev/null
+++ b/src/com/android/apps/tag/provider/SQLiteContentProvider.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.apps.tag.provider;
+
+import android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.OperationApplicationException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteTransactionListener;
+import android.net.Uri;
+
+import java.util.ArrayList;
+
+/**
+ * General purpose {@link ContentProvider} base class that uses SQLiteDatabase for storage.
+ */
+public abstract class SQLiteContentProvider extends ContentProvider
+        implements SQLiteTransactionListener {
+
+    private static final String TAG = "SQLiteContentProvider";
+
+    private SQLiteOpenHelper mOpenHelper;
+    private volatile boolean mNotifyChange;
+    protected SQLiteDatabase mDb;
+
+    private final ThreadLocal<Boolean> mApplyingBatch = new ThreadLocal<Boolean>();
+    private static final int SLEEP_AFTER_YIELD_DELAY = 4000;
+
+    /**
+     * Maximum number of operations allowed in a batch between yield points.
+     */
+    private static final int MAX_OPERATIONS_PER_YIELD_POINT = 500;
+
+    @Override
+    public boolean onCreate() {
+        Context context = getContext();
+        mOpenHelper = getDatabaseHelper(context);
+        return true;
+    }
+
+    protected abstract SQLiteOpenHelper getDatabaseHelper(Context context);
+
+    /**
+     * The equivalent of the {@link #insert} method, but invoked within a transaction.
+     */
+    protected abstract Uri insertInTransaction(Uri uri, ContentValues values);
+
+    /**
+     * The equivalent of the {@link #update} method, but invoked within a transaction.
+     */
+    protected abstract int updateInTransaction(Uri uri, ContentValues values, String selection,
+            String[] selectionArgs);
+
+    /**
+     * The equivalent of the {@link #delete} method, but invoked within a transaction.
+     */
+    protected abstract int deleteInTransaction(Uri uri, String selection, String[] selectionArgs);
+
+    protected abstract void notifyChange();
+
+    protected SQLiteOpenHelper getDatabaseHelper() {
+        return mOpenHelper;
+    }
+
+    private boolean applyingBatch() {
+        return mApplyingBatch.get() != null && mApplyingBatch.get();
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        Uri result = null;
+        boolean applyingBatch = applyingBatch();
+        if (!applyingBatch) {
+            mDb = mOpenHelper.getWritableDatabase();
+            mDb.beginTransactionWithListener(this);
+            try {
+                result = insertInTransaction(uri, values);
+                if (result != null) {
+                    mNotifyChange = true;
+                }
+                mDb.setTransactionSuccessful();
+            } finally {
+                mDb.endTransaction();
+            }
+
+            onEndTransaction();
+        } else {
+            result = insertInTransaction(uri, values);
+            if (result != null) {
+                mNotifyChange = true;
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public int bulkInsert(Uri uri, ContentValues[] values) {
+        int numValues = values.length;
+        mDb = mOpenHelper.getWritableDatabase();
+        mDb.beginTransactionWithListener(this);
+        try {
+            for (int i = 0; i < numValues; i++) {
+                Uri result = insertInTransaction(uri, values[i]);
+                if (result != null) {
+                    mNotifyChange = true;
+                }
+                mDb.yieldIfContendedSafely();
+            }
+            mDb.setTransactionSuccessful();
+        } finally {
+            mDb.endTransaction();
+        }
+
+        onEndTransaction();
+        return numValues;
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        int count = 0;
+        boolean applyingBatch = applyingBatch();
+        if (!applyingBatch) {
+            mDb = mOpenHelper.getWritableDatabase();
+            mDb.beginTransactionWithListener(this);
+            try {
+                count = updateInTransaction(uri, values, selection, selectionArgs);
+                if (count > 0) {
+                    mNotifyChange = true;
+                }
+                mDb.setTransactionSuccessful();
+            } finally {
+                mDb.endTransaction();
+            }
+
+            onEndTransaction();
+        } else {
+            count = updateInTransaction(uri, values, selection, selectionArgs);
+            if (count > 0) {
+                mNotifyChange = true;
+            }
+        }
+
+        return count;
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        int count = 0;
+        boolean applyingBatch = applyingBatch();
+        if (!applyingBatch) {
+            mDb = mOpenHelper.getWritableDatabase();
+            mDb.beginTransactionWithListener(this);
+            try {
+                count = deleteInTransaction(uri, selection, selectionArgs);
+                if (count > 0) {
+                    mNotifyChange = true;
+                }
+                mDb.setTransactionSuccessful();
+            } finally {
+                mDb.endTransaction();
+            }
+
+            onEndTransaction();
+        } else {
+            count = deleteInTransaction(uri, selection, selectionArgs);
+            if (count > 0) {
+                mNotifyChange = true;
+            }
+        }
+        return count;
+    }
+
+    @Override
+    public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
+            throws OperationApplicationException {
+        int ypCount = 0;
+        int opCount = 0;
+        mDb = mOpenHelper.getWritableDatabase();
+        mDb.beginTransactionWithListener(this);
+        try {
+            mApplyingBatch.set(true);
+            final int numOperations = operations.size();
+            final ContentProviderResult[] results = new ContentProviderResult[numOperations];
+            for (int i = 0; i < numOperations; i++) {
+                if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) {
+                    throw new OperationApplicationException(
+                            "Too many content provider operations between yield points. "
+                                    + "The maximum number of operations per yield point is "
+                                    + MAX_OPERATIONS_PER_YIELD_POINT, ypCount);
+                }
+                final ContentProviderOperation operation = operations.get(i);
+                if (i > 0 && operation.isYieldAllowed()) {
+                    opCount = 0;
+                    if (mDb.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) {
+                        ypCount++;
+                    }
+                }
+                results[i] = operation.apply(this, results, i);
+            }
+            mDb.setTransactionSuccessful();
+            return results;
+        } finally {
+            mApplyingBatch.set(false);
+            mDb.endTransaction();
+            onEndTransaction();
+        }
+    }
+
+    public void onBegin() {
+        onBeginTransaction();
+    }
+
+    public void onCommit() {
+        beforeTransactionCommit();
+    }
+
+    public void onRollback() {
+        // not used
+    }
+
+    protected void onBeginTransaction() {
+    }
+
+    protected void beforeTransactionCommit() {
+    }
+
+    protected void onEndTransaction() {
+        if (mNotifyChange) {
+            mNotifyChange = false;
+            notifyChange();
+        }
+    }
+}
diff --git a/src/com/android/apps/tag/provider/TagContract.java b/src/com/android/apps/tag/provider/TagContract.java
new file mode 100644
index 0000000..2eaa653
--- /dev/null
+++ b/src/com/android/apps/tag/provider/TagContract.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.apps.tag.provider;
+
+import com.android.apps.tag.message.NdefMessageParser;
+import com.android.apps.tag.message.ParsedNdefMessage;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.nfc.NdefMessage;
+
+import java.util.Locale;
+
+public class TagContract {
+    public static final String AUTHORITY = "com.android.apps.tag";
+    public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY);
+
+    public static final class NdefMessages {
+        /**
+         * Utility class, cannot be instantiated.
+         */
+        private NdefMessages() {}
+
+        /**
+         * The content:// style URL for this table
+         */
+        public static final Uri CONTENT_URI = AUTHORITY_URI.buildUpon().appendPath("ndef").build();
+
+        /**
+         * The MIME type of {@link #CONTENT_URI} providing a directory of
+         * NDEF messages.
+         */
+        public static final String CONTENT_TYPE = "vnd.android.cursor.dir/ndef_msg";
+
+        /**
+         * The MIME type of a {@link #CONTENT_URI} subdirectory of a single
+         * NDEF message.
+         */
+        public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/ndef_msg";
+
+        // columns
+        public static final String _ID = "_id";
+        public static final String TITLE = "title";
+        public static final String BYTES = "bytes";
+        public static final String DATE = "date";
+        public static final String STARRED = "starred";
+
+        /**
+         * Converts an NdefMessage to ContentValues that can be insrted into this table.
+         */
+        public static ContentValues ndefMessageToValues(Context context, NdefMessage msg,
+                boolean isStarred) {
+            ParsedNdefMessage parsedMsg = NdefMessageParser.parse(msg);
+            ContentValues values = new ContentValues();
+            values.put(BYTES, msg.toByteArray());
+            values.put(DATE, System.currentTimeMillis());
+            values.put(STARRED, isStarred ? 1 : 0);
+            values.put(TITLE, parsedMsg.getSnippet(context, Locale.getDefault()));
+            return values;
+        }
+    }
+}
diff --git a/src/com/android/apps/tag/provider/TagDBHelper.java b/src/com/android/apps/tag/provider/TagDBHelper.java
new file mode 100644
index 0000000..a54c8e1
--- /dev/null
+++ b/src/com/android/apps/tag/provider/TagDBHelper.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.apps.tag.provider;
+
+import com.android.apps.tag.provider.TagContract.NdefMessages;
+import com.google.common.annotations.VisibleForTesting;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+/**
+ * Database utilities for the saved tags.
+ */
+public class TagDBHelper extends SQLiteOpenHelper {
+
+    private static final String DATABASE_NAME = "tags.db";
+    private static final int DATABASE_VERSION = 5;
+
+    public static final String TABLE_NAME_NDEF_MESSAGES = "nedf_msg";
+
+    TagDBHelper(Context context) {
+        this(context, DATABASE_NAME);
+    }
+
+    @VisibleForTesting
+    TagDBHelper(Context context, String dbFile) {
+        super(context, dbFile, null, DATABASE_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        db.execSQL("CREATE TABLE " + TABLE_NAME_NDEF_MESSAGES + " (" +
+                NdefMessages._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                NdefMessages.TITLE + " TEXT NOT NULL DEFAULT ''," +
+                NdefMessages.BYTES + " BLOB NOT NULL, " +
+                NdefMessages.DATE + " INTEGER NOT NULL, " +
+                NdefMessages.STARRED + " INTEGER NOT NULL DEFAULT 0" +  // boolean
+                ");");
+
+        db.execSQL("CREATE INDEX msgIndex ON " + TABLE_NAME_NDEF_MESSAGES + " (" +
+                TagContract.NdefMessages.DATE + " DESC, " +
+                TagContract.NdefMessages.STARRED + " ASC" +
+                ")");
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        // Drop everything and recreate it for now
+        db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME_NDEF_MESSAGES);
+        onCreate(db);
+    }
+}
diff --git a/src/com/android/apps/tag/provider/TagProvider.java b/src/com/android/apps/tag/provider/TagProvider.java
new file mode 100644
index 0000000..89dd974
--- /dev/null
+++ b/src/com/android/apps/tag/provider/TagProvider.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.apps.tag.provider;
+
+import com.android.apps.tag.provider.TagContract.NdefMessages;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.util.Log;
+
+import java.util.HashMap;
+
+public class TagProvider extends SQLiteContentProvider {
+
+    private static final int NDEF_MESSAGES = 1000;
+    private static final int NDEF_MESSAGES_ID = 1001;
+    private static final UriMatcher sMatcher;
+
+    private static final HashMap<String, String> sNdefMessagesProjectionMap;
+
+    static {
+        sMatcher = new UriMatcher(0);
+        String auth = TagContract.AUTHORITY;
+        sMatcher.addURI(auth, "ndef", NDEF_MESSAGES);
+        sMatcher.addURI(auth, "ndef/#", NDEF_MESSAGES_ID);
+
+        HashMap<String, String> map = new HashMap<String, String>();
+        map.put(NdefMessages._ID, NdefMessages._ID);
+        map.put(NdefMessages.TITLE, NdefMessages.TITLE);
+        map.put(NdefMessages.BYTES, NdefMessages.BYTES);
+        map.put(NdefMessages.DATE, NdefMessages.DATE);
+        map.put(NdefMessages.STARRED, NdefMessages.STARRED);
+        sNdefMessagesProjectionMap = map;
+    }
+
+    @Override
+    protected SQLiteOpenHelper getDatabaseHelper(Context context) {
+        return new TagDBHelper(context);
+    }
+
+    /**
+     * Appends one set of selection args to another. This is useful when adding a selection
+     * argument to a user provided set.
+     */
+    public static String[] appendSelectionArgs(String[] originalValues, String[] newValues) {
+        if (originalValues == null || originalValues.length == 0) {
+            return newValues;
+        }
+        String[] result = new String[originalValues.length + newValues.length ];
+        System.arraycopy(originalValues, 0, result, 0, originalValues.length);
+        System.arraycopy(newValues, 0, result, originalValues.length, newValues.length);
+        return result;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        SQLiteDatabase db = getDatabaseHelper().getReadableDatabase();
+        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        int match = sMatcher.match(uri);
+        switch (match) {
+            case NDEF_MESSAGES_ID: {
+                selection = DatabaseUtils.concatenateWhere(selection,
+                        TagDBHelper.TABLE_NAME_NDEF_MESSAGES + "._id=?");
+                selectionArgs = appendSelectionArgs(selectionArgs,
+                        new String[] { Long.toString(ContentUris.parseId(uri)) });
+                // fall through
+            }
+            case NDEF_MESSAGES: {
+                qb.setTables(TagDBHelper.TABLE_NAME_NDEF_MESSAGES);
+                qb.setProjectionMap(sNdefMessagesProjectionMap);
+                break;
+            }
+
+            default: {
+                throw new IllegalArgumentException("unkown uri " + uri);
+            }
+        }
+
+        Cursor cursor = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
+        if (cursor != null) {
+            cursor.setNotificationUri(getContext().getContentResolver(), TagContract.AUTHORITY_URI);
+        }
+        return cursor;
+    }
+
+    @Override
+    protected Uri insertInTransaction(Uri uri, ContentValues values) {
+        SQLiteDatabase db = getDatabaseHelper().getWritableDatabase();
+        int match = sMatcher.match(uri);
+        long id = -1;
+        switch (match) {
+            case NDEF_MESSAGES: {
+                id = db.insert(TagDBHelper.TABLE_NAME_NDEF_MESSAGES, NdefMessages.TITLE, values);
+                break;
+            }
+
+            default: {
+                throw new IllegalArgumentException("unkown uri " + uri);
+            }
+        }
+
+        if (id >= 0) {
+            return ContentUris.withAppendedId(uri, id);
+        }
+        return null;
+    }
+
+    @Override
+    protected int updateInTransaction(Uri uri, ContentValues values, String selection,
+            String[] selectionArgs) {
+        SQLiteDatabase db = getDatabaseHelper().getWritableDatabase();
+        int match = sMatcher.match(uri);
+        int count = 0;
+        switch (match) {
+            case NDEF_MESSAGES_ID: {
+                selection = DatabaseUtils.concatenateWhere(selection,
+                        TagDBHelper.TABLE_NAME_NDEF_MESSAGES + "._id=?");
+                selectionArgs = appendSelectionArgs(selectionArgs,
+                        new String[] { Long.toString(ContentUris.parseId(uri)) });
+                // fall through
+            }
+            case NDEF_MESSAGES: {
+                count = db.update(TagDBHelper.TABLE_NAME_NDEF_MESSAGES, values, selection,
+                        selectionArgs);
+                break;
+            }
+
+            default: {
+                throw new IllegalArgumentException("unkown uri " + uri);
+            }
+        }
+
+        return count;
+    }
+
+    @Override
+    protected int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
+        SQLiteDatabase db = getDatabaseHelper().getWritableDatabase();
+        int match = sMatcher.match(uri);
+        int count = 0;
+        switch (match) {
+            case NDEF_MESSAGES_ID: {
+                selection = DatabaseUtils.concatenateWhere(selection,
+                        TagDBHelper.TABLE_NAME_NDEF_MESSAGES + "._id=?");
+                selectionArgs = appendSelectionArgs(selectionArgs,
+                        new String[] { Long.toString(ContentUris.parseId(uri)) });
+                // fall through
+            }
+            case NDEF_MESSAGES: {
+                count = db.delete(TagDBHelper.TABLE_NAME_NDEF_MESSAGES, selection, selectionArgs);
+                break;
+            }
+
+            default: {
+                throw new IllegalArgumentException("unkown uri " + uri);
+            }
+        }
+
+        return count;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        int match = sMatcher.match(uri);
+        switch (match) {
+            case NDEF_MESSAGES_ID: {
+                return NdefMessages.CONTENT_ITEM_TYPE;
+            }
+            case NDEF_MESSAGES: {
+                return NdefMessages.CONTENT_TYPE;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    protected void notifyChange() {
+        getContext().getContentResolver().notifyChange(TagContract.AUTHORITY_URI, null, false);
+    }
+}