Front-End Web & Mobile

Using a ContentProvider in Android

This is the third part in a six-part series on synchronizing data within an Android mobile app to the AWS Cloud.  Check out the full series:

In our previous post, we wrote about the role of the ContentProvider. The content provider is a custom data management object. This object provides access to your app and other parts of the Android system, such as widgets on the home screen and (probably most importantly) the sync framework that is built into the Android operating system.  We also walked through how to implement a SQLite database implementation for a sample notes app.  Today, we show you how to use that implementation.

Retrieving a single item

Each Activity within an Android application has a content resolver that can be returned using getContentResolver().  The content resolver determines the correct content provider based on the URI of the request and then routes the request to the appropriate content provider.

Each item within the content provider has a unique URI.  For the notes content provider, the URI is based on the authority, table name, and note ID.  Each URI has the form:

content://com.amazonaws.mobile.samples.notetaker.provider/notes/noteId

If you know the ID of the note you want to retrieve, you can obtain it by using the following:

/**
 * Loads the single item from the content provider
 * @param noteId the ID of the note
 */
private void loadData(String noteId) {
    Uri itemUri = NotesContentContract.Notes.uriBuilder(noteId);
    Cursor data = getContentResolver().query(itemUri, NotesContentContract.Notes.PROJECTION_ALL, null, null, null);
    if (data != null) {
        data.moveToFirst();
        mItem = NotesConverter.fromCursor(data);
        editTitle.setText(mItem.getTitle());
        editContent.setText(mItem.getContent());
    }
}

NotesConverter is a static class that contains routines to convert to and from the Note client-side model:

package com.amazonaws.mobile.samples.notetaker.models;

import android.content.ContentValues;
import android.database.Cursor;

import com.amazonaws.mobile.samples.notetaker.provider.NotesContentContract;

/**
 * Conversion routines to convert to/from the Note client-side model
 */
public class NotesConverter {
    public static Note fromCursor(Cursor c) {
        Note note = new Note();

        note.setId(getLong(c, NotesContentContract.Notes._ID, -1));
        note.setNoteId(getString(c, NotesContentContract.Notes.NOTEID, ""));
        note.setTitle(getString(c, NotesContentContract.Notes.TITLE, ""));
        note.setContent(getString(c, NotesContentContract.Notes.CONTENT, ""));
        note.setUpdated(getLong(c, NotesContentContract.Notes.UPDATED, 0));
        note.setIsDeleted(getBoolean(c, NotesContentContract.Notes.ISDELETED, false));
        note.setIsDirty(getBoolean(c, NotesContentContract.Notes.ISDIRTY, false));

        return note;
    }

    public static ContentValues toContentValues(Note note) {
        ContentValues values = new ContentValues();

        if (note.getId() >= 0) {
            values.put(NotesContentContract.Notes._ID, note.getId());
        }
        values.put(NotesContentContract.Notes.NOTEID, note.getNoteId());
        values.put(NotesContentContract.Notes.TITLE, note.getTitle());
        values.put(NotesContentContract.Notes.CONTENT, note.getContent());
        values.put(NotesContentContract.Notes.UPDATED, note.getUpdated());
        values.put(NotesContentContract.Notes.ISDELETED, note.isDeleted());
        values.put(NotesContentContract.Notes.ISDIRTY, note.isDirty());

        return values;
    }

    /**
     * Read a string from a key in the cursor
     *
     * @param c the cursor to read from
     * @param col the column key
     * @param defaultValue the default value if the column key does not exist in the cursor
     * @return the value of the key
     */
    private static String getString(Cursor c, String col, String defaultValue) {
        if (c.getColumnIndex(col) >= 0) {
            return c.getString(c.getColumnIndex(col));
        } else {
            return defaultValue;
        }
    }

    /**
     * Read a long value from a key in the cursor
     *
     * @param c the cursor to read from
     * @param col the column key
     * @param defaultValue the default value if the column key does not exist in the cursor
     * @return the value of the key
     */
    private static long getLong(Cursor c, String col, long defaultValue) {
        if (c.getColumnIndex(col) >= 0) {
            return c.getLong(c.getColumnIndex(col));
        } else {
            return defaultValue;
        }
    }

    /**
     * Read a boolean value from a key in the cursor
     *
     * @param c the cursor to read from
     * @param col the column key
     * @param defaultValue the default value if the column key does not exist in the cursor
     * @return the value of the key
     */
    private static boolean getBoolean(Cursor c, String col, boolean defaultValue) {
        if (c.getColumnIndex(col) >= 0) {
            return c.getInt(c.getColumnIndex(col)) != 0;
        } else {
            return defaultValue;
        }
    }
}

The main thing you need to do when receiving data from the content provider is to convert from data stored in a Cursor to the Note.  When writing to the content provider, you need to convert from the Note to the ContentValues object.

Retrieving multiple items

The ContentProvider.query() method takes two parameters that are used to construct the SQLite query – selection and selectionArgs. The selection argument is a string that looks like the WHERE clause of a standard SQLite SELECT statement. However, you can use the question mark to designate arguments. These arguments are listed (in order) in the selectionArgs string array. For example, if you call:

String selection = “isComplete = ?”;
String[] selectionArgs = { “0” };
String sortOrder = “noteId ASC”;
Cursor cursor = contentResolver().query(
    tableUri, 
    PROJECTION_ALL, 
    selection, selectionArgs, 
    sortOrder); 

The eventual SQLite call is something like:

SELECT * FROM notes WHERE isComplete = 0 ORDER BY noteId ASC

You can then page through the data using the standard Cursor methods available in the android.database package.

Using the Loaders Framework

You want to bulk load data to a RecyclerView or ListView object. The best way to do this is to use the built-in Loaders Framework to load data into the activity. The Loaders Framework loads the data asynchronously and then uses callbacks when the loading is complete. Loaders run on separate threads to prevent an unresponsive UI and they cache the results to prevent duplicate queries. In addition, they can automatically register an observer to trigger a reload only when the data changes. The Loaders Framework implements a standard CursorLoader that has this type of observer, which is ideal for use with content providers. Note that we implemented a notifier when writing the content provider in the last article. This notifier works with the observer in the CursorLoader.

To implement a Loader:

  1. Implement the LoaderManager.LoaderCallbacks interface.
  2. Initialize the loader in the activity onCreate() method.

To add the LoaderManager interface, adjust the definition of the class:

public class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor> {
    private static int NOTES_LOADER_ASYNC_TASK = 10001;

    /* BEGIN: LOADER INTERFACE */
    /**
     * When initLoader() is called, this method is called on the asynchronous thread to
     * initialize the loader and kick-off the data process.  This version calls the
     * CursorLoader which, in turn, does the specified query on the defined content
     * provider.  We are fetching all fields (PROJECTION_ALL) or all fields (null, null for the
     * selection)
     * @param id
     * @param args
     * @return
     */
    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        return new CursorLoader(this,
            NotesContentContract.Notes.CONTENT_URI,
            NotesContentContract.Notes.PROJECTION_ALL,
            null,
            null,
            NotesContentContract.Notes.SORT_ORDER_DEFAULT);
    }

    /**
     * This is called on the UI thread when the loader has finished loading data.
     * @param loader
     * @param data
     */
    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        ((NotesAdapter) notesList.getAdapter()).swapCursor(data);
    }

    /**
     * This is called on the UI thread when the loader is reset (indicating that we
     * should remove all data)
     * @param loader
     */
    @Override    
    public void onLoaderReset(Loader<Cursor> loader) {
        ((NotesAdapter) notesList.getAdapter()).swapCursor(null);
    }
    /* END: LOADER INTERFACE */

}

To initialize the loader, add the following to the end of the onCreate() method:

// Initialize the data loader
getLoaderManager().initLoader(NOTES_LOADER_ASYNC_TASK, null, this);

When the loader is initialized, a new asynchronous task is added to the application. This immediately calls onCreateLoader() to create the loader.  This method creates a CursorLoader.  The CursorLoader calls the equivalent query() method in the content provider with the same arguments.  When the data is available, onLoaderFinished() is called on the UI thread with the new cursor.  In this case, you use this to replace the cursor in a RecyclerView.Adapter interface, which then refreshes the data within the list.

Inserting, updating, and deleting items

You should always call mutations asynchronously so that they do not cause unresponsive UI. Fortunately, this is a common requirement, and the content provider interface has already done the majority of the implementation. It’s considered good practice to abstract the call into its own method. For example, we use three mutations within the Notes tutorial – insert, update, and delete:

private static final int DELETE_NOTE_ASYNC_TASK = 10002;
private static final int INSERT_NOTE_ASYNC_TASK = 10003;
private static final int UPDATE_NOTE_ASYNC_TASK = 10004;

void insertItemAsync(final Note note) {
    AsyncQueryHandler handler = new AsyncQueryHandler(context.getContentResolver()) {
        @Override
        protected void onInsertComplete(int token, Object cookie, Uri uri) {
            super.onInsertComplete(token, cookie, uri);
        }
    };
    final ContentValues values = NotesConverter.toContentValues(note);
    handler.startInsert(INSERT_NOTE_ASYNC_TASK, note, NotesContentContract.Notes.CONTENT_URI, values);
}

void removeItemAsync(final Note note, final int position) {
    AsyncQueryHandler handler = new AsyncQueryHandler(context.getContentResolver()) {
        @Override
        protected void onDeleteComplete(int token, Object cookie, int result) {
            super.onDeleteComplete(token, cookie, result);
            notifyItemRemoved(position);
        }
    };
    Uri itemUri = NotesContentContract.Notes.uriBuilder(note);
    handler.startDelete(DELETE_NOTE_ASYNC_TASK, note, itemUri, null, null);
}

void updateItemAsync(final Note note) {
    AsyncQueryHandler handler = new AsyncQueryHandler(context.getContentResolver()) {
        @Override
        protected void onUpdateComplete(int token, Object cookie, int result) {
            super.onUpdateComplete(token, cookie, result);
        }
    };
    Uri itemUri = NotesContentContract.Notes.uriBuilder(note);
    final ContentValues values = NotesConverter.toContentValues(note);
    handler.startUpdate(UPDATE_NOTE_ASYNC_TASK, note, itemUri, values, null, null);
}

The AsyncQueryHandler provides the appropriate callbacks for each method.  There is an equivalent handler.startNNN  method. This method takes an async task ID (that you provide), a cookie (which is passed along to the callback when the async task is complete), and then the same arguments as the call to the content provider.  When you call the startNNN  method, it creates an async task, calls the content provider, and then calls the callback with the results.

If your underlying database is SQLite, then you do not need to use AsyncQueryHandler because the SQLite database already uses async callbacks for you. However, you should always use AsyncQueryHandler as a best practice in case you want to change the underlying implementation from SQLite to something else (such as Couchbase Lite or an online data store).

Wrap up

In this series so far, we’ve provided an overview of the MyNotes sample code, which is available in the GitHub repository. We’ve also shown how to create a content provider for your application. In the next post, we start to implement a sync adapter by adding an Amazon Cognito authentication source to the built-in Android Account Manager.