This lab will be a continuation of Lab 3. You will be given a solution to Lab 3 so you may begin Lab 4 even if you did not complete all of Lab 3. You will be persisting data using a content provider with a SQLite database inside, and preserving the state of an application during its life cycle. It is important to note that this lab is meant to be done in order, from start to finish. Each activity builds on the previous one, so skipping over earlier activities in the lab may cause you to miss an important lesson that you should be using in later activities.
Objectives
At the end of this lab you will be expected to know:
Activities
For this lab we will be extending the "Joke List" application that you worked on in Lab 3. The previous app you created functioned completely on its own, but when the orientation or state of the device was changed the jokes disappeared, replaced by the original unrated prepopulated jokes. This version of the app will add a persistence layer to the previous version to fix these issues and more. It will allow Jokes that are added to be saved to a database as well as maintain application state throughout the life cycle of the application. All tasks for this lab will be based off of this application. Over the course of the lab you will be iteratively refining and adding functionality to the Joke List app. With each iteration you will be improving upon the previous iteration's functionality.
IMPORTANT:
You will be given a Skeleton Project to work with. This project contains all of the java and resource files you will need to complete the lab. Some method stubs, member variables, and resource values and ids have been added as well. It is important that you not change the names of these methods, variables, and resource values and ids. These are given to you because there are unit tests included in this project that depend on these items being declared exactly as they are. These units test will be used to evaluate the correctness of your lab. You have complete access to these test cases during development, which gives you the ability to run these tests yourself. In fact, you are encouraged to run these tests to ensure that your application is functioning properly.
Contents
1.2 Familiarize Yourself with the Source Code
2. Maintaining Application State
2.1.3 Showing Persistent Filtering
2.2.1 Saving SharedPreference Data
2.2.2 Restoring SharedPreference Data
3. Joke Persistence I: Database & Content Provider
4. Joke Persistence II: OnJokeChangeListener & CursorAdapter
5. Joke Persistence III: AdvancedJokeList & Loaders
5.2 Cursor & OnJokeChangeListener Integration
1. Setting Up
1.1 Setting Up the Project
To begin, download and extract the skeleton project for the JokeList application from Polylearn.
Next you will need to set up the Joke List Android project for this app. Since the skeleton project was created in Eclipse, the easiest thing is to import this project into Eclipse.
Make sure that your project is targeting the latest version of Android but supporting API 10 and higher.
Change the author name in strings.xml to <userid>. Non-Cal Poly students can change this to an appropriate username handle of their choice.
If you haven't set up your project to use ActionBarSherlock (ABS), do so. If you already have the library project for ABS, you should be able to skip this step.
Run the following three Unit Test classes (located under the test folder) as Android JUnit Tests:
These Unit Tests are the same tests from the downloadable Lab 3 stub project. They should all pass. If not, double-check your code (if it's your old code from lab 3) and make sure it works as intended according to Lab 3.
1.2 Familiarize Yourself with the Source Code
The skeleton project that has been given to you for this lab contains a fully functional solution to Lab3. In particular, the AdvancedJokeList, JokeListAdapter, Joke, and JokeView classes are fully functioning classes from Lab 3. Additionally, many completed XML resource files (drawable, layout, menu) have been supplied as well. Most of the code in the new
It is a good idea, and good practice, to read through the source code. See how it compares to your implementation of Lab 3. It is especially important to do this if there were any parts of Lab 3 that you were not able to complete. The rest of the Lab will require you to update this source code to make use of Data Persistence so it is critical that you are familiar with it.
1.3 Fill in the Joke Class
You must fill in a few new areas of the Joke Class. Some new method stubs and a member variable have been added that you will have to fill in and make use of. Make sure not to delete or change these. In particular, a member variable named m_nID has been added to the class which will contain the unique id assigned to the Joke from the Database:
Run the JokeTest.java Unit Tests to ensure that you have properly filled in this class.
Notice that if you try to run your application, it will not function properly. This is because the equals(...) method has been changed.
Notice also that if you try to run the two AdvancedJokeListAddFilter*Test.java unit test files that passed previously, you will now get errors. This is because the equals() method has been changed. This is normal.
Note: From here on out, there are no unit tests for this project. The overall functionality of the application is not changing at all, only the way information is saved and retrieved. However, you will be asked to demonstrate your application using a particular series of instructions that will test all attributes of the application for correctness. It will be obvious if the application is not working properly. But don't panic! This lab is complex, but a large portion of it will be studying and understanding code that will be provided to you in bits and pieces. Lab 4 is more about understanding components than coding them up.
2. Maintaining Application State
You will now enable your application to maintain basic settings in the event that your Activity is destroyed and re-created, as well as maintain UI state across different runs of your application. In particular, the filter value, saved in m_nFilter, should be maintained if the Activity is destroyed and re-created for any reason. In this case the jokeList should be re-filtered to display only the jokes specified by m_nFilter. The filter value will be saved as Instance State. Additionally, the text in the m_vwJokeEditText will be saved and restored across separate runs of your programs via the Preferences mechanism. You can begin familiarizing yourself with the subject matter by reading the Android Documentation on Saving Persistent State in Activities.
2.1 Instance State
Instance state is data that is private to a single instance of an Activity. This data is not shared across the application or made available to any other instances of the same Activity. Instance state can be stored in a Bundle object when the Activity becomes inactive, or destroyed and dumped from memory. The Bundle object is saved even though the Activity no longer resides in memory. When an Activity becomes inactive, its onSaveInstanceState(...) method is called. This is where saving of instance state happens. It is important to note that this only happens when the Activity is saved in the Application History stack, not when the Activity is closed or finalized.
The Bundle object is a map data-structure that uses key-value pairs to store data. The type of data that can be stored is limited to basic data types like primitives and strings. You can retrieve the data from the Bundle object when the Activity is re-created. The Bundle object containing the instance state can be accessed by overriding two separate methods. The first, which you are already familiar with, is the onCreate(...) method. The second is the onRestoreInstanceState(...) method, which gets called immediately after the onStart(...) method. Both of these methods take a Bundle argument that contains the instance state if it was previously saved. For more information about the life cycle of an Activity, see the explanation and diagram here.
It is important to note that this Bundle object might not contain anything in the initial creation of the activity. The onRestoreInstanceState(...) method won't even get called on the initial creation of the Activity. Restoring instance state can be done in either method. However, using the onRestoreInstanceState(...) method instead of the onCreate(...) method has the benefit of logically separating initialization tasks from restoration tasks.
In the following two subsections you will be persisting the Filtering mechanism as instance state data. To start you will save the filter state of your AdvancedJokeList Activity in the onSaveInstanceState(...) method. You will then restore the state in the onRestoreInstanceState(...) methods. More background on this can be found in the Android Developer Guide on Application State and the Android Documentation on the Bundle Class. When finished, your application should function as depicted by the figure below (click to view full image):
Joke persistence is not yet implemented, this is expected.
2.1.1 Saving Instance Data
2.1.2 Restoring Instance Data
Begin by familiarizing yourself with the Android Documentation on the Activity.onSaveInstanceState(...) method. It is good to understand what the default implementation does before modifying it.
Of course, this means you have to change AdvancedJokeList to now properly modify m_nFilter during normal application usage in the code, including initially in onCreate().
Begin by familiarizing yourself with the Android Documentation on the Activity.onRestoreInstanceState(...) method. It is good to understand what the default implementation does before modifying it.
2.1.3 Showing Persistent Filtering
The final task for this section is to make it clear to the app's user that the filter is being preserved. Currently there is no indication of filter preservation other than jokes appearing in and disappearing from the list when a filter is chosen. We will make it obvious when a filter is selected, as well as preserve that filter across destructive modifications such as orientation change, by changing the text on the Filter menu item itself whenever a filter is chosen. This shall be done via the onPrepareOptionsMenu() method.
Familiarize yourself with the Android Documentation on the Activity.onPrepareOptionsMenu(...) method. It is good to understand what the default implementation does before modifying it.
Now the Filter menu item text will persist across destructive app modifications such as orientation change, but the title still needs to be updated when a new filter is chosen as well.
Try running your application. The default Filter should be Show All when the application starts up, as indicated by the Filter menu item's text.
2.2 SharedPreferences
The SharedPreferences mechanism operates in a manner similar to saving Instance State in that primitive data is stored in a map of key/value pairs. The difference is that SharedPreference data can be shared across Application components running in the same Context and that the data persists across separate runs of the application. Alternatively, you can make the SharedPreferences data private to a single instance of an Activity.
In this next section you will persist the text in m_vwJokeEditText across multiple runs of an Application. This allows the user to work on a new joke across multiple sessions, and guarantees that if the process is killed for some reason, the user won't lose a joke they were working on. To start, you will override the default implementation of onPause() and save data in a private SharedPreferences object. You will then retrieve and restore the data in the onCreate(...) method. Saving your data in onPause() guarantees that if your process is killed, the data will still be available in a subsequent call to onCreate(...). This is because onPause() is the earliest point at which an Activity can be killed by the system. You should be able to see this from the Activity Lifecycle Diagram. When finished, your application should function as depicted by the figure below (click image for full size):
2.2.1 Saving SharedPreference Data
Begin by reading the Android Developer guide on using SharedPreferences to save data, as well as the Android Documentation on the Activity.getPreferences(...) method and the SharedPreferences class. It is good to understand how these work before using them.
2.2.2 Restoring SharedPreference Data
The following should be done in the onCreate() method at the very bottom.
Before you move on to joke persistence, you will change Joke.java to accommodate for this.
JokeTest should once again pass, but your application will not function properly. This is expected behavior as this issue will be resolved by the remaining sections.
3. Joke Persistence I: Database & Content Provider
Your ultimate goal at the end of this lab is to be able to persist Jokes in a SQLite Database. Due to Android's constant evolution this has become a difficult and widely undocumented subject at the time of modifying this lab, but the process will be broken down into small pieces with many code snippets involved to make it easier to digest.
We will work our way from creating the SQLite database inside of a content provider to creating the adapter that facilitates behavior between our content provider and our ListView, to reworking the ListView itself.
3.1 Content Providers
We'll begin with a discussion on Content Providers and how they relate to databases. The place to go for information on Android Content Providers is here. Use the Android page on Content Provider basics as a guide while reading this section.
What are Content Providers?
A content provider does exactly what it is named: It is a partner to a data source, such as a database of information, and provides content from that data source to various accessors. In the context of Android, this means that Android applications may access content providers for retrieval, modification or creation of data in the database. A content provider is not a database, but rather an organized representation of one. A database is an entity that applications want information out of, and the content provider acts as the abstract layer between the database and the application (similar to how an Adapter acts as the "glue" between a View and underlying data, like in Lab 3). We will be wrapping a database inside of a content provider interface so that we may achieve a single compound entity that both contains data and provides access to it. Our lab application is the accessor in this case.
Content providers expose the data from a data source using tables. A table is a two-dimensional object that contains an organized representation of the data in a data source. The basics guide linked above contains an example table, but let's have another more relevant example (click image for full view):
A table full of joke data contained in a content provider. Pretend this table is named "joke_table".
This is an example instance of the content provider table you will be creating. The resemblance to a list of jokes is striking: Each entry, or row, in the content provider contains all Joke fields: an ID, the joke text, the joke rating and the joke author. In the above example, the rating is 0 for Unrated, 1 for Like and 2 for Dislike. This joke table serves as the content provider's organized source of data pulled from the database and is kept up to date through a set of operations that may be executed on the database. As you can guess, a table is an extremely effective way to get, modify and create data in a database: Why would we want to deal with an unorganized database, much less force it to organize itself? Moreover, a table is clean and easy for both humans and machines to read. Keep in mind that a table is not a content provider but rather a part of one, and a table is not a database but rather an organized representation of part of one.
To put it simply: We will store our joke data in a database, and that database will be inside of a content provider. That content provider will expose the database's data in an organized table to our application. Finally, our application will have access to create, modify and obtain the data exposed by the content provider, and the content provider will handle any modifications requested by our application and update the database accordingly.
3.2 The SQLite Database
With a brief background on content providers and tables, we will now focus on the aspects of the actual database. First, you guessed it...more information!
What are Content URIs?
A Content URI, referred to as just URI throughout the remainder of this lab, is essentially a special code that is required when trying to perform an operation through a content provider. First, let's look at examples of the two Content URI formats we will use in this lab:
content://edu.calpoly.android.lab4.contentprovider/joke_table/filters/1
content://edu.calpoly.android.lab4.contentprovider/joke_table/jokes/5
While similar in appearance to a URL, a URI contains two important pieces: an address to the content provider, and a statement with a specific intent that is parsed by the content provider using a URIMatcher (more on this later). All Content URIs in Android are formatted using the "content" scheme, which is a fancy term that means "prefix the URI with the text content://".
In both URIs displayed above, edu.calpoly.android.lab4.contentprovider is the authority. This is a fancy term for "match me with the content provider at this location". Not to anyone's surprise, we will implement our own content provider in this lab so it makes sense to point to it when trying to invoke operations on it. Rocket science, right? Everything else that follows the authority is referred to as the path.
In both URIs displayed above, the next part of the path is joke_table. This part means "this is the table I'm trying to access". Since our content provider will have only one table, it makes sense to point to it when trying to do anything with it.
The remainder of the paths of the above URIs, filters/1 and jokes/5, are basically keys for the operations we wish to perform. These can be whatever we want them to be, but when they are passed to the operations they must be expected, as you'll see soon. The first one indicates that we want to include the Like filter on our operation, since Joke.LIKE has a value of 1. You'll notice in the joke table image in 3.1 that the rating column uses 1 for Like. The second one indicates that we want to include the 5th joke in the table on our operation. Using URIs, we can specify different behavior within the same operation. Note: The reason why we need to have two separate paths for filters and jokes is because both of them refer to numbers. If they were left as just numbers (1 and 5) then there would be no way to differentiate filters from joke IDs.
See more detailed information about Content URIs here.
What Operations Can be Performed Through the Content Provider?
The content provider will provide four operations that may be performed on data from the database: query, insert, delete and update. As you can probably guess, these are operations that we will implement in our JokeContentProvider class as methods.
The operations are briefly described below at a high level:
Query - Given a URI containing a numerical Joke filter and conditions for which rows to fetch, the content provider returns a cursor (more on this later) that contains a list of rows that match the conditions specified. For example, given the table above, a query made for jokes that have a rating of 0 would return a cursor object that contains two rows: the row with ID 1 and the row with ID 4.
Insert - Given a URI containing a Joke ID and list of values, the content provider inserts a new object into the database and updates the table with the new information. The inserted object appears as a new row at the bottom of the table, populating the row's columns with the list of values passed in. Then the content provider returns the ID of the newly inserted row. We do not need to worry about passing in a proper ID for insertion because we will create the database with a statement that will automate the ID assignment system for us. Convenient!
Delete - Given a URI that contains a Joke ID, the content provider locates a row in the table with a matching ID and purges it from both the table and the database. The content provider then returns the number of rows that were deleted (in our case, only one row will be deleted per operation).
Update - Given a URI that contains a Joke ID and a list of values, the content provider searches for a row with the matching ID and updates that row's columns with the passed-in values. Then the content provider returns the number of rows that were updated (in our case, only one row will be updated per operation).
3.2.1 JokeTable.java
All right, it's finally time to get our hands dirty! If the past few steps were an information overload, don't worry: you can refer back to them if you need to check terminology or get your bearings straight.
We need to create two special helper classes that will aid our Content Provider class first. The first is JokeTable.
Open up JokeTable.java. Spend some time looking at the code, particularly the class variables. You will see some familiar terms such as joke_table, which is the name of the table in which we will operate on all of our jokes. You will also see strings that represent the columns for each row in the joke table that match with member variables in the Joke class. There is an _id field, but as explained above we will automate row ID creation in our database. In fact, look at the DATABASE_CREATE string: Even if you don't know any SQLite syntax, you can probably understand what this string is doing and what it's meant for.
3.2.2 JokeDatabaseHelper.java
The second helper class we will need is JokeDatabaseHelper.java. This class will have some assistance from JokeTable to actually open and minimally manage the database for our content provider. The content provider will naturally contain a copy of the database. JokeDatabaseHelper contains a reference to the database as well as the starting database version.
And...that's it. The SQLite database creation and management is prepared for the content provider.
3.3 The Content Provider
We will now implement the content provider. As a reminder, this bad boy will wrap around our SQLite database and give other classes an easy way to interact with it. All coding in this section will take place inside of JokeContentProvider.java.
Open up the class and take a look at its class variables and methods. You will see the four database operations mentioned above. Implementing those methods will be the bulk of this step.
content://edu.calpoly.android.lab4.contentprovider/joke_table/filters/2
content://edu.calpoly.android.lab4.contentprovider/joke_table/jokes/3
Assuming our URIMatcher recognizes both of these as properly formed URIs (by matching them to URI formats it is given upon initialization), we could theoretically use a switch statement inside of our query(...) method in the content provider to catch both possibilities. If it matches the first URI format, we can modify our query to fetch all table rows that have the rating "Dislike" (since the Dislike filter is represented by the integer value '2'). If it matches the second URI format, we can modify our query in a separate switch case to instead fetch all table rows that have the ID 3 (which will only return 1 row). This means that we have absolute flexibility when it comes to querying the database (if this is confusing to you, it will become clear once you finish filling in the insert(...) method and implement the query(...) method).
sURIMatcher.addURI(AUTHORITY, BASE_PATH + "/jokes/#", JOKE_ID);
When all of the parts are put together, that URI format looks like this: content://edu.calpoly.android.lab4.contentprovider/joke_table/jokes/#
In other words, the URIMatcher is ready to accept URIs that deal with jokes and provide a joke ID. It matches that URI format to the value JOKE_ID, which is the value that the URIMatcher will return after finding a match. This allows us to provide special logic inside of the operation methods to alter the actual database operation calls before we make them (if this is confusing to you, look at the insert(...) method and observe the case inside the switch statement).
Boom, your SQLite database is now open and ready for business. Such an easy way to manage a SQLite database, isn't it? That's the power of a SQLiteOpenHelper.
3.3.1 Query() and Cursors
The first of the content provider operations we will implement is query(...). What this method will do is format a database query with special arguments to indicate which rows we want to get out of our joke table, and then perform the actual query call on the database itself. You will be walked entirely through this method as it is rather complex and introduces a new data type: Cursors.
Below is the entire solution to the query(...) method. Copy and paste it over the query(...) method in JokeContentProvider.java (just under the method's comment block):
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
/** Use a helper class to perform a query for us. */
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
/** Make sure the projection is proper before querying. */
checkColumns(projection);
/** Set up helper to query our jokes table. */
queryBuilder.setTables(JokeTable.DATABASE_TABLE_JOKE);
/** Match the passed-in URI to an expected URI format. */
int uriType = sURIMatcher.match(uri);
switch(uriType) {
case JOKE_FILTER:
/** Fetch the last segment of the URI, which should be a filter number. */
String filter = uri.getLastPathSegment();
/** Leave selection as null to fetch all rows if filter is Show All. Otherwise,
* fetch rows with a specific rating according to the parsed filter. */
if(!filter.equals(AdvancedJokeList.SHOW_ALL_FILTER_STRING)) {
queryBuilder.appendWhere(JokeTable.JOKE_KEY_RATING + "=" + filter);
} else {
selection = null;
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
/** Perform the database query. */
SQLiteDatabase db = this.database.getWritableDatabase();
Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null, null);
/** Set the cursor to automatically alert listeners for content/view refreshing. */
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
Above is the method signature. The comment block already provided for you explains the method and how to get more information about the query(...) method JokeContentProvider is overriding.
The parameters:
uri - As discussed previously in the section on URIs, this contains information about how to tweak the operation (a database query) before actually making it.
projection - This is the set of column names (think back to JokeTable.java) that we are going to place into the Cursor we will return. Just assume we will always place all of the columns (_id, joke_text, rating, author) into the Cursor for simplicity's sake. Cursors are essentially lists that contain any table rows returned after making a database query.
selection - This is the most important part of each operation in our content provider, as it defines how to format each database operation. It is also referred to as the WHERE clause. For example, in the above code selection is being altered to have the statement JokeTable.JOKE_KEY_RATING + "=" + filter. Assuming filter is Like, for example, this translates into the raw String rating=1. This means that when the actual call to query is made near the bottom of the query(...) method, it will probe the joke table for all rows that contain the rating 1 and return them inside of a cursor. Referring back to the joke table image in section 3.1, this means that the query will return a Cursor with row 3 and row 5, since those contain a rating of 1. Important note: if selection is set to null, then all rows will be returned.
We aren't concerned with the rest of the parameters.
/** Use a helper class to perform a query for us. */
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
We are using a SQLiteQueryBuilder to construct our query for us. You'll notice that there's a call made to appendWhere(...) later on in the method. This will automatically add whatever is passed into appendWhere(...) onto the end of our selection variable when the query is finally being made. We don't even have to make any changes to selection, the SQLiteQueryBuilder does it for us!
/** Make sure the projection is proper before querying. */
checkColumns(projection);
Speaking of the checkColumns(...) method, here's the whole thing provided to you:
private void checkColumns(String[] projection) {
String[] available = { JokeTable.JOKE_KEY_ID, JokeTable.JOKE_KEY_TEXT, JokeTable.JOKE_KEY_RATING,
JokeTable.JOKE_KEY_AUTHOR };
if(projection != null) {
HashSet<String> requestedColumns = new HashSet<String>(Arrays.asList(projection));
HashSet<String> availableColumns = new HashSet<String>(Arrays.asList(available));
if(!availableColumns.containsAll(requestedColumns)) {
throw new IllegalArgumentException("Unknown columns in projection");
}
}
}
What this does is perform a sanity check to make sure that the projection values match the expected values. Since we're always going to pass in all columns into our projection, and this method checks all columns, we aren't worried about anything. Don't concern yourself too much with this method.
/** Set up helper to query our jokes table. */
queryBuilder.setTables(JokeTable.DATABASE_TABLE_JOKE);
This line makes sure that we are going to query the correct table. In this case, that would be joke_table.
/** Match the passed-in URI to an expected URI format. */
int uriType = sURIMatcher.match(uri);
switch(uriType) {
case JOKE_FILTER:
/** Fetch the last segment of the URI, which should be a filter number. */
String filter = uri.getLastPathSegment();
/** Leave selection as null to fetch all rows if filter is Show All. Otherwise,
* fetch rows with a specific rating according to the parsed filter. */
if(!filter.equals(AdvancedJokeList.SHOW_ALL_FILTER_STRING)) {
queryBuilder.appendWhere(JokeTable.JOKE_KEY_RATING + "=" + filter);
} else {
selection = null;
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
Here is where our URIMatcher goes to work for us. Our content provider's query(...) method is only checking for the URI format that matches JOKE_FILTER (which looks for URIs formatted like this: content://edu.calpoly.android.lab4.contentprovider/joke_table/filters/#). This means that we are only querying for rows that contain a certain joke rating. This reflects what our original application does via its filtering system.
Note that getLastPathSegment() fetches the last part of the path in the passed-in URI variable, which in this case is the number at the very end of our URI. This corresponds to a joke rating, and we are ordering the query builder to set the selection to this joke rating.
/** Perform the database query. */
SQLiteDatabase db = this.database.getWritableDatabase();
Cursor cursor = queryBuilder.query(db, projection, selection, null, null, null, null);
Finally, the above code shows the actual query operation is carried out on the database! This is where we obtain our Cursor, which will contain rows in the joke table whose rating value matches whatever filter value we obtained from the URI and placed into our selection statement. This is the "wrapped" operation call that we've been talking about, the call made straight to the database. We are inside the content provider's query(...) method, which is the wrapper for the actual database operation call. Hopefully, the big picture is more clear at this point.
/** Set the cursor to automatically alert listeners for content/view refreshing. */
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
Content Resolvers act as event resolvers for any accessors that are working with content providers, such as our AdvancedJokeList application. They notify observers of underlying data changes which triggers content refreshing. Our ListView will become an observer because it will eventually use a JokeCursorAdapter, which will bind the ListView to a Cursor. And since the cursor is attached to the joke table and we are registering our application's content resolver to changes made with the given uri (meaning any change made by the query will set the content resolver off), any changes that are made to the joke table are automatically resolved in our ListView. This is extremely efficient, since this means less updating work on our end!
Finally, we return our cursor.
Phew, that was a lot of information to digest! Now let's do some more coding.
3.3.2 Insert()
The content provider's insert(...) method is a wrapper for an actual database insert operation call, just like how query(...) is a wrapper for the query database operation call. It also looks very similar to query(...) implementation-wise. This method is almost entirely implemented for you.
To explain this method in words:
3.3.3 Delete()
The content provider's delete(...) method is a wrapper for an actual database delete operation call, just like how insert(...) is a wrapper for the insert database operation call. It will look similar to the two wrapper methods you've already completed so far.
Fill out this method as follows:
3.3.4 Update()
The content provider's update(...) method is a wrapper for an actual database update operation call, just like how delete(...) is a wrapper for the delete database operation call.
You are tasked with implementing this method yourself, but it is pretty much identical to the implementation for delete(...) only with a different database call.
Congratulations, you have successfully wrapped a SQLite database inside a Content Provider! Now we have to make the provider available to our application:
<provider android:name="edu.calpoly.android.lab4.JokeContentProvider"
android:authorities="edu.calpoly.android.lab4.contentprovider">
</provider>
Note: Because we are only keeping this content provider local to our application, there are few permission settings to worry about. However, when making a content provider open to other applications besides the one the provider belongs to, there is a need to worry about permissions. Read more about it here.
Now we will be moving on to the next step, which is implementing the CursorAdapter and OnJokeChangeListener.
4. Joke Persistence II: OnJokeChangeListener & CursorAdapter
As a summary, let's go over the path of data flow for our application so far:
Now we will add another step: the Cursor will be bound to a ListView through the JokeCursorAdapter.
With the database and content provider all set up, we will now implement the CursorAdapter and complete the bridge between database and ViewGroup. The JokeCursorAdapter will function similarly to the previously used JokeListAdapter in Lab 3, but this adapter is binding a ListView to a Cursor that is tied to the joke table of the content provider we just implemented.
But before we handle that, we will implement functionality for listening to JokeView changes. The JokeCursorAdapter also contains an OnJokeChangeListener which will complete the cycle of database-to-ListView-to-database updating (thanks also in part to the ContentResolver set in our content provider, as you will find out in the next section).
What's this OnJokeChangeListener for, exactly?
In short, it has to do with Joke change preservation going back to the database. When the list of Jokes is displayed on-screen, Jokes are retrieved from a cursor and then wrapped into JokeViews. The JokeViews let the user modify the internal state of the Joke, in particular the ratings for a joke. Once the user has modified the rating for a Joke, the Joke object stops being an exact copy of the data in the database. If we want to make sure that the new rating gets preserved we must be able to write that rating change back to the database. You implemented this functionality in the previous lab, but not as a database.
The question is then whose job is it to write that change back to the database? Is it the job of the Joke object, the JokeView object, the Adapter, or the Activity itself? The JokeView and the Adapter are two very specialized classes, charged with specific tasks that don't really care about the persistent state of a Joke. The Adapter's job is to provide View objects for Jokes, and the JokeView's job is to show what a Joke looks like and provide controls for manipulating the internal state of the Joke.
That leaves two possibilities, the Joke and the Activity. While it is entirely acceptable to put the responsibility of persisting state on the Joke object, in this case we are going to follow the Model-View-Controller pattern and place that responsibility in the hands of the Activity. By doing this, we decouple the Joke class from the idea of a Database entirely. This allows the same Joke class to be used with or without a database (evidence of this is Lab 3 and this lab). But now we need some way for the JokeView to signal to whomever is responsible, that its Joke object has changed and needs to be written back to the database.
The JokeView is just going to put its foot down and say: "Whoever you are who is responsible: if you want to know when the Joke changes, then you've got to tell me. I'll let you know which method I'm going to call to notify you."
Thus, a static interface named OnJokeChangeListener has been added to the JokeView class. This interface specifies a callback method that gets called when a Joke's internal state changes. Additionally, each JokeView class now holds a single member variable reference to an OnJokeChangeListener. Any class that is interested in receiving notifications that a Joke has changed should implement the OnJokeChangeListener and register itself with the corresponding JokeView.
Open up JokeView.java:
Now it's the CursorAdapter's turn.
Open up JokeCursorAdapter.java and examine the code.
Great, now we're almost done. We just need to set up AdvancedJokeList to use our database and implement the OnJokeChangeListener.
5. Joke Persistence III: AdvancedJokeList & Loaders
The final step in this lab is to update AdvancedJokeList so that it completes the cycle of data transfer to and from the database and ListView children. Now we just have to integrate cursors and the application should work completely.
We initialized our CursorAdapter appropriately, but we need to integrate cursors into AdvancedJokeList. We can't use the above methods to manage it. Fortunately, there's good news in the form of a newer class: Loaders gets around the previous method's issues by using asynchronous handling, and as a bonus they manage their loaded data automatically for us. Conveniently, there is a CursorLoader class.
We are going to make AdvancedJokeList use the LoaderManager class to load and manage our CursorLoader asynchronously, and the CursorLoader class to load and handle cursors asynchronously.
5.1 The Loaders
Here we go! First up is the LoaderManager.
In AdvancedJokeList:
More information on the LoaderManager and how to implement the callback methods can be found here.
Next is the CursorLoader (that's the main documentation page, but make sure to import the support version in AdvancedJokeList).
in AdvancedJokeList:
5.2 Cursor & OnJokeChangeListener Integration
Time to take out the trash!
Out with the old, now in with the new:
Now you will finally invoke a majority of the content provider database operation wrapper methods whenever a joke is updated, added, removed or a new filter is chosen.
For your convenience, here is an overview of how data in the application gets passed around (click image for full size):
Run your application. It should behave in nearly exactly the same way as it did in Lab 3, only now all data should be persistent upon creation, destruction, and resumption of the application. The one difference is that jokes that get added are now automatically filtered (e.g., if adding a new joke and the current filter is set to Like or Dislike, then the new joke will be added but automatically filtered out).
If it works as indicated above, then congratulations! You got through a particularly nasty, confusing and dense concept. Hopefully this was an informative exercise in data persistence.
6. Deliverables
To complete this lab you will be required to:
Primary Authors: James Reed and Kennedy Owen
Adviser: Dr. David Janzen