Android TDD Series: Test-Driving Data-Access Part 2 - ContentProviders
In my last entry in this series I spoke about test-driving the SQLiteOpenHelper. We learned how to test the creation of a database for an application, and now we need to be able to interact this that database. Android provides more than one method to access the database, including accessing it directly via a query on the SQLiteOpenHelper, but here we’re going show how to create and test a ContentProvider, which will provide a more indirect and encapsulated method of access.
There are a number of reasons to implement a ContentProvider in your app. First, it gives you the ability to allow other applications to access your data. Beyond that it provides a cleaner, more maintainable design. Rather polluting your view classes with direct calls to the database, you can instead make calls to a ContentResolver (more on this later), which hides all of the gory details about how that data is retrieved. Finally, by implementing a ContentProvider we can enable thread-safe data access in Activities and Fragments via the LoaderManager Callbacks. These last two points, cleaner design and the LoaderManager Callbacks, are the reasons we will be implementing a ContentProvider.
The ContentProvider is a major component in Android, so if you’ve never worked with one before I highly recommend you read the ContentProvider Basics. You may also be interested in learning how to access some of the already-provided ContentProviders, including the ContactsProvider, or the Calendar Provider.
General Usage
As I mentioned earlier the ContentProvider is never accessed directly in our application. Instead, developers will use a combination of a Contract Class and the ContentResolver.
Contract Class
The Contract Class provides a consistent set of domain-specific fields and properties to be passed to the ContentResolver, namely the required URIs and database fields, which is the language the ContentResolver speaks in. The URIs are important, as they provide the ContentResolver with the means to determine which ContentProvider to make requests to, and potentially how many records to apply CRUD operations to. The Contract class will also expose common projections (otherwise know as the ‘select [fields]’ portion of a SQL select statement), and common sort orders.
See the example below for one implementation of a Contract class (also available on Github):
package com.groceryreminder.data;
import android.content.ContentResolver;
import android.net.Uri;
import android.provider.BaseColumns;
public class ReminderContract {
public static final String REMINDER_AUTHORITY = "com.groceryreminder.data.ReminderContentProvider";
public static final Uri REMINDER_CONTENT_URI = Uri.parse("content://" + REMINDER_AUTHORITY);
//snip
public static final class Reminders implements BaseColumns {
public static final String DESCRIPTION = "description";
public static final String[] PROJECT_ALL = {_ID, DESCRIPTION};
public static final String SORT_ORDER_DEFAULT = "";
public static final Uri CONTENT_URI = Uri.withAppendedPath(ReminderContract.REMINDER_CONTENT_URI, "reminders");
}
}
Usage of this class in conjunction with the ContentResolver in an Activity or Fragment might look something like this:
ContentValues values = new ContentValues();
values.put(ReminderContract.Reminders.DESCRIPTION, "value");
getContentResolver().insert(ReminderContract.Reminders.CONTENT_URI, values);
Notice that calling the ContentResolver does not expose any of the details of how the data is stored. You’re not communicating directly with a database helper of any sort, there is no mention of SQLite, you’re simply inserting some values. This means that if you wanted to change the implementation of the ContentProvider and store the data remotely instead, none of your view-related code would have to change. You’re also do not have to worry about what thread this call is made on, as the ContentResolver handles that for you as well.
Implementing a ContentProvider
Implementing a custom ContentProvider involves creating a class which extends ContentProvider and adding the required configuration in the AndroidManifest.xml file. When we create our new ContentProvider there are a number of abstract methods which must be implemented:
public boolean onCreate()
- A hook to allow you to perform whatever setup is necessary to interact with your data source. In our case this method will be used to a a handle on our SQLiteOpenHelper which we’ll use to interact with our database. Note: This method is called on main thread, so you’ll need to be careful not to implement long-running operations here.public String getType(Uri uri)
- Provides the mime type of the content to callers (not covered here).public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
- The primary query method which allows read access to the underlying data. Returns a Cursor to walk through the result set.public Uri insert(Uri uri, ContentValues values)
- The primary insertion method to add new data, which should return the URI of the new record. Note that there is abulkInsert()
method, but the underlying implementation calls this method.public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs)
- The primary update method, which should return the number of records updated (not covered here).public int delete(Uri uri, String selection, String[] selectionArgs)
- The primary deletion method, which should return the number of records deleted.
You should be aware that there is also an implied contract when implementing these methods, which is that any time data is changed any interested observers should be notified. This means a notification should be sent whenever insert, update, or delete successfully completes an operation. Without this notification downstream clients of this class, such as the LoaderManager Callbacks, won’t function properly.
Enough Talk, Let’s Write Some Code!
We’re about to get into the nuts and bolts of implementing this class. If you’re short on time you may want to simply check out a production example, which you can find in my GroceryReminder App on Github.
Creating the Provider
First, we need to implement our onCreate()
method. This only returns a boolean, so initially it will be simple:
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class ReminderContentProviderTest {
private ReminderContentProvider provider;
@Before
public void setUp() {
provider = new ReminderContentProvider();
}
@Test
public void whenTheProviderIsCreatedThenItShouldBeInitialized() {
assertTrue(provider.onCreate());
}
}
Now to get that test passing:
public class ReminderContentProvider extends ContentProvider {
@Override
public boolean onCreate() {
return true;
}
//snip
}
Dead simple, right? Let’s get to the meat and look at the insert()
method next.
Inserting Data
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class ReminderContentProviderTest {
private ReminderContentProvider provider;
@Before
public void setUp() {
provider = new ReminderContentProvider();
provider.onCreate();
}
@Test
public void whenTheProviderIsCreatedThenItShouldBeInitialized() {
ReminderContentProvider = new ReminderContentProvider();
assertTrue(provider.onCreate());
}
@Test
public void givenAReminderContentTypeAndContentValuesWhenARecordIsInsertedThenAURIWithRecordIDShouldBeReturned()
{
ContentValues values = createDefaultReminderValues();
Uri expectedUri = provider.insert(ReminderContract.Reminders.CONTENT_URI, values);
assertNotNull(expectedUri);
assertEquals(1, ContentUris.parseId(expectedUri));
}
private ContentValues createDefaultReminderValues() {
//Helper method which populates our ContentValues object for insertion.
return new ReminderValuesBuilder().createDefaultReminderValues().build();
}
}
We now have a test that is checking for a proper URI to be returned from the insert. Let’s implement that method now.
public class ReminderContentProvider extends ContentProvider {
private ReminderDBHelper reminderDBHelper;
@Override
public boolean onCreate() {
reminderDBHelper = new ReminderDBHelper(getContext());
return true;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
SQLiteDatabase writableDatabase = reminderDBHelper.getWritableDatabase();
long id = writableDatabase.insertWithOnConflict("reminders", null, values, SQLiteDatabase.CONFLICT_NONE);
Uri insertedUri = ContentUris.withAppendedId(uri, id);
return insertedUri;
}
//snip
}
Notice this test drove us to introduce our ReminderDBHelper (created in the previous post). We now perform a proper insertion of data. We’re missing functionality however, which is the notification to our observers that we made a change. Let’s write a test for that.
@Test
public void givenAReminderContentTypeAndContentValuesWhenARecordIsInsertedThenObserversAreNotified()
{
//Calling another helper method to create some values to be inserted
ContentValues values = createDefaultReminderValues();
ShadowContentResolver contentResolver = Shadows.shadowOf(provider.getContext().getContentResolver());
Uri expectedUri = provider.insert(ReminderContract.Reminders.CONTENT_URI, values);
List<ShadowContentResolver.NotifiedUri> notifiedUriList = contentResolver.getNotifiedUris();
assertThat(notifiedUriList.get(0).uri, is(expectedUri));
}
Here we’re leaning on Robolectric to get access to the list of notified URIs, which are normally not available to us in the vanilla Android framework. This allows us to test for functionality which would otherwise be untestable. Now the production change.
@Override
public Uri insert(Uri uri, ContentValues values) {
SQLiteDatabase writableDatabase = reminderDBHelper.getWritableDatabase();
long id = writableDatabase.insertWithOnConflict("reminders", null, values, SQLiteDatabase.CONFLICT_NONE);
Uri insertedUri = ContentUris.withAppendedId(uri, id);
//This line handles the notifications
getContext().getContentResolver().notifyChange(insertedUri, null);
return insertedUri;
}
Querying Data
There are a number of tests that need to written to fully cover the query()
method, but in the interest of time we’ll cover basics here. Let’s write our first query test.
@Test
public void whenRemindersAreQueriedThenTheRequestedSelectionShouldBeUsed() {
ContentValues otherRecord = new ReminderValuesBuilder().createDefaultReminderValues().build();
String expectedDescription = "test";
ContentValues recordToQuery = new ReminderValuesBuilder().createDefaultReminderValues().withDescription(expectedDescription).build();
provider.insert(ReminderContract.Reminders.CONTENT_URI, otherRecord);
provider.insert(ReminderContract.Reminders.CONTENT_URI, recordToQuery);
String selection = ReminderContract.Reminders.DESCRIPTION + " = 'test'";
Cursor cursor = provider.query(ReminderContract.Reminders.CONTENT_URI, ReminderContract.Reminders.PROJECT_ALL, selection, null, null);
assertEquals(1, cursor.getCount());
assertTrue(cursor.moveToNext());
assertEquals(expectedDescription, cursor.getString(1));
cursor.close();
cursor = null;
}
This is pretty straight forward. We insert some data, query for a particular record and expect to get it back. Now let’s implement the production code.
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables("reminders");
Cursor cursor = queryBuilder.query(reminderDBHelper.getReadableDatabase(), projection, selection, selectionArgs, null, null, sortOrder);
//Cannot currently test-drive this line: minSdk must be 19, currently set to 15
cursor.setNotificationUri(getContext().getContentResolver(), ReminderContract.Reminders.CONTENT_URI);
return cursor;
}
The magic here is that we are using the SQLiteQueryBuilder to actually build our query, instead of trying to manually put it together. Note that for more complex queries we can pass multiple tables as a comma-delimited String to queryBuilder.setTables()
in order to perform joins.
Deleting records
As with the query method a number of tests should be written to cover everything, and again we’ll simply go over the basics. The test:
@Test
public void givenASelectionIsProvidedWhenAReminderIsDeletedThenADeletionWillOccur() {
String testDescription = "test";
ContentValues values = new ReminderValuesBuilder().createDefaultReminderValues().withDescription(testDescription).build();
provider.insert(ReminderContract.Reminders.CONTENT_URI, values);
String selection = ReminderContract.Reminders.DESCRIPTION + " = ? ";
String[] selectionArgs = new String[] {testDescription};
int count = provider.delete(ReminderContract.Reminders.CONTENT_URI, selection, selectionArgs);
assertEquals(1, count);
}
Now the production code:
public class ReminderContentProvider extends ContentProvider {
private static final int REMINDER_LIST = 1;
private static final int REMINDER_ITEM_ID = 2;
private static final UriMatcher URI_MATCHER;
private static final String REMINDERS_URI_LIST_PATH = "reminders";
static {
URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
URI_MATCHER.addURI(ReminderContract.REMINDER_AUTHORITY, REMINDERS_URI_LIST_PATH, REMINDER_LIST);
URI_MATCHER.addURI(ReminderContract.REMINDER_AUTHORITY, "reminders/#", REMINDER_ITEM_ID);
}
//snip
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
SQLiteDatabase writableDatabase = reminderDBHelper.getWritableDatabase();
int deletedCount = 0;
switch(URI_MATCHER.match(uri))
{
case REMINDER_LIST:
deletedCount = writableDatabase.delete("reminders", selection, selectionArgs);
break;
case REMINDER_ITEM_ID:
String id = uri.getLastPathSegment();
String whereClause = ReminderContract.Reminders._ID + " = " + id;
deletedCount = writableDatabase.delete("reminders", whereClause, selectionArgs);
break;
}
if (deletedCount > 0) {
getContext().getContentResolver().notifyChange(uri, null);
}
return deletedCount;
}
}
There is a lot going on here, actually way more than the test actually called for. Trust me, the rest of this functionality was also test-driven just in case you’re wondering. The important things to note here are the usage of the URI_MATCHER, which controls whether or not a single record should be deleted or multiple, the actual deletion, and the returned deletionCount.
Configuration and Caveats
You should now have a pretty good idea of the basic operations for a ContentProvider. You’ve also seen how to use a ContentResolver and a Contract Class to access data via a ContentProvider. The last thing that we have to do is to add the required entries in the AndroidManifest.xml. Unfortunately we cannot test-drive this functionality; instead a functional test is required, as this will load the app in an emulator, where the AndroidManifest.xml is loaded. We haven’t touched on functional testing in this series yet, but my favored framework is Espresso, which I’ll try to cover in a later entry. For now, I’ll show you the entry to be added to the manifest file.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.groceryreminder">
<application
android:name=".injection.ReminderApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme" >
<!-- snip -->
<provider android:name=".data.ReminderContentProvider"
android:authorities="com.groceryreminder.data.ReminderContentProvider"
android:enabled="true"
android:exported="false">
</provider>
</application>
</manifest>
There are four things we must do here:
- Add the provider node.
- Set the
android:name
attribute, which must match the ContentProvider class name relative to the base package. - Set the
android:authorities
attribute. You might recognize that this matches exactly to the ReminderContract.REMINDER_AUTHORITY value we created above, and that this value is used for URI matching in theContentProvider.delete()
method. IMPORTANT: If there is a mismatch between the AUTHORITY values your ContentProvider will not be accessible from your application. - Set the android:enabled attribute to true, to allow the provider to be used at all.
This concludes the tutorial on implementing your own ContentProvider. As you’ve seen there is a lot that needs to happen here, but once you’ve implemented one or two of these you’ll quickly become familiar with the operations. You’ll also quickly see the benefits of creating this class after reading my next entry, which will cover implementing the LoaderManager Callback methods in your view classes.