Front-End Web & Mobile
Building a ContentProvider for Android
This is the second part in a six-part series on synchronizing data within an Android mobile app to the AWS Cloud. Check out the full series:
- An Introduction to the Sync Framework for Android
- Building a ContentProvider for Android (this article)
- Using a ContentProvider in Android Mobile Apps
- Integrating Amazon Cognito with the Android AccountManager API
- Building a Synchronization Endpoint with AWS Mobile Hub
- Building a Synchronization Framework for Android
In the previous post, we described data management in Android and the role of the content provider and sync framework in building cloud-connected data-driven applications. In this article, we delve into the content provider in some detail. Building a content provider is a multi-step process:
- Create a model for handling the data.
- Create a ContentContract that defines the content provider constants.
- Create a ContentProvider to access the data.
- Define the content provider within the application manifest.
In this post, we show how to write a content provider for a simple list of notes. The long-term goal is to synchronize that data to the cloud. You can also use tasks, news stories, comments, or any other data type. It is possible to use multiple data types within the same content provider.
Create a model
When building the class for your model, you need to think about the eventual use case. Since this model is going to be synchronized, there are additional fields to store on the client side. The server-side model needs to be multi-user, but that information does not need to be transferred to the client side. Here are the two models:
Most fields are transferred complete. There are three special cases:
- isDirty only exists on the client side and is used to indicate that the client version of the record needs to be transferred to the server.
- userId only exists on the server side. The current user owns all the records on the client, so it is not needed. When submitting records on the server, you tag each record with the userId.
- isDeleted is different on the server and client. When isDeleted is set on the client, that value is sent to the server. When isDeleted is received from the server, the record is removed from the client.
Of the other fields, noteId is a globally unique identifier (GUID), updated is a time stamp (represented in UTC by the number of milliseconds since the epoch). Title and content are used by the UI of the application.
It is common for the client and server models to differ. They are supporting single-user vs. multi-user and support different use cases at the query and mutation level. When developing the model for the application, ensure you can read and write JSON data on each side. The transfer to the server uses JSON formatted data, so handling this now is a good idea. You can use the gson library to perform the conversion. Add the following to your app/build.gradle in the dependencies section:
The former does the JSON conversion and the latter provides utility methods for building checksum or hash code functions that you need to generate models. You can auto-generate a template using the jsonschema2pojo website. This allows you to convert a JSON object into the model library. Ensure you specify Gson annotation style and use primitive types. You need to tweak the model afterwards. Here is the completed version:
package com.amazonaws.mobile.samples.notetaker.models;
import com.google.gson.Gson;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import java.util.UUID;
/**
* Client-side model for a Note
*/
public class Note {
private long id = -1; // The internal ID of the record (required by SQLite)
private boolean mIsDirty; // Not exposed to the server, so no annotations
@SerializedName("noteId") @Expose private String mNoteId;
@SerializedName("updated") @Expose private long mUpdated;
@SerializedName("title") @Expose private String mTitle;
@SerializedName("content") @Expose private String mContent;
@SerializedName("isDeleted") @Expose private boolean mIsDeleted;
public Note() {
setNoteId(UUID.randomUUID().toString());
setTitle("");
setContent("");
setUpdated(0L);
setIsDeleted(false);
}
public long getId() { return id; }
public void setId(long id) {
this.id = id;
}
public boolean isDirty() {
return mIsDirty;
}
public void setIsDirty(boolean mIsDirty) {
this.mIsDirty = mIsDirty;
}
public String getNoteId() {
return mNoteId;
}
public void setNoteId(String mNoteId) {
this.mNoteId = mNoteId;
}
public long getUpdated() {
return mUpdated;
}
public void setUpdated(long mUpdated) {
this.mUpdated = mUpdated;
}
public String getTitle() {
return mTitle;
}
public void setTitle(String mTitle) {
this.mTitle = mTitle;
}
public String getContent() {
return mContent;
}
public void setContent(String mContent) {
this.mContent = mContent;
}
public boolean isDeleted() {
return mIsDeleted;
}
public void setIsDeleted(boolean mIsDeleted) {
this.mIsDeleted = mIsDeleted;
}
@Override
public String toString() {
return new Gson().toJson(this);
}
@Override
public int hashCode() {
return new HashCodeBuilder()
.append(mNoteId)
.append(mTitle)
.append(mContent)
.append(mUpdated)
.build();
}
@Override
public boolean equals(Object other) {
if (other == this) { return true; }
if (!(other instanceof Note)) { return false; }
Note rhs = (Note) other;
return new EqualsBuilder()
.append(mNoteId, rhs.getNoteId())
.append(mTitle, rhs.getTitle())
.append(mContent, rhs.getContent())
.append(mUpdated, rhs.getUpdated())
.isEquals();
}
}
All of the fields that you send to the server have a SerializedName and an Expose annotation. The isDirty field does not have these annotations because it is not exposed to the server. We also use two helper methods from the Apache Commons Lang library for handling hash codes and equality. You only use them to verify that the critical fields are equal. The isDeleted and isDirty fields are not germane to that decision so they are left off the comparison. The id field is not used by the app but is required by the SQLite implementation of the content provider, so it is necessary in the client-side model.
Create a content contract
The next step is to create a content contract. The content contract is a series of constants and methods that allow the application to effectively use the content provider. They provide the base URI of the tables and the representation of the tables in the database. First, you define how the provider is referenced. The URI is something like the following:
content://com.amazonaws.mobile.samples.notetaker.provider/notes[/noteId]
The com.amazonaws.mobile.samples.notetaker.provider is called the Authority and it is a unique value (across all applications on the device) that represents the dataset. Because the value is unique across all applications on the device and you cannot know which applications are on the device, this value is effectively unique in the universe. Although not required, it is normal to make it look like your java package name. Minimally, your content contract must contain the authority and content URI:
package com.amazonaws.mobile.samples.notetaker.provider;
import android.net.Uri;
/**
* Definition of the contract between the app and the content provider
*/
public class NotesContentContract {
/**
* The authority of the notes content provider - this must match the authorities field
* specified in the AndroidManifest.xml provider section
*/
public static final String AUTHORITY = "com.amazonaws.mobile.samples.notetaker.provider";
/**
* The content URI for the top-level content provider
*/
public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY);
}
In addition, provide an inner class per table for each data type. In this case, we only have one model, so we only have one inner class. The inner class must specify:
- The table name
- The field names
- The URI forms that are acceptable for the table
- How to construct a URI from an ID
In this example, the table name (within SQLite) is notes and the field names are the names of the fields in the client-side model. Because this is a SQLite implementation, you must have an id field that is an auto-incrementing integer. This is not used by the application and is an artifact of the database.
/**
* Constants for the Notes table
*/
public static final class Notes implements BaseColumns {
/**
* The table name within SQLite
*/
public static final String TABLE_NAME = "notes";
/**
* The fields that make up the SQLite table
*/
public static final String _ID = "id";
public static final String NOTEID = "noteId";
public static final String TITLE = "title";
public static final String CONTENT = "content";
public static final String UPDATED = "updated";
public static final String ISDELETED = "isDeleted";
public static final String ISDIRTY = "isDirty";
/**
* The URI base-path for the table
*/
public static final String DIR_BASEPATH = "notes";
/**
* The URI base-path for a single item. Note the wild-card * to represent
* any string (so we can specify a noteId within the content URI)
*/
public static final String ITEM_BASEPATH = "notes/*";
/**
* The content URI for this table
*/
public static final Uri CONTENT_URI = Uri.withAppendedPath(NotesContentContract.CONTENT_URI, TABLE_NAME);
/**
* The MIME type for the response for all items within a table
*/
static final String BASE_TYPE = "/vnd.com.amazonaws.mobile.samples.notetaker.models.";
public static final String CONTENT_DIR_TYPE = ContentResolver.CURSOR_DIR_BASE_TYPE + BASE_TYPE + TABLE_NAME;
/**
* The MIME type for a single item within the table
*/
public static final String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + BASE_TYPE + TABLE_NAME;
/**
* Method to build a Uri based on the NoteId
* @param noteId the Id of the note
* @return the Uri of the note
*/
public static Uri uriBuilder(String noteId) {
return Uri.withAppendedPath(CONTENT_URI, noteId);
}
/**
* Method to build a Uri based on a note
* @param note the note
* @return the Uri of the note
*/
public static Uri uriBuilder(Note note) {
return Uri.withAppendedPath(CONTENT_URI, note.getNoteId());
}
/**
* A projection of all columns in the table
*/
public static final String[] PROJECTION_ALL = {
_ID, NOTEID, TITLE, CONTENT, UPDATED, ISDELETED, ISDIRTY
};
/**
* The SQLite CREATE TABLE statement
*/
public static final String CREATE_SQLITE_TABLE =
"CREATE TABLE " + TABLE_NAME + " ("
+ _ID + " INTEGER PRIMARY KEY, "
+ NOTEID + " TEXT UNIQUE NOT NULL, "
+ TITLE + " TEXT NOT NULL DEFAULT '', "
+ CONTENT + " TEXT NOT NULL DEFAULT '', "
+ UPDATED + " BIGINT NOT NULL DEFAULT 0, "
+ ISDELETED + " BOOLEAN DEFAULT 0, "
+ ISDIRTY + " BOOLEAN DEFAULT 0)";
/**
* The default sort order (in SQLite notation)
*/
public static final String SORT_ORDER_DEFAULT = _ID + " ASC";
}
Create a content provider
This content provider is based on the SQLite database. The Android system provides assistance for SQLite databases so this is the path of least resistance. You may, however, need to switch to a NoSQL offline cache, such as Couchbase Lite, if your data model does not fit into a relational database model. This takes more work because you need to deal with the queries yourself. First, create a database helper:
package com.amazonaws.mobile.samples.notetaker.provider;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
/**
* Android has a base class for dealing with SQLite databases called SQLiteOpenHelper.
* This is a derived class that sets up the database on first access.
*/
public class DatabaseHelper extends SQLiteOpenHelper {
private static final String DBNAME = "application.db";
private static final int DBVERSION = 1;
DatabaseHelper(Context context) {
super(context, DBNAME, null, DBVERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(NotesContentContract.Notes.CREATE_SQLITE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.d("DatabaseHelper", String.format("Database requires upgrade from %d to %d", oldVersion, newVersion));
}
}
When DatabaseHelper is created, it creates a database if needed, calling onCreate() to set up the database structure. It also stores a database version. If the database version changes (for example, you add a new field), increase DBVERSION in DatabaseHelper. The database helper then calls onUpgrade() and you can use db.execSQL() to adjust the database by using SQL statements. With this class, the effort of maintaining the database connection and maintaining the schema is done for you.
The content provider itself must implement six methods:
- onCreate() is called when the content provider is first accessed.
- getType() returns the MIME type of a provided URI.
- query(), insert(), delete(), and update() are called for CRUD operations on the provider.
The standard form for the content provider is as follows:
package com.amazonaws.mobile.samples.notetaker.provider;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
/**
* Implementation of the content provider for notes
*/
public class NotesContentProvider extends ContentProvider {
private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private static final int ALL_NOTES = 1;
private static final int ONE_NOTE = 2;
static {
sUriMatcher.addURI(
NotesContentContract.AUTHORITY,
NotesContentContract.Notes.DIR_BASEPATH,
ALL_NOTES);
sUriMatcher.addURI(
NotesContentContract.AUTHORITY,
NotesContentContract.Notes.ITEM_BASEPATH,
ONE_NOTE);
}
private DatabaseHelper databaseHelper;
@Override
public boolean onCreate() {
databaseHelper = new DatabaseHelper(getContext());
return true;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return null;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
return null;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
return 0;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
return 0;
}
}
We didn’t fill in the actual methods yet, except for onCreate(), which initializes the database helper and creates the database if required. Note the URI matcher. When you call the URI matcher with a URI, it converts the matched URI to an integer. You can then use this in a switch statement, which makes most of the code easier to understand. For example, here is the getType() method:
@Nullable
@Override
public String getType(@NonNull Uri uri) {
int uriType = sUriMatcher.match(uri);
switch (uriType) {
case ALL_NOTES:
return NotesContentContract.Notes.CONTENT_DIR_TYPE;
case ONE_NOTE:
return NotesContentContract.Notes.CONTENT_ITEM_TYPE;
default:
return null;
}
}
Without the URI matcher, we would have to deconstruct the URI to ensure that the URI was handled by this content provider. We also would be required to use a regular expression parse on each type that we handle. The URI matcher makes this much easier.
The three mutation methods write to the database using standard SQLite routines. Think of these as REST endpoints. To insert a new item, you POST to the directory URI. To update an entry, you POST (to replace) or DELETE to the item URI.
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
int uriType = sUriMatcher.match(uri);
switch (uriType) {
case ALL_NOTES:
SQLiteDatabase db = databaseHelper.getWritableDatabase();
long id = db.insert(NotesContentContract.Notes.TABLE_NAME, null, values);
if (id > 0) {
String noteId = values.getAsString(NotesContentContract.Notes.NOTEID);
Uri item = NotesContentContract.Notes.uriBuilder(noteId);
notifyAllListeners(item);
return item;
}
throw new SQLException(String.format(Locale.US, "Error inserting for URI %s - id = %d", uri, id));
default:
throw new IllegalArgumentException("Unsupported URI: " + uri);
}
}
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
int uriType = sUriMatcher.match(uri);
String where;
switch (uriType) {
case ALL_NOTES:
where = selection;
break;
case ONE_NOTE:
where = String.format("%s = \"%s\"",
NotesContentContract.Notes.NOTEID, uri.getLastPathSegment());
if (!TextUtils.isEmpty(selection)) {
where += " AND (" + selection + ")";
}
break;
default:
throw new IllegalArgumentException("Unsupported URI: " + uri);
}
SQLiteDatabase db = databaseHelper.getWritableDatabase();
int rows = db.delete(NotesContentContract.Notes.TABLE_NAME, where, selectionArgs);
if (rows > 0) {
notifyAllListeners(uri);
}
return rows;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
int uriType = sUriMatcher.match(uri);
String where;
switch (uriType) {
case ALL_NOTES:
where = selection;
break;
case ONE_NOTE:
where = String.format("%s = \"%s\"",
NotesContentContract.Notes.NOTEID, uri.getLastPathSegment());
if (!TextUtils.isEmpty(selection)) {
where += " AND (" + selection + ")";
}
break;
default:
throw new IllegalArgumentException("Unsupported URI: " + uri);
}
SQLiteDatabase db = databaseHelper.getWritableDatabase();
int rows = db.update(NotesContentContract.Notes.TABLE_NAME, values, where, selectionArgs);
if (rows > 0) {
notifyAllListeners(uri);
}
return rows;
}
private void notifyAllListeners(Uri uri) {
ContentResolver resolver = getContext().getContentResolver();
if (resolver != null) {
resolver.notifyChange(uri, null);
}
}
When you update or delete one item, you prepend a search for the task ID to the SQL query. Other applications (including widgets and the sync framework) can listen to the content resolver for changes. If the database changes, notify any listeners that the data has changed. Listeners can then act on that change, by updating the widget or immediately pushing the change to the server, for example. The content provider enables you to update or delete multiple records. For example, you might want to delete all records that are marked for deletion, or update all incomplete tasks to be completed. However, inserts are handled individually.
That leaves the query portion. The query takes a SQL query and returns a Cursor that can be used to load the data into the UI. The whole process is driven by the UI. This is well suited for a UI-driven application like a mobile app.
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
int uriType = sUriMatcher.match(uri);
SQLiteDatabase db = databaseHelper.getReadableDatabase();
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
switch (uriType) {
case ALL_NOTES:
queryBuilder.setTables(NotesContentContract.Notes.TABLE_NAME);
if (TextUtils.isEmpty(sortOrder)) {
sortOrder = NotesContentContract.Notes.SORT_ORDER_DEFAULT;
}
break;
case ONE_NOTE:
String where = String.format("%s = \"%s\"", NotesContentContract.Notes.NOTEID, uri.getLastPathSegment());
queryBuilder.setTables(NotesContentContract.Notes.TABLE_NAME);
queryBuilder.appendWhere(where);
break;
default:
throw new IllegalArgumentException("Unsupported URI: " + uri);
}
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
Again, the SQLite database is helpful here. You can move directly from the arguments that are fed to the content provider query() method to the database query itself. Everything happens naturally from this. A different database provider would require building your own query, which is likely to be more complex code within the content provider. However, the changes to support a different local database are confined to the content provider. This application does not need to know about the changes to the underlying database.
Register the content provider
Before the app can use the content provider, it must be registered in the AndroidManifest.xml file. This is defined by a <provider> element that must be located with the <application> element for the Android application:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.amazonaws.mobile.samples.tasklist">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name=".provider.TaskListContentProvider"
android:authorities="com.amazonaws.mobile.samples.tasklist.provider"
android:label="TaskListProvider" />
</application>
</manifest>
The android:name field for the provider points to the class. If the class starts with a period, then it is relative to the package referenced in the manifest. The android:authorities field must match the AUTHORITY field in the content contract. The label is required but can be anything you like because it is not displayed anywhere.
Wrap up
We’ve only just started to investigate the content provider, and the code doesn’t run yet! In the next post, we use this content provider to produce a simple task list app and show how to call this content provider (or any other content provider) from your code. If you can’t wait until the next article, the content provider was taken from our Notes tutorial code that is published on GitHub.