Lab 4

Go to http://sites.google.com/site/androidappcoursev3/ for the most up-to-date versions of these labs.

Intro

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 an SQLite Database and preserving the state of an application during its lifecycle. 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:

  • How to save & restore data as Application Preferences
  • How to save & restore data as Instance State
  • How to create an SQLiteDatabase
  • How to manage database connections
  • How to insert, update, remove, and retrieve data from an SQLite Database
  • How to work with and manage Cursors
  • How to use CursorAdapters

Activities

For this lab we will be extending the "Joke List" application that you created in Lab3. This version of the app will add a persistence layer to the previous version. 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 as well 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.

1. Setting Up...

1.1 Creating the Project

To begin, download and extract the skeleton project for the JokeList application from Blackboard (under Course Materials). ***Non-Cal Poly students may contact Dr. Janzen (djanzen at calpoly.edu) directly for these files.

  • Extract the project, making sure to preserve the folder structure.
    • Take note of the path to the root folder of the skeleton project.
    • You may prefer to extract it to your Eclipse workspace directory.

Next you will need to setup a "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.

  • Select File -> Import.
  • In the Import Wizard, expand General and select Existing Projects into Workspace. Click Next.
  • In the Import Project wizard, click select root directory and click Browse. Select the root directory of the skeleton project that you extracted. Click Open and then Finish.
  • Click on the project name in the Package Explorer. Select File -> Rename and change the name of your project to lab4<userid> where <userid> is your user id (e.g. jsmith).
  • Check to make sure the package names are edu.calpoly.android.lab4 and edu.calpoly.android.lab4.tests, and that the build target is Android 1.6.

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 Lab3. Additionally, completed advanced.xml and joke_view.xml layout files have been supplied as well.

It is a good idea, and good practice, to read through the source code. See how it compares to your implementation of Lab3. It is especially important to do this if there were any parts of Lab3 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:

  • You must update the constructors to properly set m_nID:
    • For constructors that do not take in an ID parameter initialize m_nID to -1.
  • The equals(...) method now requires that only the ID values be equal for two Jokes to be equal.
  • There is a getter and setter that needs to be filled in for m_nID.

Run the JokeTest.java Unit Tests to ensure that you have properly filled in this class. The AdvancedJokeListTest2.java Unit Tests should run here at the beginning, but they will start failing later in the lab. You do not need to modify them to continue passing.

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 for 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.

It is important to note that this Bundle object might not contain anything if this is the initial creation of the activity. The onRestoreInstanceState 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 these next two sections you will be persisting the Filtering mechanism as instance state. 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:

2.1.1 Saving 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.

    • Override the Activity.onSaveInstanceState(Bundle outState) method.
    • Store the current value of m_nFilter in outState using the SAVED_FILTER_VALUE static constant string.
      • Use the appropriate Bundle.put... method.
    • Call the super version of this method to ensure that other UI state is preserved as well.
    • The default implementation of onSaveInstanceState(...) will save the state of any UI component in the Activity's content/layout hierarchy that has an id value. When you change the orientation of your device, this is why the state of basic UI components is remembered. Things like the text in m_vwJokeEditText and which Joke is in expanded mode.
    • If you fail to call the default super implementation, this will not occur.

2.1.3 Restoring Instance Data.

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.

    • Override the Activity.onRestoreInstanceState(Bundle savedInstanceState) method.
    • Retrieve the value of m_nFilter from savedInstanceState using the SAVED_FILTER_VALUE key.
      • It is best to test the savedInstanceState parameter and the values you retrieve from it before using them. Check for null and use the Bundle.containsKey(String key) method.
      • Use the appropriate Bundle.get... method.
    • Call the super version of this method to ensure that other UI state is preserved as well.
    • The default implementation of onRestoreInstanceState(...) will restore the state of any UI component in the Activity's content/layout hierarchy that has an id value. This is why when you change the orientation of your device the state of basic UI components is remembered. Things like the text in m_vwJokeEditText and which Joke is in expanded mode.
    • If you fail to call the default super implementation, this will not occur.
    • Re-filter your joke list to ensure that the proper jokes are displayed. This filtering should happen the same way it does when the user selects a filter MenuItem from the Filter-SubMenu.
      • The filtering logic is located below the onOptionsItemSelected(...) method, inside of the setAndUpdateFilter(int newFilterID) method.
      • newFilterID is the MenuItem ID of the new filtering option that should be used. This contains one of the four possible values: R.id.like_menuitem, R.id.dislike_menuitem, R.id.unrated_menuitem, or R.id.show_all_menuitem.
      • You need to call this method from onRestoreInstanceState(...) passing in m_nFilter.
    • Try running your application:
      • The default filter should be "Show All" when the application starts up.
      • Select Menu->Filter
        • The "Show All" MenuItem should be checked.
        • Select the "Dislike" MenuItem.
        • All the jokes should disappear since by default they are unrated at this point.
      • Change the orientation of your device, which will force your Activity to be destroyed.
        • No jokes should be displayed since the "Dislike" filter should have been saved, restored, and applied.
      • On an emulator you can do this by turning the "Num Lock" off on your keyboard, and then hitting the "7" key on your Number-Keypad. This will put you into Landscape orientation. Hitting the "9" key on your Number-Keypad switches you back to Portrait orientation.
      • If you're on a Laptop and don't have a Number-Keypad you may have to play around with your keyboard to get this to work, or you could plug in a USB keyboard from one of the Lab Machines.
      • Select Menu->Filter and the "Dislike" MenuItem should still be checked.

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:

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.

    • Override the Activity.onPause() method and perform the following steps inside this method. Making sure to call the default implementation.
    • Retrieve the private SharedPreferences belonging to this Activity by calling the getPreferences method.
    • See the Documenation on Activity.getPreferences(...) for details on how to do this.
    • Retrieve a SharedPreferences.Editor from the SharedPrefence object.
    • See the Documenation on SharedPreferences.edit() for details on how to do this.
    • Store the text in m_vwJokeEditText in the SharedPreferences by calling the appropriate Editor.put... method.
      • You should use the AdvancedJokeList.SAVED_EDIT_TEXT as the key.
    • Don't forget to commit your changes.

2.2.2 Restoring SharedPreference Data

The following should be done in the onCreate() method.

    • Retrieve the private SharedPreferences belonging to this Activity.
    • Retrieve the text that was saved in the onPause() method using the appropriate SharedPreferences.get... method.
    • The appropriate default value is an empty string "".
    • Set the text in m_vwJokeEditText to the text you just retrieved from the SharedPreferences.
    • Run your application to ensure that the text in m_vwJokeEditText is properly preserved across multiple runs of the application. See the figure in 2.2 for details on how to test this.

3. Binding Views to a Database

Your ultimate goal at the end of this lab is to be able to persist Jokes in a Database. In section 4 of this lab, you will be implementing the Database. Before you do that, however, it seems more pertinent to update your application to be able to use a database first. Proceeding in this manner will allow you to incrementally add Database functionality and then test it in section 4. Since the Database implementation will be more tricky, this seems like a better route than the alternative of implementing the Database in one-shot and incrementally updating your application to use it.

With that said, it may seem a little odd that you will be making use of Database related classes and methods that you either haven't implemented or haven't learned about yet. Fear not, you will learn about and implement the Database related stuff in the next section. For the time being, this section of the lab will act like these features are already implemented (i.e. there will be lots of hand-waving and urging you not to ask questions about the strange-man in the corner of the room).

In this section of the Lab you will be updating your application to bind the list of Jokes that are displayed, to the results of a database query, as well as write any new Jokes and changes to existing Jokes back to the Database. In particular, you will be creating a call back mechanism in the JokeView class which will notify the AdvancedJokeList Activity when the internal state of a Joke has changed and needs to be written back to the Database. You will then implement the JokeCursorAdapter. This class extends the CursorAdapter class and is very similar to the Adapter that you wrote in Lab 3. The main difference being that JokeCursorAdapter is bound to a Cursor object, which represents the results of a Database query. You will then update the AdvancedJokeList Activity class to use this new Adapter instead of the JokeListAdapter class that you wrote in Lab 3. Lastly, you will update the AdvancedJokeList to add Jokes to the Database instead of to an ArrayList<Joke> and to monitor and write back Joke ratings to the database. The last part will allow you to persist a list of Jokes across multiple application sessions.

3.1 JokeView.OnJokeChangeListener

I apologize in advance, this is a bit long winded, but it's a complicated subject. Especially for those who aren't well practiced with the Model-View-Controller, Observer/Observable, and Data Persistance patterns. If you already know this stuff, then feel free to skip this introduction.

When the list of Jokes is displayed, Jokes are copied from the Database into Joke Objects, which are then wrapped in JokeViews. These JokeViews allow the user to 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 rating gets preserved we must be able to write that rating change back to the 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? 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, the JokeView's job is to show what a Joke looks like and provide controls for manipulating the 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.

So 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. We could just add an arbitrary method to AdvancedJokeList and call it from the JokeView, however, this is a bit hacky. It is also foreseeable that we might even have a third highly specialized class whose only responsibility is persisting the state of Jokes, like a Data Transfer Object (DTO). With this in mind, its easy to see that the JokeView shouldn't even care who handles persistence or how they handle it since its not the JokeView's job to worry about such things. Why should the JokeView cater to the needs of arbitrary classes?

Instead, the JokeView is just going to put its foot down and say:

"If you want to know when the Joke changes, then you've got to tell me you're interested in such things. Furthermore, when the joke changes I'm gonna tell you 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.

3.1.1 Registering & Notifying OnJokeChangeListeners

The JokeView class now contains a member variable reference to a single OnJokeChangeListener, m_onJokeChangeListener. It should notify m_onJokeChangeListener any time the internal state of its Joke object changes. You must now maintain this reference and make sure it gets notified:

    • In the JokeView class, initialize the m_onJokeChangeListener to null in the constructor.
    • It is perfectly acceptable for this m_onJokeChangeListener to be null. No one might care if the Joke changes. You should keep this in mind and test for null when working with this reference.
    • Fill in the setOnJokeChangeListener(...) method.
    • Fill in the notifyOnJokeChangeListener() method.
      • Notify the m_onJokeChangeListener that the JokeView's Joke has changed.
    • There is only one way to do this. If you are confused, read the comments in the JokeView.OnJokeChangeListener interface.
    • Call notifyOnJokeChangeListener() whenever the internal state of the JokeView's Joke object changes.
    • This should happen when any of the Joke.set... mutator methods are called (i.e. Joke.setAuthor(...))
    • Currently this only happens in one method, when the rating is changed.

3.2 JokeCursorAdapter

The JokeCursorAdapter class will be responsible for providing JokeViews for Jokes in the same way that your JokeListAdapter did in Lab3. The key difference here is that the JokeCursorAdapter will use a Cursor object as its data source instead of an ArrayList<Joke>. A Cursor object is a set of results from a Database query. If you've ever worked with a Database, you can think of the Cursor as an iterator for a list of rows returned from a database query. You can move the cursor around in the list and retrieve data from a particular row. Each row contains all the data for a single joke, the text of the joke, the author, the rating and a unique id.

I strongly encourage you to read the sections of the Android Developer Guide on the CursorAdapter Class before beginning.

3.2.1 Fill in Basic JokeCursorAdapter Methods

    • Begin by filling in the constructor.
      • Set m_nSelectedID to Adapter.NO_SELECTION.
      • Set m_listener to null.
    • Fill in the getSelectedID() method.
      • Instead of maintaining the position in a list that is "selected" like the JokeListAdapter, the JokeCursorAdapter will maintain the ID of the Joke that is "selected".
      • This method replaces JokeListAdapter.getSelectedPosition().
    • Fill in the onItemLongClick(...) method.
      • Set m_nSelectedID equal to the id argument.
    • Fill in the setOnJokeChangeListener(...) method.
      • Set m_listener.
      • This onJokeChangeListener will be applied to every JokeView that this adapter creates/recycles.

3.2.2 Make JokeCursorAdapter Extend CursorAdapter

  • Make JokeCursorAdapter explicitly extend the android.widget.CursorAdapter class
  • Uncomment the line making a call to super(context, jokeCursor) in the constructor.
  • Add the necessary abstract methods:
    • public void bindView(View view, Context context, Cursor cursor)
    • public View newView(Context context, Cursor cursor, ViewGroup parent)
    • Instead of having one method that recycles old View objects and creates new View objects like the JokeListAdapter.getView(...) method class, the CursorAdapter separates this logic out into the two separate methods listed above.
  • Fill in bindView(...).
    • This method recycles JokeViews that have been previously created by this Adapter and are no longer in use.
    • Retrieve the Joke from the Cursor parameter.
      • Make a call to the static JokeDBAdapter.getJokeFromCursor(cursor) method.
      • This method (which you haven't implemented yet) parses the data from a database row that the Cursor object is pointing at, constructs a Joke object from the data, and returns it.
    • Set the recycled view parameter's Joke to the Joke object you just retrieved from the Cursor.
      • Hint: You can safely cast view to a JokeView object.
    • Set the recycled view parameter's onJokeChangeListener to m_listener.
  • Fill in newView(...).
    • This method creates new JokeViews.
    • Retrieve the Joke from the Cursor parameter like you did in bindView(...).
    • Set the JokeView's Joke to the Joke object you just retrieved from the Cursor.
    • Set the JokeView's onJokeChangeListener to m_listener.
    • Return the JokeView.

3.3 Updating AdvancedJokeList

For this section you will be updating AdvancedJokeList to use the the JokeDBAdapter Database class and JokeCursorAdapter class that you just implemented. You will start by initializing your database connection and updating the types on both m_jokeAdapter and m_arrJokeList to JokeCursorAdapter and Cursor respectively.

AdvancedJokeList will query the database for a list of Jokes to display, which will return a Cursor. This Cursor is what you will use in place of the ArrayList<Joke> that you used in Lab 3. Its worth noting that this will make filtering much easier, since any time a filter is changed you simple have to re-query the database and get a new Cursor. Consequently, you will have to update your filtering functionality.

Lastly, you will be updating all of the JokeList maintenance methods. Specifically, you will update the addJoke(...) method, the Remove Joke Context-MenuItem, the Upload Joke Context-MenuItem, and make AdvancedJokeList save changes to Jokes in the database. You will do this last part by making AdvancedJokeList implement the onJokeChangeListener interface so that it can monitor and write back Joke changes to the database.

3.3.1 Initializing JokeDBAdapter, JokeCursorAdapter, & Cursor

  • Change the type on m_arrJokeList from ArrayList<Joke> to Cursor
  • Change the type on m_jokeAdapter from JokeListAdapter to JokeCursorAdapter.
  • In the onCreate(...) method:
    • Initialize m_jokeDB.
      • m_jokeDB is a reference to a JokeDBAdapter object, which represents the connection to your Database. Any interaction with the Database will be done through m_jokeDB.
      • Set m_jokeDB equal to a new JokeDBAdapter object. The constructor takes a reference to an application Context object, which is this.
    • Open the connection to the Database by calling m_jokeDB.open().
    • Since the Filter is set to "SHOW_ALL" by default when the activity starts up, you will need to retrieve a Cursor pointing to a list of all the jokes in the Database.
      • Retrieve a Cursor object for all Jokes in the Database by calling m_jokeDB.getAllJokes().
      • Set m_arrJokeList equal to the Cursor you just retrieved.
    • A Cursor object represents an open connection to the Database, and has a lifecycle just like an Activity does. When the Activity closes, we want to make sure that all of our Database connections close as well. Luckily, the Activity class provides a mechanism for managing the lifecycle of a Cursor so that it falls in line with the lifecycle of the Activity.
      • Have AdvancedJokeList manage the lifecycle of m_arrJokeList by calling startManagingCursor(...) and pass in m_arrJokeList.
    • Initialize m_jokeAdapter to a new JokeCursorAdapter.
      • Make sure that m_jokeAdapter binds to m_arrJokeList by passing m_arrJokeList into the constructor.
    • Remove the code that initializes your list of Jokes from the joke-string resource values. Since jokes will be persisted in the database you no longer need to prime you application with test jokes.

3.3.2 Update Your Filtering Functionality

Since the master copy of all your jokes will now live in the Database, and m_arrJokeList is now a copy of that list, there is no need for an extra mechanism to keep track of which jokes should be displayed to the user. You need to remove the extra mechanism being used to filter Jokes so that all the jokes in m_arrJokeList will get displayed to the user. When you need to change the filter, you simply retrieve a different Cursor from the Database and update m_arrJokeList to reference the new cursor.

In this section you will be updating the filtering logic to retrieve a new Cursor from the database instead of manually filtering the Jokes. The filtering logic will be self-contained in a single method called AdvancedJokeList.setAndUpdateFilter(...). Perform the following modifications in that method:

  • Since we are getting a new Cursor from the Database, tell AdvancedJokeList to stop managing the lifecycle of our current Cursor and close it.
    • Make a call stopManagingCursor(...), passing in m_arrJokeList.
    • call close() on m_arrJokeList.
  • Retrieve a new Cursor from m_jokeDB, update m_arrJokeList to use it, and make sure AdvancedJokeList manages its lifecycle.
    • call m_jokeDB.getAllJokes(...), passing in the new filter value as a string, or null if the filter is SHOW_ALL.
      • You will have to update your logic to choose the correct new filter value as a string.
      • The overriden JokeDBAdapter.getAllJokes(String filterVal) method takes a string containing the filter value and returns a Cursor of all jokes that have a rating equal to the filter value.
      • The filterVal string passed in should contain one of the Joke classes "static-final" rating values. Either Joke.LIKE, Joke.DISLIKE, or Joke.UNRATED. Alternatively if you set filterVal to null then the method will not filter out any jokes and will return a Cursor of every Joke in the Database.
    • Set m_arrJokeList equal to the Cursor you just retrieved.
    • Make a call to startManagingCursor(...), passing in m_arrJokeList.
  • Since we have changed the Cursor that we are using, we now need to update m_jokeAdapter to use this new cursor as well.
    • Call m_jokeAdapter.changeCursor(...) passing in the new Cursors.
    • Be aware that calling changeCursor(...) will cause the Adapter to close whatever Cursor the Adapter was previously using. You were asked to explicitly close() the cursor yourself to get practice doing that.

3.3.3 Maintaining the JokeList

Update the addJoke(...) method so that it inserts the Joke parameter passed into the Database and have the Cursor refresh itself.

    • Instead of adding the new Joke into m_arrJokeList, call m_jokeDB.insertJoke(...) passing in the new Joke.
    • Call requery() on m_arrJokeList to have the Cursor refresh itself.

Update the Remove Joke Context-MenuItem so that the Joke is properly removed from the Database and have the Cursor refresh itself. These changes should be made to the Remove MenuItem's OnMenuItemClickListener.

    • Retrieve the ID of the selected joke from m_jokeAdapter by calling its getSelectedID() method.
    • pass the ID of the Joke to m_jokeDB.removeJoke(...).
    • Requery m_arrJokeList.

Update the Upload Joke Context-MenuItem to get the Joke from m_jokeDB instead of m_arrJokeList. These changes should be made to the Upload MenuItem's OnMenuItemClickListener.

    • Get the ID of the selected joke from m_jokeAdapter.
    • Retrieve the Joke from the Database passing the Joke ID into m_jokeDB.getJoke(...).
    • Upload the Joke as you did before.

Make AdvancedJokeList monitor changes to any Joke and save them back into the database. Do this by making AdvancedJokeList implement the JokeView.OnJokeChangeListener interface and setting the m_jokeAdapter's OnJokeChangeListener to AdvancedJokeList.

    • Implement the JokeView.OnJokeChangeListener interface, then create and fill in the required onJokeChanged(JokeView view, Joke joke) method:
      • This method takes as a parameter the JokeView that contains the Joke that is being changed, as well as the Joke object that was changed.
    • Pass the Joke object that was changed into m_jokeDB.updateJoke(...).
    • Requery m_arrJokeList.
    • Set m_jokeAdapter's OnJokeChangeListener to reference this instance of AdvancedJokeList Activity by calling its setOnJokeChangeListener(...) method.
      • This should be done in the AdvancedJokeList.onCreate(...) method, just below the initialization of m_jokeAdapter.

And you are done updating your JokeList Application to persist Jokes to a Database. Now all thats left is to implement your JokeDBAdapter Database class. Before we go on, I encourage you to test your application using the JokeCursorAdapterTest provided. This set of unit tests uses a provided database implementation (the same as what you will be completing in the next section). To use it, you will need to import the JokeDBAdapter (import edu.calpoly.android.lab4_key.JokeDBAdapter;) from the key in your AdvancedJokeList and JokeCursorAdapter classes. After testing, you can remove these imports so you use your own versions of these classes.

4. Databases & Using Persistent Data

I strongly encourage you to read the sections of the Android Developer Guide on Data Storage. In this fourth and final section you will be implementing the class that actually saves your Jokes to a database, making them persistent across multiple user sessions. The class is named the JokeDBAdapter class. JokeDBAdapter wraps all of the functionality necessary to save, update, delete, and retrieve Jokes. In addition to this, it also wraps the functionality necessary to create and update the Database itself and manage connections to it.

You will now be tasked with implementing all of this functionality. The goal of this section of the lab is teach you how to work with an SQLiteDatabase and the general approach for wrapping data-persistence into a single class. The goal is not to teach you SQL. With this in mind, most of the actual SQL statements necessary to create and work with a Database have been written for you and placed into static-final-strings that you can use.

4.1 Creating an SQLiteDatabase & Managing Connections

The JokeDBAdapter class provides an interface for performing common database operations like inserting data, updating data, removing data, and retrieving data. Additionally, it allows you to open a connection to the database as well. You can think of opening a database connection like opening a file. You can open a file for reading or you can open a file for writing. The JokeDBAdapter manages these connections as well.

Also inside the JokeDBAdapter class is a static inner class called JokeDBHelper. This helper solves a problem that arises when trying to open a database connection. When you install the application and run it for the first time your database will not exist. You need to execute database creation code on this first run of the application before you can use the database and open a connection. However, all subsequent runs of the application don't need to execute this database creation code. A similar problem occurs if you need to upgrade your database to a new version.

So how do you know whether your database already exists, or whether you need to execute the database creation code? The SQLiteOpenHelper abstract class solves this problem for you. By extending this class and implementing the abstract onCreate(...) and onUpgrade(...), you can test whether the database exists and conditionally execute creation or upgrade code before opening a connection. So, if you haven't guessed it by now, the static inner JokeDBHelper realizes the abstract SQLiteOpenHelper class.

4.1.1 Fill in the JokeDBAdapter.JokeDBHelper Static Inner Class

JokeDBAdapter will request database connections from JokeDBHelper. When a connection is requested, JokeDBHelper will test whether the database exists or needs to be updated, and take whatever actions are necessary before opening the connection. If the database needs to be created, then it will call its JokeDBHelper.onCreate(...) method. If the database needs to upgraded, it will call its JokeDBHelper.onUpgrade(...) method. For more details on this method see the Android Documentation on SQLiteOpenHelper. You will now implement these methods.

    • Fill in the JokeDBAdapter.JokeDBHelper.onCreate(...) method.
      • Execute the Database Creation SQL statement by calling the execSQL(...) method on the SQLiteDatabase parameter and passing in JokeDBAdapter.DATABASE_CREATE.
      • execSQL(...) will execute an SQL statement passed into it as a string. See the Android Documentation on SQLiteDatabase.execSQL(...) for more details on this method.
      • JokeDBAdapter.DATABASE_CREATE is a static final string that contains the Database Creation SQL statement. Go ahead and look at this SQL statement to see what the Joke database looks like.
    • Fill in the JokeDBAdapter.JokeDBHelper.onUpgrade(...) method.
    • What you would normally do in this method is test which versions you are upgrading to and from. You could then conditionally modify the database to migrate it to the new schema. In our case, we will simply be deleting the existing database and creating the new one.
      • Execute the Database Removal SQL statement by calling the execSQL(...) method on the SQLiteDatabase parameter and passing in JokeDBAdapter.DATABASE_DROP.
      • Make a call to onCreate(...) to create the new database, passing in the SQLiteDatabase parameter from onUpgrade(...).

4.1.2 Fill JokeDBAdapter Constructor, Open, & Close

Now that the JokeDBHelper class has been established, JokeDBAdapter can use it to open database connections. JokeDBAdapter has a JokeDBHelper member variable aptly name m_dbHelper. You will have to maintain this member variable via the constructor. JokeDBAdapter also has an SQLiteDatabase member variable named m_db that represents your connection to the database. You will manage this connection via the open() and close() methods.

    • In the JokeDBAdapter constructor:
      • Initialize m_dbHelper to a new JokeDBHelper object.
        • Pass in the same application Context object that the JokeDBAdapter constructor received as a parameter.
        • Pass in the JokeDBAdapter.DATABASE_NAME string constant as the name of the database.
        • Pass in null for the CursorFactory object. This tells SQLiteOpenHelper to use the default CursorFactory (Don't worry about this).
        • Pass in the JokeDBAdapter.DATABASE_VERSION integer constant as the database version number. SQLiteOpenHelper will compare this version number against the existing database's version number (if it exists) to determine whether JokeDBHelper.onUpdate(...) needs to be called.
    • In JokeDBAdapter.open():
      • Retrieve a writable SQLiteDatabase object from m_dbHelper.
        • Do this by calling getWritableDatabase() on m_dbHelper.
      • Alternatively, you could retrieve a read-only SQLiteDatabase by calling getReadableDatabase().
      • set m_db equal to the SQLiteDatabase you just retrieved.
    • The reason why you don't open the database connection in the constructor is that you may initialize the JokeDBAdapter long before you ever need or want to use the database connection. You may also open and close the database multiple times to minimize the length of time in which the connection is open. Doing it this way allows you to explicitly open and close the database while only instantiating a single JokeDBAdapter object.
    • In JokeDBAdapter.close():
      • Close the SQliteDatabase, m_db, by calling its close() method.

Try running your application to make sure that you don't get any errors. You won't be able to view, add, remove, or update jokes, but you shouldn't get any errors at this point.

4.2 Adding Data to an SQLiteDatabase

You will now implement functionality for adding Jokes. You won't be able to test this until you finish section 4.3.1 which enables you to retrieve jokes, but it's coming up quick so sit tight. Functionality for adding Jokes to the database is encapsulated in the JokeDBAdapter.insertJoke(...) method which you used when updating your AdvancedJokeList.addJoke(...) method.

Jokes are stored in the Database in a single table. The table has an attribute, or column, for each member variable of the Joke. Uniqueness of a Joke is determined by its ID member variable. This value is maintained by the database. When a Joke gets added to the database, the database generates a unique id for it. You can see what the table looks like in the diagram below:

When inserting Jokes into the database you will use an android.content.ContentValues object. For complete background on this class see the Android Documentation on ContentValues. Essentially, a ContentValues object is a key/value map in which you store each piece of the Joke using its corresponding database table attribute name, or column name, as the key. For example, you would store the Joke's rating value under a key of "rating".

4.2.1 Fill in insertJoke(Joke joke)

  • Create a new ContentValues object.
  • Put the text of the Joke in the ContentValues object using JokeDBAdapter.JOKE_KEY_TEXT as the key.
    • Use the ContentValues.Put(...) method.
  • Put the rating of the Joke in the ContentValues object using JokeDBAdapter.JOKE_KEY_RATING as the key.
  • Put the author of the Joke in the ContentValues object using JokeDBAdapter.JOKE_KEY_AUTHOR as the key.
  • Since this is a new Joke, the ID hasn't been assigned to it by the database yet, so you do not put the ID in the ContentValues object.
  • Insert the new Joke into the database by calling insert(...) on m_db.
    • Pass into this method JokeDBAdapter.DATABASE_TABLE_JOKE as the table name to insert the joke into.
    • Pass in null as the "nullColHack" parameter (Don't worry about this).
    • Pass in the ContentValues object you just created.
    • Read the Android Documentation on SQLiteDatabase for a complete description of the method.
  • The insert(...) method returns the ID of the newly inserted Joke, or -1 if the insert was unsuccessful. Make insertJoke(...) return this value.

4.3 Querying an SQLiteDatabase

In order to retrieve jokes from the database, you must execute what is known as a query. A query tells the database to find specific rows in the database as well as which columns should be returned in the result of the query. The result of an SQLiteDatabase query is a Cursor object, which you used in previous changes you made to the JokeList Application. Another way to think of the Cursor is as a subset of the Joke database table. This subset may contain all, none, or some of the rows from the actual Joke database table. Additionally, it may contain all, none, or some of the columns from the Joke database table.

The result of the query depends entirely on the query itself. To perform a query, you use the overloaded SQLiteDatabase.query(...) method. The simplest form of this method takes in the name of the table you want to query and an array containing the names of the columns for which you want information returned. It takes a number of other arguments as well, which have the option of being null. See the Android Documentation on SQLiteDatabase for a complete description of this method.

There is one other argument that this method takes that is of importance. This is the selection string argument. The selection argument allows you to tell the database to only return joke rows that meet a certain criteria. In particular, you can build a string to tell the database to only return joke rows that have a certain rating.

4.3.1 Fill in getAllJokes(String ratingFilter)

This method returns all rows of Jokes in the database that have a rating equal to ratingFilter. However, if the ratingFilter parameter is null, this method should return every Joke row in the database with no exclusions.

  • Create a local selection string variable.
  • Test the ratingFilter parameter for null.
    • If null, then set your selection string to null.
      • If the selection string is null, then all rows will be returned.
    • Else, set your selection string to:
      • JOKE_KEY_RATING + "=" + ratingFilter;
      • This is evaluated by the database like an "if" statement. If the row in the database has a JOKE_KEY_RATING column value equal to rating filter it will be returned.
  • Call the following method on m_db
    • SQLiteDatabase.query (String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy)
    • Pass in JokeDBAdapter.DATABASE_TABLE_JOKE as the table name.
    • Pass in a string array containing the JokeDBAdapter.JOKE_KEY_ID, JokeDBAdapter.JOKE_KEY_TEXT, JokeDBAdapter.JOKE_KEY_RATING, and JokeDBAdapter.JOKE_KEY_AUTHOR column names.
    • Pass in your local selection string variable.
    • Pass in null for everything else.
  • The query method will return a Cursor object containing the results of the query. Make getAllJokes(...) return the result of the query method.

4.3.2 Fill in getAllJokes()

This is an overloaded version of the previous method that returns a cursor containing all Joke rows in the database. Fill in this method so that it returns the result of getAllJokes(null).

4.3.3 Fill in getJokeFromCursor(Cursor cursor)

This static helper method is used to translate a Joke database table row into a Joke object. It should translate the row where the Cursor is currently pointing.

    • First and foremost, there is no guarantee that the Cursor given to you as a parameter is not null and not empty. You should test for both of these conditions and return null in the event that either are true.
    • Construct a new Joke object and initialize all of its member variables with data from the cursor.
      • To retrieve data from the Cursor, call the appropriate get method, passing in the Joke table column index corresponding to the member variable data that you want.
      • The column indexes are JOKE_COL_ID, JOKE_COL_TEXT, JOKE_COL_AUTHOR, JOKE_COL_RATING.
    • Return the Joke object you just created.

You can now run your application. You should now be able to add Jokes and have them appear in your application. If you close the application and restart it, the Jokes that you previously entered should still be there.

4.3.4 Fill in getJoke(long id)

This method retrieves a single Joke table row from the database. The row retrieved is the row whose JOKE_KEY_ID column value equals the id parameter passed in. This is guaranteed to return one row since no two rows share the same JOKE_KEY_ID column value. This method should return a Joke object constructed from the row returned by the query.

Fill in this method on your own.

  • Execute a query using a selection string like you did for the getAllJokes(String ratingFilter) method.
  • NOTE: The Cursor that is returned from query(...) is positioned before the first result/row. You must call cursor.moveToNext() before using the cursor or you will get an IndexOutOfBoundsException for trying to use a cursor positioned at -1.
  • Construct the Joke object to return like you did in getJokeFromCursor(Cursor cursor).

After filling this method in, you should be able to upload jokes to the Server, which is the only piece of functionality that uses this method.

4.4 Editing Data in an SQLiteDatabase

You are almost done. You only need to implement two more pieces of functionality. You need to be able to update the contents of a Joke in the database and you need to be able to remove a Joke from the Database.

4.4.1 Fill in removeJoke(Joke joke)

The remove method operates similarly to the getJoke(...) method in that your goal is to identify a single Joke row in the database.

    • Create a selection string to identify the Joke row in the database whose JOKE_KEY_ID column value matches the id of the Joke parameter passed into removeJoke(...).
    • Call the delete(...) method on m_db.
      • Pass in DATABASE_TABLE_JOKE as the table name.
      • Pass in your selection string.
      • Pass in null for the final argument.
    • The delete(...) method returns a count of the number of rows that were deleted as a result of making this call. If the result is greater than 0, removeJoke(...) should return true, otherwise it should return false.
    • Run your application. You should now be able to permanently remove Jokes from the database via the Remove Context MenuItem.

4.4.2 Fill in updateJoke(Joke joke)

The update method should identify a single Joke row in the database and replace all of its column values with the values in the Joke parameter passed into updateJoke(...).

    • Create a ContentValues object and place the contents of the Joke parameter into it like you did for the insertJoke(...) method.
      • Do not put the ID value into your ContentValues object. You do not want to change this.
    • Create a selection string to identify the Joke row in the database whose JOKE_KEY_ID column value matches the id of the Joke parameter.
    • Call the update(...) method on m_db.
      • Pass in DATABASE_TABLE_JOKE as the table name.
      • Pass in your ContentValues object.
      • Pass in your selection string.
      • Pass in null for the final argument.
    • The update(...) method returns a count of the number of rows that were updated as a result of making this call. If the result is greater than 0, updateJoke(...) should return true, otherwise it should return false.
    • Run your application. Changes to Joke ratings should now be persistant.

5. Deliverables

To complete this lab you will be required to:

  1. Put your entire project directory into a .zip file, similar to the stub you were given. Submit the archive using handin on a Cal Poly Linux server. The name of your archive should be lab4<cal-poly-username>.zip. So if your username is jsmith, then your file would be named lab4jsmith.zip. You would submit this file with the command handin djanzen 409Lab4 lab4jsmith.zip.
  2. Complete the following survey for Lab 4:
  3. http://www.surveymonkey.com/s/92RP6BS

Primary Author: James Reed

Adviser: Dr. David Janzen