Android Notes App: Content Providers

In my last blog post, I introduced a new app I am working on. It’s a typical notes app, but I’m writing it to use all the best practices and learn a lot of the internals of Android app writing. Todays lesson is basically “how do I access data”. My notes app has a Note class that defines the object that I am showing off in my app.

You can, of course, generate a large number of AsyncTasks to handle the data loading, but it seemed to me that there should be a better way of dealing with data. After all, pretty much all the apps in the app store are data driven in one way or another. Indeed, there is, and it’s called the ContentProvider. A Content Provider is a special class that allows you to do CRUD operations on a data source. Since you are doing the implementation, it can be any data source. However, the content provider API is optimized for SQLite (and SQL tables in general). About the only requirement is that it needs to be organized in rows and have a certain restricted data set. Content Providers are a necessity when doing certain things within Android – sharing or synchronizing data, for example. The nice thing about them, however, is that the Content Provider framework deals with all the threading for you. That means you can concentrate on the writing of the app.

In this blog, I’m going to write a Content Provider for the Note model that uses a SQLite instance to store the data.

The Android Developers Guide has some good docs on creating a ContentProvider, so I’m not going to go into a great level of detail about some of the items:

  • The authority will be defined as a constant called AUTHORITY equal to com.shellmonger.notes.provider.
  • The notes table will have a path of com.shellmonger.notes.provider/notes.
  • Each row in my table will have a numeric ID field called _ID, which can be accessed through com.shellmonger.notes.provider/notes/_ID. where _ID will be replaced by the row number.

This last point is important. Internally, my rows are going to have an auto-incrementing row number. Externally, they have a GUID. I have to support both.

If I follow the Android Developers Guide advice, I’m going to need a bunch of classes:

  • My Note class will need some updates to support the internal ID and conversion from the Note object to the internal format used by content providers.
  • I will need a database helper class to provide access to the SQLite database.
  • I need to define a contract for the content provider that includes information about the tables that are supported.
  • I need to write the content provider itself.
  • Finally, I’m going to add the content provider to the project in the AndroidManifest.xml.

Once I’ve done all this, I can use it in my app. This is going to be a lot of code; just imagine how much it would be without a framework! You would have to deal with all the same stuff, but threading and async tasks in addition.

Let’s start with the contract class. There is a bunch of information I need about each table in the content provider – the columns that it supports, the types of each record, and the SQLite statement I need to perform to create the table. Here is the class:

package com.shellmonger.notes.data;

import android.content.ContentResolver;
import android.net.Uri;
import android.provider.BaseColumns;

/**
 * Per the official Android documentation, this class defines all publically available
 * elements, like the authority, the content URIs, columns, and content types for each
 * element
 */
public class NotesContentContract {
    /**
     * The authority of the notes content provider
     */
    public static final String AUTHORITY = "com.shellmonger.notes.provider";

    /**
     * The content URI for the top-level notes authority
     */
    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY);

    /**
     * Constants for the Notes table
     */
    public static final class Notes implements BaseColumns {
        /**
         * The Table Name
         */
        public static final String TABLE_NAME = "notes";

        /**
         * The noteId field
         */
        public static final String NOTEID = "noteId";

        /**
         * The title field
         */
        public static final String TITLE = "title";

        /**
         * The content field
         */
        public static final String CONTENT = "content";

        /**
         * The created field
         */
        public static final String CREATED = "created";

        /**
         * The updated field
         */
        public static final String UPDATED = "updated";

        /**
         * The directory base-path
         */
        public static final String DIR_BASEPATH = "notes";

        /**
         * The items base-path
         */
        public static final String ITEM_BASEPATH = "notes/#";

        /**
         * The SQLite database command to create the table
         */
        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 '', "
                + CREATED + " BIGINT NOT NULL DEFAULT 0, "
                + UPDATED + " BIGINT NOT NULL DEFAULT 0)";

        /**
         * The content URI for this table
         */
        public static final Uri CONTENT_URI =
                Uri.withAppendedPath(NotesContentContract.CONTENT_URI, TABLE_NAME);

        /**
         * The mime type of a directory of items
         */
        public static final String CONTENT_DIR_TYPE =
                ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd.com.shellmonger.notes";

        /**
         * The mime type of a single item
         */
        public static final String CONTENT_ITEM_TYPE =
                ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.com.shellmonger.notes";

        /**
         * A projection of all columns in the items table
         */
        public static final String[] PROJECTION_ALL = {
                _ID,
                NOTEID,
                TITLE,
                CONTENT,
                CREATED,
                UPDATED
        };

        /**
         * The default sort order (SQLite syntax)
         */
        public static final String SORT_ORDER_DEFAULT = CREATED + " ASC";
    }
}

You should create an inner class for each table you need to support. I only support one table right now, so that’s all I need to create. This is all constants needed elsewhere. One important one is the _ID field. This must be present, otherwise the CursorAdapter will not work properly. It’s a common design pattern to use a CursorAdapter with a ContentProvider because queries across a ContentProvider produce a Cursor.

Now that I’ve got the contract, I can turn my attention to the Note model. I’ve added a new field for the internal ID. I’ve also added two new methods:

  • static Note fromCursor(cursor);
  • ContentValues toContentValues();

The Content Provider and the SQLite database both use the ContentValues object to pass data back and forth. The ContentValues object is basically a Map object – it has get/put values. However, unlike a normal Map, the value of the ContentValues map can be any number of types – strings, booleans, numbers, etc. One thing to note is that the list of types is restricted. The DateTime type, for example, is not included in the list. The ContentValues are embedded in a Cursor when returning data, and ContentValues when setting values. Here is the new Note class:

package com.shellmonger.notes.data;

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

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;

import java.io.PrintWriter;
import java.util.UUID;

/**
 * The Note model
 */
public class Note {
    private long id = -1;
    private String noteId;
    private String title;
    private String content;
    private DateTime created;
    private DateTime updated;

    /**
     * Create a new Note from a cursor
     *
     * @param c the cursor
     * @return the note
     */
    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.setCreated(new DateTime(getLong(c, NotesContentContract.Notes.CREATED, 0), DateTimeZone.UTC));
        note.setUpdated(new DateTime(getLong(c, NotesContentContract.Notes.UPDATED, 0), DateTimeZone.UTC));

        return note;
    }

    private static String getString(Cursor c, String col, String defaultValue) {
        if (c.getColumnIndex(col) >= 0) {
            return c.getString(c.getColumnIndex(col));
        } else {
            return defaultValue;
        }
    }

    private static long getLong(Cursor c, String col, long defaultValue) {
        if (c.getColumnIndex(col) >= 0) {
            return c.getLong(c.getColumnIndex(col));
        } else {
            return defaultValue;
        }
    }

    /**
     * Create a new blank note
     */
    public Note() {
        setNoteId(UUID.randomUUID().toString());
        setTitle("");
        setContent("");
        setCreated(DateTime.now(DateTimeZone.UTC));
        setUpdated(DateTime.now(DateTimeZone.UTC));
    }

    /**
     * Create a new note with a specific title and content
     * @param title the title
     * @param content the content
     */
    public Note(String title, String content) {
        setNoteId(UUID.randomUUID().toString());
        setTitle(title);
        setContent(content);
        setCreated(DateTime.now(DateTimeZone.UTC));
        setUpdated(DateTime.now(DateTimeZone.UTC));
    }

    /**
     * Returns the internal ID
     * @return the internal ID
     */
    public long getId() { return id; }

    /**
     * Sets the internal ID
     * @param id the new internal ID
     */
    public void setId(long id) { this.id = id;}

    /**
     * Returns the noteId
     * @return the note ID
     */
    public String getNoteId() { return noteId; }

    /**
     * Sets the noteId
     * @param noteId the new note ID
     */
    public void setNoteId(String noteId) {
        this.noteId = noteId;
    }

    /**
     * Returns the title
     * @return the title
     */
    public String getTitle() { return title; }

    /**
     * Sets the title
     * @param title the new title
     */
    public void setTitle(String title) {
        this.title = title;
    }

    /**
     * Returns the note content
     * @return the note content
     */
    public String getContent() { return content; }

    /**
     * Sets the note content
     * @param content the note content
     */
    public void setContent(String content) {
        this.content = content;
    }

    /**
     * Returns the created date
     * @return the created date
     */
    public DateTime getCreated() { return created; }

    /**
     * Sets the created date
     * @param created the created date
     */
    public void setCreated(DateTime created) {
        this.created = created;
    }

    /**
     * Returns the last updated date
     * @return the last updated date
     */
    public DateTime getUpdated() { return updated; }

    /**
     * Sets the last updated date
     * @param updatedDate the new last updated date
     */
    public void setUpdated(DateTime updatedDate) {
        this.updated = updatedDate;
    }

    /**
     * Updates the note
     * @param title the new title
     * @param content the new content
     */
    public void updateNote(String title, String content) {
        setTitle(title);
        setContent(content);
        setUpdated(DateTime.now(DateTimeZone.UTC));
    }

    /**
     * The string version of the class
     * @return the class unique descriptor
     */
    @Override
    public String toString() {
        return String.format("[note#%s] %s", noteId, title);
    }

    /**
     * Return the contentvalues for this record
     */
    public ContentValues toContentValues() {
        ContentValues values = new ContentValues();
        values.put(NotesContentContract.Notes.NOTEID, noteId);
        values.put(NotesContentContract.Notes.TITLE, title);
        values.put(NotesContentContract.Notes.CONTENT, content);
        values.put(NotesContentContract.Notes.CREATED, created.toDateTime(DateTimeZone.UTC).getMillis());
        values.put(NotesContentContract.Notes.UPDATED, updated.toDateTime(DateTimeZone.UTC).getMillis());
        return values;
    }
}

When inserting the dates into the database, they are stored as “milliseconds since the epoch in UTC”. Note that the toContentValues() method does not add the id field into the ContentValues object. This is automatically assigned by the SQLite database.

The next step is to create the database helper. Android uses an abstract class for SQLite called SQLiteOpenHelper. This abstracts away most of the complexity of dealing with SQLite. All I have to do is ensure the correct tables are created on first run:

package com.shellmonger.notes.data;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

/**
 * The SQLiteOpenHelper implementation for the Notes database
 */
class DatabaseHelper extends SQLiteOpenHelper {
    private static final String DBNAME = "notes.db";
    private static final int DBVERSION = 1;

    /**
     * Create a new SQLiteOpenHelper object for this database.
     * @param context the application context
     */
    public DatabaseHelper(Context context) {
        super(context, DBNAME, null, DBVERSION);
    }

    /**
     * Called when the database needs to be created
     * @param db the database handle
     */
    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(NotesContentContract.Notes.CREATE_SQLITE_TABLE);
    }

    /**
     * Called when the database needs to be updated
     * @param db the database handle
     * @param oldVersion the old database version
     * @param newVersion the new database version
     */
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // Don't do anything here - we don't support upgrade yet.
    }
}

Note that database upgrades are natively supported. When the schema of your database changes, you need to do two things:

  1. Update the DBVERSION to the next integer.
  2. Add some code in the onUpgrade() method to upgrade the database schema.

This second point is important. SQLite supports things like ALTER TABLE, for example. It’s almost always better to adjust the schema of the tables than to wipe them out and re-image them.

Finally, we can work on the content provider. Yes, all that code was just in support of the content provider. There are three things you need to implement for the content provider:

  • onCreate() – called when the content provider is instantiated.
  • getType() – called to convert a Uri into a type.
  • CRUD operations (query, insert, update, delete).

Content Providers deal with Uri’s. An item has a URI and the table (which is really a collection of items) has a URI. Whenever you send a request to a content provider, you send along the URI of the item or items you want to deal with. Think the HTTP protocol, but with a different protocol (instead of http/https, you have content). Just like the HTTP protocol, each item and collection of items has a type that is returned. The HTTP protocol deals with MIME types like text/html. The Content protocol deals with more complex types.

Let’s look at the basics for the content provider:

package com.shellmonger.notes.data;

import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;

/**
 * The Content Provider for the internal Notes database
 */
public class NotesContentProvider extends ContentProvider {
    /**
     * Creates a UriMatcher for matching the path elements for this content provider
     */
    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

    /**
     * The code for the UriMatch matching all notes
     */
    private static final int ALL_ITEMS = 10;

    /**
     * The code for the UriMatch matching a single note
     */
    private static final int ONE_ITEM = 20;

    /**
     * The database helper for this content provider
      */
    private DatabaseHelper databaseHelper;

    /*
     * Initialize the UriMatcher with the URIs that this content provider handles
     */
    static {
        sUriMatcher.addURI(
                NotesContentContract.AUTHORITY,
                NotesContentContract.Notes.DIR_BASEPATH,
                ALL_ITEMS);
        sUriMatcher.addURI(
                NotesContentContract.AUTHORITY,
                NotesContentContract.Notes.ITEM_BASEPATH,
                ONE_ITEM);
    }

    /**
     * Part of the Content Provider interface.  The system calls onCreate() when it starts up
     * the provider.  You should only perform fast-running initialization tasks in this method.
     * Defer database creation and data loading until the provider actually receives a request
     * for the data.  This runs on the UI thread.
     *
     * @return true if the provider was successfully loaded; false otherwise
     */
    @Override
    public boolean onCreate() {
        databaseHelper = new DatabaseHelper(getContext());
        return true;
    }

    /**
     * The content provider must return the content type for its supported URIs.  The supported
     * URIs are defined in the UriMatcher and the types are stored in the NotesContentContract.
     *
     * @param uri the URI for typing
     * @return the type of the URI
     */
    @Nullable
    @Override
    public String getType(@NonNull Uri uri) {
        switch (sUriMatcher.match(uri)) {
            case ALL_ITEMS:
                return NotesContentContract.Notes.CONTENT_DIR_TYPE;
            case ONE_ITEM:
                return NotesContentContract.Notes.CONTENT_ITEM_TYPE;
            default:
                return null;
        }
    }
}

The sUriMatcher is used to ensure that the Uri being passed to each operation is one that we handle. There are two Uri patterns for each table; one for the collection and one for each item.

The only thing the onCreate() method does is to instantiate the database helper. This does not create the database. Database creation happens when you access data. The onCreate() method is run on the UI thread, so you don’t want it blocking. However, the other operations are done on async threads, so they can deal with the latency associated with data access, even if the data access is over the network. I don’t have to deal with that latency in my code.

Aside from these two methods, I need the CRUD operations. Fortunately, the API for the ContentProvider and the API for the SQLite database are closely tied, so the CRUD methods become thin wrappers around SQLite:

    @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_ITEMS:
                queryBuilder.setTables(NotesContentContract.Notes.TABLE_NAME);
                if (TextUtils.isEmpty(sortOrder)) {
                    sortOrder = NotesContentContract.Notes.SORT_ORDER_DEFAULT;
                }
                break;
            case ONE_ITEM:
                queryBuilder.setTables(NotesContentContract.Notes.TABLE_NAME);
                queryBuilder.appendWhere(NotesContentContract.Notes._ID + " = " + uri.getLastPathSegment());
                break;
        }

        Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
        cursor.setNotificationUri(getContext().getContentResolver(), uri);
        return cursor;
    }

    /**
     * Insert a new record into the database.
     *
     * @param uri the base URI to insert at (must be a directory-based URI)
     * @param values the values to be inserted
     * @return the URI of the inserted item
     */
    @Nullable
    @Override
    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
        int uriType = sUriMatcher.match(uri);
        switch (uriType) {
            case ALL_ITEMS:
                SQLiteDatabase db = databaseHelper.getWritableDatabase();
                long id = db.insert(
                        NotesContentContract.Notes.TABLE_NAME,
                        null,
                        values);
                if (id > 0) {
                    Uri item = ContentUris.withAppendedId(uri, id);
                    notifyAllListeners(item);
                    return item;
                }
                throw new SQLException("Error inserting for URI " + uri);
            default:
                throw new IllegalArgumentException("Unsupported URI: " + uri);
        }
    }

    /**
     * Delete one or more records from the SQLite database.
     *
     * @param uri the URI of the record(s) to delete
     * @param selection A WHERE clause to use for the deletion
     * @param selectionArgs Any arguments to replace the ? in the selection
     * @return the number of rows deleted.
     */
    @Override
    public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
        int uriType = sUriMatcher.match(uri);
        int rows;
        SQLiteDatabase db = databaseHelper.getWritableDatabase();
        switch (uriType) {
            case ALL_ITEMS:
                rows = db.delete(
                        NotesContentContract.Notes.TABLE_NAME,  // The table name
                        selection, selectionArgs);              // The WHERE clause
                break;
            case ONE_ITEM:
                String where = NotesContentContract.Notes._ID + " = " + uri.getLastPathSegment();
                if (!TextUtils.isEmpty(selection)) {
                    where += " AND " + selection;
                }
                rows = db.delete(
                        NotesContentContract.Notes.TABLE_NAME,  // The table name
                        where, selectionArgs);                  // The WHERE clause
                break;
            default:
                throw new IllegalArgumentException("Unsupported URI: " + uri);
        }
        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);
        int rows;
        SQLiteDatabase db = databaseHelper.getWritableDatabase();
        switch (uriType) {
            case ALL_ITEMS:
                rows = db.update(
                        NotesContentContract.Notes.TABLE_NAME,  // The table name
                        values,                                 // The values to replace
                        selection, selectionArgs);              // The WHERE clause
                break;
            case ONE_ITEM:
                String where = NotesContentContract.Notes._ID + " = " + uri.getLastPathSegment();
                if (!TextUtils.isEmpty(selection)) {
                    where += " AND " + selection;
                }
                rows = db.update(
                        NotesContentContract.Notes.TABLE_NAME,  // The table name
                        values,                                 // The values to replace
                        where, selectionArgs);                  // The WHERE clause
                break;
            default:
                throw new IllegalArgumentException("Unsupported URI: " + uri);
        }
        if (rows > 0) {
            notifyAllListeners(uri);
        }
        return rows;
    }

    /**
     * Notify all listeners that the specified URI has changed
     * @param uri the URI that changed
     */
    private void notifyAllListeners(Uri uri) {
        ContentResolver resolver = getContext().getContentResolver();
        if (resolver != null) {
            resolver.notifyChange(uri, null);
        }
    }

In each of the CRUD operations, I get an appropriate handle to the database, adjust the query as needed, then call the appropriate SQLite database command.

Finally, let’s register the content provider in the AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.shellmonger.notes">

    <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=".NoteListActivity"
            android:label="@string/app_name"
            android:theme="@style/AppTheme.NoActionBar">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".NoteDetailActivity"
            android:label="@string/title_note_detail"
            android:parentActivityName=".NoteListActivity"
            android:theme="@style/AppTheme.NoActionBar">
            <meta-data
                android:name="android.support.PARENT_ACTIVITY"
                android:value=".NoteListActivity" />
        </activity>

        <provider
            android:name=".data.NotesContentProvider"
            android:authorities="com.shellmonger.notes.provider"
            android:label="NotesProvider" />
    </application>
</manifest>

The name and authorities must be present. There are other things that can go here depending on whether you are sharing data with other applications or not. I’m not, so nothing else is required.

Using the ContentProvider for Lists

So, I now have a ContentProvider. It doesn’t do anything for me if I can’t use it. Specifically, I need to:

  • Populate the Notes List with the content.
  • Retrieve a single item for the details page.
  • Update or Insert the item for the details page.

Let’s start with the list content, which is contained within NoteListActivity. The ContentProvider has been designed to work with the loader framework. In the last post, I created a RecyclerView and a RecyclerAdapter. Now it’s time to replace those. The SimpleItemRecyclerAdapter from the original code is replaced by one that can handle a Cursor:

    public class NotesAdapter extends RecyclerView.Adapter<NoteViewHolder> {
        Cursor dataCursor;
        Context context;

        public NotesAdapter(Context mContext, Cursor cursor) {
            dataCursor = cursor;
            context = mContext;
        }

        @Override
        public NoteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext())
                    .inflate(R.layout.note_list_content, parent, false);
            return new NoteViewHolder(view);
        }

        @Override
        public void onBindViewHolder(final NoteViewHolder holder, int position) {
            dataCursor.moveToPosition(position);
            Note note = Note.fromCursor(dataCursor);
            holder.setNote(note);

            holder.getView().setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                Bundle arguments = new Bundle();
                arguments.putLong(NoteDetailFragment.ARG_ITEM_ID, holder.getNote().getId());
                if (mTwoPane) {
                    NoteDetailFragment fragment = new NoteDetailFragment();
                    fragment.setArguments(arguments);
                    getSupportFragmentManager()
                            .beginTransaction()
                            .replace(R.id.note_detail_container, fragment)
                            .commit();
                } else {
                    Context context = v.getContext();
                    Intent intent = new Intent(context, NoteDetailActivity.class);
                    intent.putExtras(arguments);
                    context.startActivity(intent);
                }
                }
            });
        }

        @Override
        public int getItemCount() {
            return (dataCursor == null) ? 0 : dataCursor.getCount();
        }

        public Cursor swapCursor(Cursor cursor) {
            if (dataCursor == cursor) {
                return null;
            }
            Cursor oldCursor = dataCursor;
            this.dataCursor = cursor;
            if (cursor != null) {
                this.notifyDataSetChanged();
            }
            return oldCursor;
        }
    }

As you might guess, the major piece of work here is in the onBindViewHolder(), which inserts data into the provided view. I have to construct a Note based on the data within the cursor here. This is probably error-prone right now as the required fields may not be in the cursor, although I have helped with that in the fromCursor() method that I developed as part of the model. I am also allowing the user to press on a row. Pressing on the row will load the detail fragment either directly (for wide-mode tablets) or via the NoteDetailActivity (for phones).

The initialization of the adapter is done in the onCreate() method:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_note_list);

        // Install the application crash handler
        ApplicationCrashHandler.installHandler();

        // Work out if we are in 2-pane (tablet) mode or not
        mTwoPane = (findViewById(R.id.note_detail_container) != null);

        // Initialize the Toolbar
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        toolbar.setTitle(getTitle());

        // Initialize the floating action button
        addNoteButton = (FloatingActionButton) findViewById(R.id.addNoteButton);
        addNoteButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (mTwoPane) {
                    NoteDetailFragment fragment = new NoteDetailFragment();
                    getSupportFragmentManager()
                            .beginTransaction()
                            .replace(R.id.note_detail_container, fragment)
                            .commit();
                } else {
                    Context context = view.getContext();
                    Intent intent = new Intent(context, NoteDetailActivity.class);
                    context.startActivity(intent);
                }
            }
        });

        // Update the ListView with the CursorAdapter
        notesList = (RecyclerView) findViewById(R.id.note_list);
        NotesAdapter adapter = new NotesAdapter(this, null);
        notesList.setAdapter(adapter);
        getLoaderManager().initLoader(NOTES_LOADER, null, this);
    }

I’ve wired up the floating action button so that it also loads the NoteDetailFragment (either directly or indirectly, depending on the form factor), but without an argument. This will create a new note. Take a look at the last line of this method. This initializes the loaders framework, which is a callback based system for loading data from the content provider. There is more to it. First, I have to add implements LoaderManager.LoaderCallbacks&lt;Cursor&gt; to ensure that the loaders framework has the appropriate callbacks. I’m using a CursorLoader, so the callbacks are based on a Cursor. I also have to define the three required callbacks:

    @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);
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        ((NotesAdapter) notesList.getAdapter()).swapCursor(data);
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
        ((NotesAdapter) notesList.getAdapter()).swapCursor(null);
    }

The onCreateLoader() is important as it provides the projection of the data that I am asking for. If I don’t ask for all the fields, then the code in onBindViewHolder() in the adapter won’t have all the fields and I will probably get an application crash.

If I run the application at this point, I should not get any errors. I also won’t get any data because I haven’t got a method of adding any notes right now. I’ve only brought up the UI for it.

Inserting and Updating Data

The UI for both inserting and updating data is provided in the NoteDetailFragment.java class. It is optionally passed the ARG_ITEM_ID that holds the internal ID of the item I am trying to update. In the case of the phone, a NoteDetailActivity is started instead, which includes the fragment. The NoteDetailActivity receives the ARG_ITEM_ID in the intent extras bundle and copies it to the fragment argument bundle if it is there. The net effect is that the fragment deals with the insertion or updating of data. I did update the UI in note_detail.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/note_detail"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    tools:context=".NoteDetailFragment">

    <EditText
        android:id="@+id/edit_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="8dp"
        android:layout_marginTop="8dp"
        android:hint="Title"
        android:textAlignment="viewStart"
        android:textAppearance="@style/TextAppearance.AppCompat.Headline"
        android:textColor="?attr/editTextColor"
        android:textStyle="bold"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/edit_content"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="8dp"
        android:layout_marginTop="8dp"
        android:hint="Note Content"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/edit_title" />
</android.support.constraint.ConstraintLayout>

The major change here is that the text boxes have been changed to EditText controls so I can edit the data. Here is the code for the fragment in NoteDetailFragment.java:

package com.shellmonger.notes;

import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.support.design.widget.CollapsingToolbarLayout;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;

import com.shellmonger.notes.data.Note;
import com.shellmonger.notes.data.NotesContentContract;

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;

/**
 * A fragment representing a single Note detail screen.
 * This fragment is either contained in a {@link NoteListActivity}
 * in two-pane mode (on tablets) or a {@link NoteDetailActivity}
 * on handsets.
 */
public class NoteDetailFragment extends Fragment {
    /**
     * The fragment argument representing the item ID that this fragment
     * represents.
     */
    public static final String ARG_ITEM_ID = "item_id";

    /**
     * The dummy content this fragment is presenting.
     */
    private Note mItem;
    private long itemId = -1;
    private Uri itemUri;

    /**
     * Content Resolver
     */
    private ContentResolver contentResolver;

    /**
     * Is this an insert or an update
     */
    private boolean isUpdate;

    /**
     * The component bindings
     */
    EditText editTitle;
    EditText editContent;

    /**
     * Mandatory empty constructor for the fragment manager to instantiate the
     * fragment (e.g. upon screen orientation changes).
     */
    public NoteDetailFragment() {
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get the ContentResolver
        contentResolver = getContext().getContentResolver();

        if (getArguments().containsKey(ARG_ITEM_ID)) {
            itemId = getArguments().getLong(ARG_ITEM_ID);
            itemUri = ContentUris.withAppendedId(NotesContentContract.Notes.CONTENT_URI, itemId);
            Cursor data = contentResolver.query(itemUri, NotesContentContract.Notes.PROJECTION_ALL, null, null, null);
            if (data != null) {
                data.moveToFirst();
                mItem = Note.fromCursor(data);
                isUpdate = true;
            }

            Activity activity = this.getActivity();
            CollapsingToolbarLayout appBarLayout = (CollapsingToolbarLayout) activity.findViewById(R.id.toolbar_layout);
            if (appBarLayout != null) {
                appBarLayout.setTitle(mItem.getTitle());
            }
        } else {
            mItem = new Note();
            isUpdate = false;
        }
    }

    @Override
    public void onPause() {
        super.onPause();
        saveData();
    }
    
    private void saveData() {
        // Save the edited text back to the item.
        boolean isUpdated = false;
        if (mItem.getTitle() != editTitle.getText().toString().trim()) {
            mItem.setTitle(editTitle.getText().toString().trim());
            mItem.setUpdated(DateTime.now(DateTimeZone.UTC));
            isUpdated = true;
        }
        if (mItem.getContent() != editContent.getText().toString().trim()) {
            mItem.setContent(editContent.getText().toString().trim());
            mItem.setUpdated(DateTime.now(DateTimeZone.UTC));
            isUpdated = true;
        }

        // Convert to ContentValues and store in the database.
        if (isUpdated) {
            ContentValues values = mItem.toContentValues();
            if (isUpdate) {
                contentResolver.update(itemUri, values, null, null);
            } else {
                itemUri = contentResolver.insert(NotesContentContract.Notes.CONTENT_URI, values);
            }
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        // Get a reference to the root view
        View rootView = inflater.inflate(R.layout.note_detail, container, false);

        // Update the text in the editor
        editTitle = (EditText) rootView.findViewById(R.id.edit_title);
        editContent = (EditText) rootView.findViewById(R.id.edit_content);

        editTitle.setText(mItem.getTitle());
        editContent.setText(mItem.getContent());

        return rootView;
    }
}

This code uses a content resolver instead of the loaders framework. The content resolver is responsible for directing requests for a Uri to the appropriate content provider. This is used in two places:

  • In the onCreate() method, I use .query() to find the record to load when an internal ID is passed in.
  • In the onPause() method, I use .insert() or .update() to insert/update the record, depending on whether this is a new record or an existing record.

These three methods call the same methods in the content provider, which queries, inserts or updates records in the SQLite database.

One of the side problems at this point is that I don’t have any way to “pause” the fragment in wide-screen mode. If you only have one note, it is never persisted to the database. To get around this problem, I can add a timer control that will save the data on a regular basis. This is done in the NoteDetailFragment.java. First, define a handler and a runnable to do the saving:

    Handler timer = new Handler();
    Runnable timerTask = new Runnable() {

        @Override
        public void run() {
            saveData();
            timer.postDelayed(timerTask, 5000);     // Every 5 seconds
        }
    };

Then, in the onCreate() method, start the timer:

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get the ContentResolver
        contentResolver = getContext().getContentResolver();

        Bundle arguments = getArguments();
        if (arguments != null && arguments.containsKey(ARG_ITEM_ID)) {
            itemId = getArguments().getLong(ARG_ITEM_ID);
            itemUri = ContentUris.withAppendedId(NotesContentContract.Notes.CONTENT_URI, itemId);
            Cursor data = contentResolver.query(itemUri, NotesContentContract.Notes.PROJECTION_ALL, null, null, null);
            if (data != null) {
                data.moveToFirst();
                mItem = Note.fromCursor(data);
                isUpdate = true;
            }

            Activity activity = this.getActivity();
            CollapsingToolbarLayout appBarLayout = (CollapsingToolbarLayout) activity.findViewById(R.id.toolbar_layout);
            if (appBarLayout != null) {
                appBarLayout.setTitle(mItem.getTitle());
            }
        } else {
            mItem = new Note();
            isUpdate = false;
        }

        // Start the timer for the delayed start
        timer.postDelayed(timerTask, 5000);
    }

Finally, remove the timer when the fragment is paused:

    @Override
    public void onPause() {
        super.onPause();
        timer.removeCallbacks(timerTask);
        saveData();
    }

At this point, the data is saved every 5 seconds or when the activity is paused, whichever comes first.

Deleting Data

We’ve now done the bulk of the work for this app, but there is one problem still remaining. We don’t have any method of deleting data. I like the idea of “swipe-to-delete”. Fortunately, it is fairly straight forward to implement and there are several blogs about it. The important piece of information is that once you decide to delete something, you pass the view holder to the adapters .remove() method, which is implemented like this:

        /**
         * Remove the element in the list.
         * @param holder the viewholder to delete
         */
        void remove(final NoteViewHolder holder) {
            ContentResolver resolver = getContentResolver();
            int position = holder.getAdapterPosition();
            Uri itemUri = ContentUris.withAppendedId(NotesContentContract.Notes.CONTENT_URI, holder.getNote().getId());
            int count = resolver.delete(itemUri, null, null);
            if (count > 0) {
                notifyItemRemoved(position);
            }
        }

Wrap Up

This was a lot of code, but it introduces several key concepts in Android about handling table-based data – the ContentProvider and Loader Framework being key to this. I hope you can apply these techniques to your own data driven apps. Until next time, my code is available, as always, on my GitHub repository.

One thought

  1. Pingback: Dew Drop - June 29, 2017 (#2510) - Morning Dew

Comments are closed.