Even in the midst of grand architectures designed to shift as much user data into the cloud as possible, the transient nature of mobile applications will always require that at least some user data be persisted locally on the device. This data may range from cached responses from a web service guaranteeing offline access to preferences that the user has set for specific application behaviors. Android provides a series of helpful frameworks to take the pain out of using files and databases to persist information.
6-1. Making a Preference Screen
Problem
You need to create a simple way to store, change, and display user preferences and settings within your application.
Solution
(API Level 1)
Use the PreferenceActivity and an XML Preference hierarchy to provide the user interface, key/value combinations, and persistence all at once. Using this method will create a user interface that is consistent with the Settings application on Android devices, and it will keep users’ experiences consistent with what they expect.
Within the XML, an entire set of one or more screens can be defined with the associated settings displayed and grouped into categories by using the PreferenceScreen, PreferenceCategory, and associated Preference elements. The activity can then load this hierarchy for the user by using very little code.
How It Works
Listings 6-1 and 6-2 show the basic settings for an Android application. The XML defines two screens with a variety of all the common preference types that this framework supports. Notice that one screen is nested inside the other; the internal screen will be displayed when the user clicks on its associated list item from the root screen.
Listing 6-1. res/xml/settings.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<EditTextPreference
android:key="namePref"
android:title="Name"
android:summary="Tell Us Your Name"
android:defaultValue="Apress" />
<CheckBoxPreference
android:key="morePref"
android:title="Enable More Settings"
android:defaultValue="false" />
<PreferenceScreen
android:key="moreScreen"
android:title="More Settings"
android:dependency="morePref">
<ListPreference
android:key="colorPref"
android:title="Favorite Color"
android:summary="Choose your favorite color"
android:entries="@array/color_names"
android:entryValues="@array/color_values"
android:defaultValue="GRN" />
<PreferenceCategory
android:title="Location Settings">
<CheckBoxPreference
android:key="gpsPref"
android:title="Use GPS Location"
android:summary="Use GPS to Find You"
android:defaultValue="true" />
<CheckBoxPreference
android:key="networkPref"
android:title="Use Network Location"
android:summary="Use Network to Find You"
android:defaultValue="true" />
</PreferenceCategory>
</PreferenceScreen>
</PreferenceScreen>
Listing 6-2. res/values/arrays.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="color_names">
<item>Black</item>
<item>Red</item>
<item>Green</item>
</string-array>
<string-array name="color_values">
<item>BLK</item>
<item>RED</item>
<item>GRN</item>
</string-array>
</resources>
Notice first the convention used to create the XML file. Although this resource could be inflated from any directory (such as res/layout), the convention is to put them into a generic directory for the project titled simply xml.
Also, notice that we provide an android:key attribute for each Preference object instead of android:id. When each stored value is referenced elsewhere in the application through a SharedPreferences object, it will be accessed using the key. In addition, PreferenceActivity includes the findPreference() method for obtaining a reference to an inflated Preference in Java code, which is more efficient than using findViewById(); findPreference() also takes the key as a parameter.
When inflated, the root PreferenceScreen presents a list with the following three options (in order):
When the user taps the More Settings item, a second screen is displayed with three more items: a ListPreference item and two more CheckBoxPreferences grouped together by a PreferenceCategory. PreferenceCategory is simply a way to create section breaks and headers in the list for grouping actual preference items.
The ListPreference is the final preference type used in the example. This item requires two array parameters (although they can both be set to the same array) that represent a set of choices the user may pick from. The android:entries array is the list of human-readable items to display, while the android:entryValues array represents the actual value to be stored.
All the preference items may optionally have a default value set for them as well. This value is not automatically loaded, however. It will load the first time this XML file is inflated when the PreferenceActivity is displayed or when a call to PreferenceManager.setDefaultValues() is made.
Now let’s take a look at how a PreferenceActivity would load and manage this. See Listing 6-3.
Listing 6-3. PreferenceActivity in Action
public class SettingsActivity extends PreferenceActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Load preference data from XML
addPreferencesFromResource(R.xml.settings);
}
}
All that is required to display the preferences and allow the user to make changes is a call to addPreferencesFromResource(). There is no need to call setContentView() when we extend PreferenceActivity; addPreferencesFromResource() inflates the XML and manages displaying the content in a list. However a custom layout may be provided as long as it contains a ListView with the android:id="@android:id/list" attribute set, which is where PreferenceActivity will load the preference items.
Preference items can also be placed in the list for the sole purpose of controlling access. In the example, we put the Enable More Settings item in the list just to allow the user to enable or disable access to the second PreferenceScreen. In order to accomplish this, our nested PreferenceScreen includes the android:dependency attribute, which links its enabled state to the state of another preference. Whenever the referenced preference is either not set or false, this preference will be disabled.
When this activity loads, you see something like Figure 6-1.
Figure 6-1. The root PreferenceScreen (left) displays first. If the user taps More Settings, the secondary screen (right) displays
Loading Defaults and Accessing Preferences
Typically, a PreferenceActivity such as this one is not the root of an application. Often, if default values are set, they may need to be accessed by the rest of the application before the user ever visits Settings (the first case under which the defaults will load). Therefore, it can be helpful to put a call to the following method elsewhere in your application to ensure that the defaults are loaded prior to being used.
PreferenceManager.setDefaultValues(Context context, int resId, boolean readAgain);
This method may be called multiple times, and the defaults will not get loaded over again. It may be placed in the main activity so it is called on first launch, or perhaps it could be in a common place where the application can call it before any access to shared preferences.
Preferences that are stored by using this mechanism are put into the default shared preferences object, which can be accessed with any Context pointer by using the following:
PreferenceManager.getDefaultSharedPreferences(Context context);
An example activity that would load the defaults set in our previous example and access some of the current values stored would look like Listing 6-4.
Listing 6-4. Activity Loading Preference Defaults
public class HomeActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
//Load the preference defaults
PreferenceManager.setDefaultValues(this, R.xml.settings, false);
}
@Override
public void onResume() {
super.onResume();
//Access the current settings
SharedPreferences settings =
PreferenceManager.getDefaultSharedPreferences(this);
String name = settings.getString("namePref", "");
boolean isMoreEnabled = settings.getBoolean("morePref", false);
}
}
Calling setDefaultValues() will create a value in the preference store for any item in the XML file that includes an android:defaultValue attribute. This will make those defaults accessible to the application, even if the user has not yet visited the settings screen.
These values can then be accessed using a set of typed accessor functions on the SharedPreferences object. Each of these accessor methods requires both the name of the preference key and a default value to be returned if a value for the preference key does not yet exist.
(API Level 11)
Starting with Android 3.0, a new method of creating preference screens was introduced in the form of PreferenceFragment. This class is not in the Support Library, so it can be used only as a replacement for PreferenceActivity if your application targets a minimum of API Level 11. Listings 6-5 and 6-6 modify the previous example to use PreferenceFragment instead.
Listing 6-5. Activity Containing Fragments
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
FragmentTransaction ft = getFragmentManager().beginTransaction();
ft.add(android.R.id.content, new PreferenceFragment());
ft.commit();
}
}
Listing 6-6. New PreferenceFragment
public class SettingsFragment extends PreferenceFragment {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Load preference data from XML
addPreferencesFromResource(R.xml.settings);
}
}
Now the preferences themselves are housed inside a PreferenceFragment, which manages them in the same way as before. The other required change is that a fragment cannot live on its own; it must be contained inside an activity, so we have created a new root activity where the fragment is attached.
The Android framework has moved to Fragments for preferences in order to more easily allow multiple preference hierarchies (perhaps representing different top-level categories of settings) to be easily displayed inside a single activity rather than forcing the user to jump in and out of each category with multiple activity instances.
As of Android 4.4, a PreferenceActivity must override the isValidFragment() method in applications targeting SDK Level 19 or higher. This method prevents external applications from performing fragment injection by supplying an incorrect class name in the Intent extras directed at an exported PreferenceActivity that hosts PreferenceFragment instances.
In applications with a lower SDK target, this method will always return true for compatibility, which also leaves the security hole open. It is prudent for developers to update their target to 19+ and implement this method to validate that only Fragments you expect can be instantiated. If isValidFragment() is not overridden on an app with an updated target SDK, an exception will be thrown.
6-2. Displaying Custom Preferences
Problem
The Preference elements provided by the framework are not flexible enough, and you need to add a more specific UI for modifying the value.
Solution
(API Level 1)
Extend Preference, or one of its subclasses, to integrate a new type into a PreferenceActivity or PreferenceFragment. When creating a new preference type, there are two major objectives you need to keep in mind: how to provide an interface to the user for modifying the preference, and how to persist their selection back into SharedPreferences.
With regards to the user interface, there are several callback methods you may want to override. Notice that they use a similar pattern to the adapters we see in ListView:
Basic preferences in the framework, such as CheckBoxPreference, simply toggle the persisted state on each click. Other preferences, such as EditTextPreference or ListPreference, are subclasses of DialogPreference, which use the click event to display a dialog to provide a more complex UI for updating the given setting.
The second set of overrides you may have in a custom Preference deal with retrieving and persisting data:
Anytime that your preference needs to read the current value saved in SharedPreferences, you can invoke one of the typed getPersistedXxx() methods to return the value type your preference is persisting (integer, boolean, string, and so forth). Conversely, when the preference needs to save a new value, you can use the typed persistXxx() methods to update SharedPreferences.
How It Works
In the following example, we create a ColorPreference: a simple extension of DialogPreference that provides the user interface to select a color as three sliders that provide the RGB values discretely. Similar to ListPreference, an AlertDialog will display when the preference is selected from the list. This is where the user will make their selection and save or cancel the change, rather than in the list UI directly (as with CheckBoxPreference, for example). Listing 6-7 shows the layout for our custom dialog box, followed by Listing 6-8, which reveals our ColorPreference implementation.
Listing 6-7. res/layout/preference_color.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:minWidth="300dp"
android:orientation="vertical" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Red" />
<SeekBar
android:id="@+id/selector_red"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="255" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Green" />
<SeekBar
android:id="@+id/selector_green"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="255" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Blue" />
<SeekBar
android:id="@+id/selector_blue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="255" />
</LinearLayout>
Listing 6-8. Custom Preference Definition
public class ColorPreference extends DialogPreference {
private static final int DEFAULT_COLOR = Color.WHITE;
/* Local copy of the current color setting */
private int mCurrentColor;
/* Sliders to set color components */
private SeekBar mRedLevel, mGreenLevel, mBlueLevel;
public ColorPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
/*
* Called to construct a new dialog to show when the preference
* is clicked. We create and set up a new content view for
* each instance.
*/
@Override
protected void onPrepareDialogBuilder(Builder builder) {
//Create the dialog's content view
View rootView =
LayoutInflater.from(getContext()).inflate(R.layout.preference_color, null);
mRedLevel = (SeekBar) rootView.findViewById(R.id.selector_red);
mGreenLevel = (SeekBar) rootView.findViewById(R.id.selector_green);
mBlueLevel = (SeekBar) rootView.findViewById(R.id.selector_blue);
mRedLevel.setProgress(Color.red(mCurrentColor));
mGreenLevel.setProgress(Color.green(mCurrentColor));
mBlueLevel.setProgress(Color.blue(mCurrentColor));
//Attach the content view
builder.setView(rootView);
super.onPrepareDialogBuilder(builder);
}
/*
* Called when the dialog is closed with the result of
* the button tapped by the user.
*/
@Override
protected void onDialogClosed(boolean positiveResult) {
if (positiveResult) {
//When OK is pressed, obtain and save the color value
int color = Color.rgb(
mRedLevel.getProgress(),
mGreenLevel.getProgress(),
mBlueLevel.getProgress());
setCurrentValue(color);
}
}
/*
* Called by the framework to obtain the default value
* passed in the preference XML definition
*/
@Override
protected Object onGetDefaultValue(TypedArray a, int index) {
//Return the default value from XML as a color int
ColorStateList value = a.getColorStateList(index);
if (value == null) {
return DEFAULT_COLOR;
}
return value.getDefaultColor();
}
/*
* Called by the framework to set the initial value of the
* preference, either from its default or the last persisted
* value.
*/
@Override
protected void onSetInitialValue(boolean restorePersistedValue, Object defaultValue) {
setCurrentValue( restorePersistedValue ?
getPersistedInt(DEFAULT_COLOR) : (Integer)defaultValue );
}
/*
* Return a custom summary based on the current setting
*/
@Override
public CharSequence getSummary() {
//Construct the summary with the color value in hex
int color = getPersistedInt(DEFAULT_COLOR);
String content = String.format("Current Value is 0x%02X%02X%02X",
Color.red(color), Color.green(color), Color.blue(color));
//Return the summary text as a Spannable, colored by the selection
Spannable summary = new SpannableString (content);
summary.setSpan(new ForegroundColorSpan(color), 0, summary.length(), 0);
return summary;
}
private void setCurrentValue(int value) {
//Update latest value
mCurrentColor = value;
//Save new value
persistInt(value);
//Notify preference listeners
notifyDependencyChange(shouldDisableDependents());
notifyChanged();
}
}
When the ColorPreference is first created from XML, onGetDefaultValue()will be called with the android:defaultValue attribute (if one was added) so we can parse it. We want to allow any color attribute to be used, so we read the attribute’s value using getColorStateList(), which supports reading color strings and references to color resources, and return the result (which will be an integer).
Later, when the preference is attached to the activity, onSetInitialValue() will tell us whether we should read that default value in or use a value already saved in SharedPreferences. On the first run, we will choose the default, while each attempt after that will read the saved value using getPersistedInt(). The parameter to getPersistedInt() is the default value we should use if the persisted value doesn’t exist or can’t be read as an integer.
For the user interface, rather than monitoring onClick(), we have two new callbacks provided by DialogPreference: onPrepareDialogBuilder()and onDialogClosed(). The former is triggered with an AlertDialog.Builder instance so we can customize the dialog box to be shown when a user clicks the preference; this will happen on each new click. We are using this method to inflate and attach our dialog box layout containing the three sliders—one each for the red, green, and blue components.
When we receive onDialogClosed(), we are told whether the user selected OK to save the preference or Cancel to revert the change. In the positive case, we want to create the new color from the UI sliders and persist the value using persistInt(). In the negative case, we take no action to change the current setting.
Finally, whenever a new change is persisted, we call notifyDependencyChange() and notifyChanged()to alert any preference listeners of the update. This also alerts the PreferenceActivity to update the list display.
We have made one final customization using getSummary(). In this example, we didn’t provide a completely new layout, but rather we are customizing the summary display to include the current color selection (as a hex string) and that text will be colored with the selection. We can do this because getSummary() returns a CharSequence (instead of a pure String) allowing styled Spannable types to be returned.
With our new preference constructed, we can simply add it to an XML definition of a <PreferenceScreen> alongside other standard preferences, just as we saw in the previous recipe (see Listings 6-9 and 6-10).
Listing 6-9. res/xml/settings.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<CheckBoxPreference
android:key="dummyPref"
android:title="CheckBox Select"
android:summary="Standard preference element"
android:defaultValue="false" />
<com.androidrecipes.custompreference.ColorPreference
android:key="colorPref"
android:title="Select Color"
android:defaultValue="@android:color/black" />
</PreferenceScreen>
Listing 6-10. PreferenceActivity with New Settings
public class CustomPreferenceActivity extends PreferenceActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.settings);
}
}
We have set the default value of our color preference to a framework resource for black, which will be read by our onGetDefaultValue() override. You can see in Listing 6-10 that inflating this new preference hierarchy requires no modifications to the existing PreferenceActivity code we saw in the previous recipe. Figure 6-2 shows our results.
Figure 6-2. PreferenceScreen with our custom ColorPreference
6-3. Persisting Simple Data
Problem
Your application needs a simple, low-overhead method of storing basic data such as numbers and strings in persistent storage.
Solution
(API Level 1)
Using SharedPreferences objects, applications can quickly create one or more persistent stores where data can be saved and retrieved at a later time. Underneath the hood, these objects are actually stored as XML files in the application’s user data area. However, unlike directly reading and writing data from files, SharedPreferences provide an efficient framework for persisting basic data types.
Creating multiple SharedPreferences as opposed to dumping all your data in the default object can be a good habit to get into, especially if the data you are storing will have a shelf life. Keeping in mind that all preferences stored using the XML and PreferenceActivity framework are also stored in the default location, what if you wanted to store a group of items related to, say, a logged-in user? When that user logs out, you will need to remove all the persisted data that goes along with that. If you store all that data in default preferences, you will most likely need to remove each item individually. However, if you create a preference object just for those settings, logging out can be as simple as calling SharedPreferences.Editor.clear().
How It Works
Let’s look at a practical example of using SharedPreferences to persist simple data. Listings 6-11 and 6-12 create a data entry form for the user to send a simple message to a remote server. To aid the user, we will remember all the data he or she enters for each field until a successful request is made. This will allow the user to leave the screen (or be interrupted by a text message or phone call) without having to enter all the information again.
Listing 6-11. res/layout/form.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Email:"
android:padding="5dip" />
<EditText
android:id="@+id/email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
<CheckBox
android:id="@+id/age"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Are You Over 18?" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Message:"
android:padding="5dip" />
<EditText
android:id="@+id/message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minLines="3"
android:maxLines="3" />
<Button
android:id="@+id/submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Submit" />
</LinearLayout>
Listing 6-12. Entry Form with Persistence
public class FormActivity extends Activity implements View.OnClickListener {
EditText email, message;
CheckBox age;
Button submit;
SharedPreferences formStore;
boolean submitSuccess = false;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.form);
email = (EditText)findViewById(R.id.email);
message = (EditText)findViewById(R.id.message);
age = (CheckBox)findViewById(R.id.age);
submit = (Button)findViewById(R.id.submit);
submit.setOnClickListener(this);
//Retrieve or create the preferences object
formStore = getPreferences(Activity.MODE_PRIVATE);
}
@Override
public void onResume() {
super.onResume();
//Restore the form data
email.setText(formStore.getString("email", ""));
message.setText(formStore.getString("message", ""));
age.setChecked(formStore.getBoolean("age", false));
}
@Override
public void onPause() {
super.onPause();
if(submitSuccess) {
//Editor calls can be chained together
formStore.edit().clear().commit();
} else {
//Store the form data
SharedPreferences.Editor editor = formStore.edit();
editor.putString("email", email.getText().toString());
editor.putString("message", message.getText().toString());
editor.putBoolean("age", age.isChecked());
editor.commit();
}
}
@Override
public void onClick(View v) {
//DO SOME WORK SUBMITTING A MESSAGE
//Mark the operation successful
submitSuccess = true;
//Close
finish();
}
}
We start with a typical user form containing two simple EditText entry fields and a check box. When the activity is created, we gather a SharedPreferences object using Activity.getPreferences(), and this is where all the persisted data will be stored. If at any time the activity is paused for a reason other than a successful submission (controlled by the boolean member), the current state of the form will be quickly loaded into the preferences and persisted.
Note When saving data into SharedPreferences using an Editor, always remember to call commit() or apply() after the changes are made. Otherwise, your changes will not be saved.
Conversely, whenever the activity becomes visible, onResume() loads the user interface with the latest information stored in the preferences object. If no preferences exist, either because they were cleared or never created (first launch), then the form is set to blank.
When a user presses Submit and the fake form submits successfully, the subsequent call to onPause() will clear any stored form data in preferences. Because all these operations were done on a private preferences object, clearing the data does not affect any user settings that may have been stored using other means.
Note Methods called from an Editor always return the same Editor object, allowing them to be chained together in places where doing so makes your code more readable.
Creating Common SharedPreferences
The previous example illustrated how to use a single SharedPreferences object within the context of a single activity with an object obtained from Activity.getPreferences(). Truth be told, this method is really just a convenience wrapper for Context.getSharedPreferences(), in which it passes the activity name as the preference store name. If the data you are storing are best shared between two or more activity instances, it might make sense to call getSharedPreferences() instead and pass a more common name so the data can be accessed easily from different places in code. See Listing 6-13.
Listing 6-13. Two Activities Using the Same Preferences
public class ActivityOne extends Activity {
public static final String PREF_NAME = "myPreferences";
private SharedPreferences mPreferences;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mPreferences = getSharedPreferences(PREF_NAME, Activity.MODE_PRIVATE);
}
}
public class ActivityTwo extends Activity {
private SharedPreferences mPreferences;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mPreferences = getSharedPreferences(ActivityOne.PREF_NAME, Activity.MODE_PRIVATE);
}
}
In this example, both activity classes retrieve the SharedPreferences object using the same name (defined as a constant string): thus they will be accessing the same set of preference data. Furthermore, both references are even pointing at the same instance of preferences, as the framework creates a singleton object for each set of SharedPreferences (a set being defined by its name). This means that changes made on one side will immediately be reflected on the other.
A NOTE ABOUT MODE
Context.getSharedPreferences() also takes a mode parameter. Passing 0 or MODE_PRIVATE provides the default behavior of allowing only the application that created the preferences (or another application with the same user ID) to gain read/write access. This method supports two more mode parameters: MODE_WORLD_READABLE and MODE_WORLD_WRITEABLE. These modes allow other applications to gain access to these preferences by setting the user permissions on the file it creates appropriately. However, the external application still requires a valid Context pointing back to the package where the preference file was created.
For example, let’s say you created SharedPreferences with world-readable permission in an application with the package com.examples.myfirstapplication. In order to access those preferences from a second application, the second application would obtain them using the following code:
Context otherContext = createPackageContext("com.examples.myfirstapplication", 0);
SharedPreferences externalPreferences = otherContext.getSharedPreferences(PREF_NAME, 0);
Caution If you choose to use the mode parameter to allow external access, be sure that you are consistent in the mode you provide everywhere getSharedPreferences() is called. This mode is used only the first time the preference file gets created, so calling up SharedPreferences with different mode parameters at different times will only lead to confusion on your part.
6-4. Reading and Writing Files
Problem
Your application needs to read data in from an external file or write more-complex data out for persistence.
Solution
(API Level 1)
Sometimes, there is no substitute for working with a filesystem. Working with files allows your application to read and write data that does not lend itself well to other persistence options such as key/value preferences and databases. Android also provides a number of cache locations for files you can use to place data that you need to persist on a temporary basis.
Android supports all the standard Java file I/O APIs for create, read, update, and delete (CRUD) operations, along with some additional helpers to make accessing those files in specific locations a little more convenient. There are three main locations in which an application can work with files:
While the underlying mechanism to work with file data remains the same, we will look at the details that make working with each destination slightly different.
How It Works
As we stated earlier, the traditional Java FileInputStream and FileOutputStream classes constitute the primary method of accessing file data. In fact, you can create a File instance at any time with an absolute path location and use one of these streams to read and write data. However, with root paths varying on different devices and certain directories being protected from your application, we recommend some slightly more efficient ways to work with files.
In order to create or modify a file’s location on internal storage, utilize the Context.openFileInput() and Context.openFileOutput() methods. These methods require only the name of the file as a parameter, instead of the entire path, and will reference the file in relation to the application’s protected directory space, regardless of the exact path on the specific device. See Listing 6-14.
Listing 6-14. CRUD a File on Internal Storage
public class InternalActivity extends Activity {
private static final String FILENAME = "data.txt";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView tv = new TextView(this);
setContentView(tv);
//Create a new file and write some data
try {
FileOutputStream mOutput = openFileOutput(FILENAME, Activity.MODE_PRIVATE);
String data = "THIS DATA WRITTEN TO A FILE";
mOutput.write(data.getBytes());
mOutput.flush();
mOutput.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
//Read the created file and display to the screen
try {
FileInputStream mInput = openFileInput(FILENAME);
byte[] data = new byte[128];
mInput.read(data);
mInput.close();
String display = new String(data);
tv.setText(display.trim());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
//Delete the created file
deleteFile(FILENAME);
}
}
This example uses Context.openFileOutput() to write some simple string data out to a file. When using this method, the file will be created if it does not already exist. It takes two parameters: a file name and an operating mode. In this case, we use the default operation by defining the mode as MODE_PRIVATE. This mode will overwrite the file with each new write operation; use MODE_APPEND if you prefer that each write append to the end of the existing file.
After the write is complete, the example uses Context.openFileInput(), which requires only the file name again as a parameter to open an InputStream and read the file data. The data will be read into a byte array and displayed to the user interface through a TextView. Upon completing the operation, Context.deleteFile() is used to remove the file from storage.
Note Data is written to the file streams as bytes, so higher-level data (even strings) must be converted into and out of this format.
This example leaves no traces of the file behind, but we encourage you to try the same example without running deleteFile() at the end in order to keep the file in storage. Using the SDK’s DDMS tool with an emulator or unlocked device, you may view the filesystem and can find the file this application creates in its respective application data folder.
Because these methods are a part of Context, and not bound to an activity, this type of file access can occur anywhere in an application that you require, such as a BroadcastReceiver or even a custom class. Many system constructs either are a subclass of Context or will pass a reference to one in their callbacks. This allows the same open/close/delete operations to take place anywhere.
External Storage
The key differentiator between internal and external storage lies in the fact that external storage is mountable. This means that the user can connect his or her device to a computer and have the option of mounting that external storage as a removable disk on the PC. Often, the storage itself is physically removable (such as an SD card), but this is not a requirement of the platform.
Important Writing to the external storage of the device will require that you add a declaration for android.permission.WRITE_EXTERNAL_STORAGE to the application manifest.
During periods where the device’s external storage is either mounted externally or physically removed, it is not accessible to an application. Because of this, it is always prudent to check whether external storage is ready by checking Environment.getExternalStorageState().
Let’s modify the file example to do the same operation with the device’s external storage. See Listing 6-15.
Listing 6-15. CRUD a File on External Storage
public class ExternalActivity extends Activity {
private static final String FILENAME = "data.txt";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView tv = new TextView(this);
setContentView(tv);
//Create the file reference
File dataFile = new File(Environment.getExternalStorageDirectory(), FILENAME);
//Check if external storage is usable
if(!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
Toast.makeText(this, "Cannot use storage.", Toast.LENGTH_SHORT).show();
finish();
return;
}
//Create a new file and write some data
try {
FileOutputStream mOutput = new FileOutputStream(dataFile, false);
String data = "THIS DATA WRITTEN TO A FILE";
mOutput.write(data.getBytes());
mOutput.flush();
//With external files, it is often good to sync the file
mOutput.getFD().sync();
mOutput.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
//Read the created file and display to the screen
try {
FileInputStream mInput = new FileInputStream(dataFile);
byte[] data = new byte[128];
mInput.read(data);
mInput.close();
String display = new String(data);
tv.setText(display.trim());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
//Delete the created file
dataFile.delete();
}
}
With external storage, we utilize a little more of the traditional Java file I/O. The key to working with external storage is calling Environment.getExternalStorageDirectory() to retrieve the root path to the device’s external storage location.
Before any operations can take place, the status of the device’s external storage is first checked with Environment.getExternalStorageState(). If the value returned is anything other than Environment.MEDIA_MOUNTED, we do not proceed because the storage cannot be written to, so the activity is closed. Otherwise, a new file can be created and the operations may commence.
The input and output streams must now use default Java constructors, as opposed to the Context convenience methods. The default behavior of the output stream will be to overwrite the current file or to create it if it does not exist. If your application must append to the end of the existing file with each write, change the boolean parameter in the FileOutputStream constructor to true.
Often, it makes sense to create a special directory on external storage for your application’s files. We can accomplish this simply by using more of Java’s file API. See Listing 6-16.
Listing 6-16. CRUD a File Inside New Directory
public class ExternalActivity extends Activity {
private static final String FILENAME = "data.txt";
private static final String DNAME = "myfiles";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView tv = new TextView(this);
setContentView(tv);
//Create a new directory on external storage
File rootPath = new File(Environment.getExternalStorageDirectory(), DNAME);
if(!rootPath.exists()) {
rootPath.mkdirs();
}
//Create the file reference
File dataFile = new File(rootPath, FILENAME);
//Create a new file and write some data
try {
FileOutputStream mOutput = new FileOutputStream(dataFile, false);
String data = "THIS DATA WRITTEN TO A FILE";
mOutput.write(data.getBytes());
mOutput.flush();
//With external files, it is often good to wait for the write
mOutput.getFD().sync();
mOutput.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
//Read the created file and display to the screen
try {
FileInputStream mInput = new FileInputStream(dataFile);
byte[] data = new byte[128];
mInput.read(data);
mInput.close();
String display = new String(data);
tv.setText(display.trim());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
//Delete the created file
dataFile.delete();
}
}
In this example we created a new directory path within the external storage directory and used that new location as the root location for the data file. Once the file reference is created using the new directory location, the remainder of the example is the same.
A NOTE ABOUT WRITING FILES
Android applications run inside the Dalvik virtual machine environment. This has some effects to be aware of when working with certain aspects of the system, such as the filesystem. Java APIs like FileOutputStream do not share a 1:1 relationship with the native file descriptor inside the kernel. Typically, when data is written to the stream by using the write() method, that data is written directly into a memory buffer for the file and asynchronously written out to disk. In most cases, as long as your file access is strictly within the Dalvik VM, you will never see this implementation detail. A file you just wrote could be opened and immediately read without issue, for example.
However, when dealing with removeable storage such as an SD card on a mobile handset or tablet, we may often need to guarantee that the file data has made it all the way to the filesystem before returning some operation to the user since the user has the ability to physically remove the storage medium. The following is a good standard code block to use when writing external files:
//Write the data
out.write();
//Clear the stream buffers
out.flush();
//Sync all data to the filesystem
out.getFD().sync();
//Close the stream
out.close();
The flush() method on an OutputStream is designed to ensure that all the data resident in the stream is written out the VM’s memory buffer. In the direct case of FileOutputStream, this method actually does nothing. However, in cases where that stream may be wrapped inside another (such as a BufferedOutputStream), this method can be essential in clearing out internal buffers, so it is a good habit to get into by calling it on every file write before closing the stream.
Additionally, with external files, we can issue a sync() to the underlying FileDescriptor. This method will block until all the data has been successfully written to the underlying filesystem, so it is the best indicator of when a user could safely remove physical storage media without file corruption.
(API Level 8)
There are additional methods in Environment and Context that provide standard locations on external storage where specific files can be written. Some of these locations have additional properties as well.
6-5. Using Files as Resources
Problem
Your application must utilize resource files that are in a format Android cannot compile into a resource ID.
Solution
(API Level 1)
Use the assets directory to house files your application needs to read from, such as local HTML, comma-separated values (CSV), or proprietary data. The assets directory is a protected resource location for files in an Android application. The files placed in this directory will be bundled with the final APK but will not be processed or compiled. Like all other application resources, the files in assets are read-only.
How It Works
There are a few specific instances that we’ve seen already in this book, where assets can be used to load content directly into widgets, such as WebView and MediaPlayer. However, in most cases, assets is best accessed through a traditional InputStream. Listings 6-17 and 6-18 provide an example in which a private CSV file is read from assets and displayed onscreen.
Listing 6-17. assets/data.csv
John,38,Red
Sally,42,Blue
Rudy,31,Yellow
Listing 6-18. Reading from an Asset File
public class AssetActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView tv = new TextView(this);
setContentView(tv);
try {
//Access application assets
AssetManager manager = getAssets();
//Open our data file
InputStream in = manager.open("data.csv");
//Parse the CSV data and display
ArrayList<Person> cooked = parse(in);
StringBuilder builder = new StringBuilder();
for(Person piece : cooked) {
builder.append(String.format("%s is %s years old, and likes the color %s",
piece.name, piece.age, piece.color));
builder.append(' '),
}
tv.setText(builder.toString());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
/* Simple CSV Parser */
private static final int COL_NAME = 0;
private static final int COL_AGE = 1;
private static final int COL_COLOR = 2;
private ArrayList<Person> parse(InputStream in) throws IOException {
ArrayList<Person> results = new ArrayList<Person>();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String nextLine = null;
while ((nextLine = reader.readLine()) != null) {
String[] tokens = nextLine.split(",");
if (tokens.length != 3) {
Log.w("CSVParser", "Skipping Bad CSV Row");
continue;
}
//Add new parsed result
Person current = new Person();
current.name = tokens[COL_NAME];
current.color = tokens[COL_COLOR];
current.age = tokens[COL_AGE];
results.add(current);
}
in.close();
return results;
}
private class Person {
public String name;
public String age;
public String color;
public Person() { }
}
}
The key to accessing files in assets lies in using AssetManager, which will allow the application to open any resource currently residing in the assets directory. Passing the name of the file we are interested in to AssetManager.open() returns an InputStream for us to read the file data. Once the stream is read into memory, the example passes the raw data off to a parsing routine and displays the results to the user interface.
Parsing the CSV
This example also illustrates a simple method of taking data from a CSV file and parsing it into a model object (called Person in this case). The method used here takes the entire file and reads it into a byte array for processing as a single string. This method is not the most memory efficient when the amount of data to be read is quite large, but for small files like this one it works just fine.
The raw string is passed into a StringTokenizer instance, along with the required characters to use as breakpoints for the tokens: comma and new line. At this point, each individual chunk of the file can be processed in order. Using a basic state machine approach, the data from each line is inserted into new Person instances and loaded into the resulting list.
Problem
Your application needs to persist data that can later be queried or modified as subsets or individual records.
Solution
(API Level 1)
Create an SQLiteDatabase with the assistance of an SQLiteOpenHelper to manage your data store. SQLite is a fast and lightweight database technology that utilizes SQL syntax to build queries and manage data. Support for SQLite is baked in to the Android SDK, making it very easy to set up and use in your applications.
How It Works
Customizing SQLiteOpenHelper allows you to manage the creation and modification of the database schema itself. It is also an excellent place to insert any initial or default values you may want into the database while it is created. Listing 6-19 is an example of how to customize the helper in order to create a database with a single table that stores basic information about people.
Listing 6-19. Custom SQLiteOpenHelper
public class MyDbHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "mydb";
private static final int DB_VERSION = 1;
public static final String TABLE_NAME = "people";
public static final String COL_NAME = "pName";
public static final String COL_DATE = "pDate";
private static final String STRING_CREATE =
"CREATE TABLE "+TABLE_NAME+" (_id INTEGER PRIMARY KEY AUTOINCREMENT, "
+ COL_NAME + " TEXT, " + COL_DATE + " DATE);";
public MyDbHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
//Create the database table
db.execSQL(STRING_CREATE);
//You may also load initial values into the database here
ContentValues cv = new ContentValues(2);
cv.put(COL_NAME, "John Doe");
//Create a formatter for SQL date format
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
cv.put(COL_DATE, dateFormat.format(new Date())); //Insert 'now' as the date
db.insert(TABLE_NAME, null, cv);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
//For now, clear the database and re-create
db.execSQL("DROP TABLE IF EXISTS "+TABLE_NAME);
onCreate(db);
}
}
They key pieces of information you will need for your database are a name and version number. Creating and upgrading an SQLiteDatabase does require some light knowledge of SQL, so we recommend glancing at an SQL reference briefly if you are unfamiliar with some of the syntax. The helper will call onCreate() anytime this particular database is accessed, using either SQLiteOpenHelper.getReadableDatabase() or SQLiteOpenHelper.getWritableDatabase(), if it does not already exist.
The example abstracts the table and column names as constants for external use (a good practice to get into). Here is the actual SQL create string that is used in onCreate() to make our table:
CREATE TABLE people (_id INTEGER PRIMARY KEY AUTOINCREMENT, pName TEXT, pAge INTEGER, pDate DATE);
When using SQLite in Android, there is a small amount of formatting that the database must have in order for it to work properly with the framework. Most of it is created for you, but one piece that the tables you create must have is a column for _id. The remainder of this string creates two more columns for each record in the table:
Data is inserted into the database by using ContentValues objects. The example illustrates how to use ContentValues to insert some default data into the database when it is created. SQLiteDatabase.insert() takes a table name, null column hack, and ContentValues representing the record to insert as parameters.
The null column hack is not used here but serves a purpose that may be vital to your application. SQL cannot insert an entirely empty value into the database, and attempting to do so will cause an error. If there is a chance that your implementation may pass an empty ContentValues to insert(), the null column hack is used to instead insert a record where the value of the referenced column is NULL.
SQLiteOpenHelper also does a great job of assisting you with migrating your database schema in future versions of the application. Whenever the database is accessed, but the version on disk does not match the current version (meaning the version passed in the constructor), onUpgrade() will be called.
In our example, we took the lazy way out and simply dropped the existing database and re-created it. In practice, this may not be a suitable method if the database contains user-entered data; a user probably won’t be too happy to see it disappear. So let’s digress for a moment and look at an example of onUpgrade() that may be more useful. Take, for example, the following three databases used throughout the lifetime of an application:
We can leverage onUpgrade() to alter the existing database instead of erasing all the current information in place. See Listing 6-20.
Listing 6-20. Sample of onUpgrade()
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
//Upgrade from v1. Adding phone number
if(oldVersion <= 1) {
db.execSQL("ALTER TABLE "+TABLE_NAME+" ADD COLUMN phone_number INTEGER;");
}
//Upgrade from v2. Add entry date
if(oldVersion <= 2) {
db.execSQL("ALTER TABLE "+TABLE_NAME+" ADD COLUMN entry_date DATE;");
}
}
In this example, if the user’s existing database version is 1, both statements will be called to add columns to the database. If a user already has version 2, just the latter statement is called to add the entry date column. In both cases, any existing data in the application database is preserved.
Looking back to our original sample, let’s take a look at how an activity would utilize the database we’ve created. See Listings 6-21 and 6-22.
Listing 6-21. res/layout/main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:id="@+id/add"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add New Person" />
<ListView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
Listing 6-22. Activity to View and Manage Database
public class DbActivity extends Activity implements View.OnClickListener,
AdapterView.OnItemClickListener {
EditText mText;
Button mAdd;
ListView mList;
MyDbHelper mHelper;
SQLiteDatabase mDb;
Cursor mCursor;
SimpleCursorAdapter mAdapter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mText = (EditText)findViewById(R.id.name);
mAdd = (Button)findViewById(R.id.add);
mAdd.setOnClickListener(this);
mList = (ListView)findViewById(R.id.list);
mList.setOnItemClickListener(this);
mHelper = new MyDbHelper(this);
}
@Override
public void onResume() {
super.onResume();
//Open connections to the database
mDb = mHelper.getWritableDatabase();
String[] columns = new String[] {"_id", MyDbHelper.COL_NAME, MyDbHelper.COL_DATE};
mCursor = mDb.query(MyDbHelper.TABLE_NAME, columns, null, null, null, null,
null);
//Refresh the list
String[] headers = new String[] {MyDbHelper.COL_NAME, MyDbHelper.COL_DATE};
mAdapter = new SimpleCursorAdapter(this, android.R.layout.two_line_list_item,
mCursor, headers, new int[]{android.R.id.text1, android.R.id.text2});
mList.setAdapter(mAdapter);
}
@Override
public void onPause() {
super.onPause();
//Close all connections
mDb.close();
mCursor.close();
}
@Override
public void onClick(View v) {
//Add a new value to the database
ContentValues cv = new ContentValues(2);
cv.put(MyDbHelper.COL_NAME, mText.getText().toString());
//Create a formatter for SQL date format
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//Insert 'now' as the date
cv.put(MyDbHelper.COL_DATE, dateFormat.format(new Date()));
mDb.insert(MyDbHelper.TABLE_NAME, null, cv);
//Refresh the list
mCursor.requery();
mAdapter.notifyDataSetChanged();
//Clear the edit field
mText.setText(null);
}
@Override
public void onItemClick(AdapterView<?> parent, View v, int position, long id) {
//Delete the item from the database
mCursor.moveToPosition(position);
//Get the id value of this row
String rowId = mCursor.getString(0); //Column 0 of the cursor is the id
mDb.delete(MyDbHelper.TABLE_NAME, "_id = ?", new String[]{rowId});
//Refresh the list
mCursor.requery();
mAdapter.notifyDataSetChanged();
}
}
In this example, we utilize our custom SQLiteOpenHelper to give us access to a database instance, and it displays each record in that database as a list to the user interface. Information from the database is returned in the form of a Cursor, an interface designed to read, write, and traverse the results of a query.
When the activity becomes visible, a database query is made to return all records in the people table. An array of column names must be passed to the query to tell the database which values to return. The remaining parameters of query() are designed to narrow the selection data set, and we will investigate this further in the next recipe. It is important to close all database and cursor connections when they are no longer needed. In the example, we do this in onPause(), when the activity is no longer in the foreground.
SimpleCursorAdapter is used to map the data from the database to the standard Android two-line list item view. The string and int array parameters constitute the mapping; the data from each item in the string array will be inserted into the view with the corresponding ID value in the int array. The list of column names passed here is slightly different than the array passed to the query. This is because we will need to know the record ID for other operations, but it is not necessary in mapping the data to the user interface.
The user may enter a name in the text field and then press the Add New Person button to create a new ContentValues instance and insert it into the database. At that point, in order for the UI to display the change, we call Cursor.requery() and ListAdapter.notifyDataSetChanged().
Conversely, tapping on an item in the list will remove that specified item from the database. In order to accomplish this, we must construct a simple SQL statement telling the database to remove only records where the _id value matches this selection. At that point, the cursor and list adapter are refreshed again.
The _id value of the selection is obtained by moving the cursor to the selected position and calling getString(0) to get the value of column index zero. This request returns the _id because the first parameter (index 0) passed in the columns list to the query was _id. The delete statement is composed of two parameters: the statement string and the arguments. An argument from the passed array will be inserted in the statement for each question mark that appears in the string.
6-7. Querying a Database
Problem
Your application uses an SQLiteDatabase, and you need to return specific subsets of the data contained therein.
Solution
(API Level 1)
Using fully structured SQL queries, it is very simple to create filters for specific data and return those subsets from the database. There are several overloaded forms of SQLiteDatabase.query() to gather information from the database. We’ll examine the most verbose of them here:
public Cursor query(String table, String[] columns,
String selection,
String[] selectionArgs,
String groupBy,
String having,
String orderBy,
String limit)
The first two parameters simply define the table in which to query data, as well as the columns for each record that we would like to have access to. The remaining parameters define how we will narrow the scope of the results.
As you can see, all of these parameters are designed to provide the full power of SQL to the database queries.
How It Works
Let’s look at some example queries that can be constructed to accomplish some common practical queries:
String[] COLUMNS = new String[] {COL_NAME, COL_DATE};
String selection = COL_NAME+" = ?";
String[] args = new String[] {"NAME_TO_MATCH"};
Cursor result = db.query(TABLE_NAME, COLUMNS, selection, args, null, null, null, null);
This query is fairly straightforward. The selection statement just tells the database to match any data in the name column with the argument supplied (which is inserted in place of ? in the selection string).
String orderBy = "_id DESC";
String limit = "10";
Cursor result = db.query(TABLE_NAME, COLUMNS, null, null, null, null, orderBy, limit);
This query has no special selection criteria but instead tells the database to order the results by the auto-incrementing _id value, with the newest (highest _id) records first. The limit clause sets the maximum number of returned results to 10.
String[] COLUMNS = new String[] {COL_NAME, COL_DATE};
String selection = "datetime("+COL_DATE+") > datetime(?)"+
" AND datetime("+COL_DATE+") < datetime(?)";
String[] args = new String[] {"2000-1-1 00:00:00","2000-12-31 23:59:59"};
Cursor result = db.query(TABLE_NAME, COLUMNS, selection, args, null, null, null, null);
SQLite does not reserve a specific data type for dates, although they allow DATE as a declaration type when creating a table. However, the standard SQL date and time functions can be used to create representations of the data as TEXT, INTEGER, or REAL. Here, we compare the return values of datetime() for both the value in the database and a formatted string for the start and end dates of the range.
String[] COLUMNS = new String[] {COL_NAME, COL_AGE};
String selection = COL_AGE+" > ? AND "+COL_AGE+" < ?";
String[] args = new String[] {"7","10"};
Cursor result = db.query(TABLE_NAME, COLUMNS, selection, args, null, null, null, null);
This is similar to the previous example but is much less verbose. Here, we simply have to create the selection statement to return values greater than the low limit, but less than the high limit. Both limits are provided as arguments to be inserted so they can be dynamically set in the application.
6-8. Backing Up Data
Problem
Your application persists data on the device, and you need to provide users with a way to back up and restore this data in cases where they change devices or are forced to reinstall the application.
Solution
(API Level 1)
Use the device’s external storage as a safe location to copy databases and other files. External storage is often physically removable, allowing the user to place it in another device and do a restore. Even in cases where this is not possible, external storage can always be mounted when the user connects his or her device to a computer, allowing data transfer to take place.
How It Works
Listing 6-23 shows an implementation of AsyncTask that copies a database file back and forth between the device’s external storage and its location in the application’s data directory. It also defines an interface for an activity to implement to get notified when the operation is complete. File operations such as copy can take some time to complete, so you can implement this by using an AsyncTask so it can happen in the background and not block the main thread.
Listing 6-23. AsyncTask for Backup and Restore
public class BackupTask extends AsyncTask<String,Void,Integer> {
public interface CompletionListener {
void onBackupComplete();
void onRestoreComplete();
void onError(int errorCode);
}
public static final int BACKUP_SUCCESS = 1;
public static final int RESTORE_SUCCESS = 2;
public static final int BACKUP_ERROR = 3;
public static final int RESTORE_NOFILEERROR = 4;
public static final String COMMAND_BACKUP = "backupDatabase";
public static final String COMMAND_RESTORE = "restoreDatabase";
private Context mContext;
private CompletionListener listener;
public BackupTask(Context context) {
super();
mContext = context;
}
public void setCompletionListener(CompletionListener aListener) {
listener = aListener;
}
@Override
protected Integer doInBackground(String... params) {
//Get a reference to the database
File dbFile = mContext.getDatabasePath("mydb");
//Get a reference to the directory location for the backup
File exportDir =
new File(Environment.getExternalStorageDirectory(), "myAppBackups");
if (!exportDir.exists()) {
exportDir.mkdirs();
}
File backup = new File(exportDir, dbFile.getName());
//Check the required operation
String command = params[0];
if(command.equals(COMMAND_BACKUP)) {
//Attempt file copy
try {
backup.createNewFile();
fileCopy(dbFile, backup);
return BACKUP_SUCCESS;
} catch (IOException e) {
return BACKUP_ERROR;
}
} else if(command.equals(COMMAND_RESTORE)) {
//Attempt file copy
try {
if(!backup.exists()) {
return RESTORE_NOFILEERROR;
}
dbFile.createNewFile();
fileCopy(backup, dbFile);
return RESTORE_SUCCESS;
} catch (IOException e) {
return BACKUP_ERROR;
}
} else {
return BACKUP_ERROR;
}
}
@Override
protected void onPostExecute(Integer result) {
switch(result) {
case BACKUP_SUCCESS:
if(listener != null) {
listener.onBackupComplete();
}
break;
case RESTORE_SUCCESS:
if(listener != null) {
listener.onRestoreComplete();
}
break;
case RESTORE_NOFILEERROR:
if(listener != null) {
listener.onError(RESTORE_NOFILEERROR);
}
break;
default:
if(listener != null) {
listener.onError(BACKUP_ERROR);
}
}
}
private void fileCopy(File source, File dest) throws IOException {
FileChannel inChannel = new FileInputStream(source).getChannel();
FileChannel outChannel = new FileOutputStream(dest).getChannel();
try {
inChannel.transferTo(0, inChannel.size(), outChannel);
} finally {
if (inChannel != null)
inChannel.close();
if (outChannel != null)
outChannel.close();
}
}
}
As you can see, BackupTask operates by copying the current version of a named database to a specific directory in external storage when COMMAND_BACKUP is passed to execute(), and it copies the file back when COMMAND_RESTORE is passed.
Once executed, the task uses Context.getDatabasePath() to retrieve a reference to the database file we need to back up. This line could easily be replaced with a call to Context.getFilesDir(), accessing a file on the system’s internal storage to back up instead. A reference to a backup directory we’ve created on external storage is also obtained.
The files are copied using traditional Java file I/O, and if all is successful, the registered listener is notified. During the process, any exceptions thrown are caught and an error is returned to the listener instead. Now let’s take a look at an activity that utilizes this task to back up a database (see Listing 6-24).
Listing 6-24. Activity Using BackupTask
public class BackupActivity extends Activity implements BackupTask.CompletionListener {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
//Dummy example database
SQLiteDatabase db = openOrCreateDatabase("mydb", Activity.MODE_PRIVATE, null);
db.close();
}
@Override
public void onResume() {
super.onResume();
if( Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED) ) {
BackupTask task = new BackupTask(this);
task.setCompletionListener(this);
task.execute(BackupTask.COMMAND_RESTORE);
}
}
@Override
public void onPause() {
super.onPause();
if( Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED) ) {
BackupTask task = new BackupTask(this);
task.execute(BackupTask.COMMAND_BACKUP);
}
}
@Override
public void onBackupComplete() {
Toast.makeText(this, "Backup Successful", Toast.LENGTH_SHORT).show();
}
@Override
public void onError(int errorCode) {
if(errorCode == BackupTask.RESTORE_NOFILEERROR) {
Toast.makeText(this, "No Backup Found to Restore", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "Error During Operation: "+errorCode, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onRestoreComplete() {
Toast.makeText(this, "Restore Successful", Toast.LENGTH_SHORT).show();
}
}
The activity implements the CompletionListener defined by BackupTask, so it may be notified when operations are finished or an error occurs. For the purposes of the example, a dummy database is created in the application’s database directory. We call openOrCreateDatabase() only to allow a file to be created, so the connection is immediately closed afterward. Under normal circumstances, this database would already exist and these lines would not be necessary.
The example does a restore operation each time the activity is resumed, registering itself with the task so it can be notified and raise a Toast to the user of the status result. Notice that the task of checking whether external storage is usable falls to the activity as well, and no tasks are executed if external storage is not accessible. When the activity is paused, a backup operation is executed, this time without registering for callbacks. This is because the activity is no longer interesting to the user, so we won’t need to raise a toast to point out the operation results.
This background task could be extended to save the data to a cloud-based service for maximum safety and data portability. There are many options available to accomplish this, including Google’s own set of web APIs, and we recommend you give this a try.
Android, as of API Level 8, also includes an API for backing up data to a cloud-based service. This API may suit your purposes; however, we will not discuss it here. The Android framework cannot guarantee that this service will be available on all Android devices, and there is no API as of this writing to determine whether the device the user has will support the Android backup, so it is not recommended for critical data.
6-9. Sharing Your Database
Problem
Your application would like to provide the database content it maintains to other applications on the device.
Solution
(API Level 4)
Create a ContentProvider to act as an external interface for your application’s data. ContentProvider exposes an arbitrary set of data to external requests through a database-like interface of query(), insert(), update(), and delete(), though the implementer is free to design how the interface maps to the actual data model. Creating a ContentProvider to expose the data from an SQLiteDatabase is straightforward and simple. With some minor exceptions, the developer needs only to pass calls from the provider down to the database.
Arguments about which data set to operate on are typically encoded in the Uri passed to the ContentProvider. For example, sending a query Uri such as
content://com.examples.myprovider/friends
would tell the provider to return information from the friends table within its data set, while
content://com.examples.myprovider/friends/15
would instruct just the record ID 15 to return from the query. It should be noted that these are only the conventions used by the rest of the system, and that you are responsible for making the ContentProvider you create behave in this manner. There is nothing inherent about ContentProvider that provides this functionality for you.
How It Works
First of all, to create a ContentProvider that interacts with a database, we must have a database in place to interact with. Listing 6-25 is a sample SQLiteOpenHelper implementation that we will use to create and access the database itself.
Listing 6-25. Sample SQLiteOpenHelper
public class ShareDbHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "frienddb";
private static final int DB_VERSION = 1;
public static final String TABLE_NAME = "friends";
public static final String COL_FIRST = "firstName";
public static final String COL_LAST = "lastName";
public static final String COL_PHONE = "phoneNumber";
private static final String STRING_CREATE =
"CREATE TABLE "+TABLE_NAME+" (_id INTEGER PRIMARY KEY AUTOINCREMENT, "
+COL_FIRST+" TEXT, "+COL_LAST+" TEXT, "+COL_PHONE+" TEXT);";
public ShareDbHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
//Create the database table
db.execSQL(STRING_CREATE);
//Inserting example values into database
ContentValues cv = new ContentValues(3);
cv.put(COL_FIRST, "John");
cv.put(COL_LAST, "Doe");
cv.put(COL_PHONE, "8885551234");
db.insert(TABLE_NAME, null, cv);
cv = new ContentValues(3);
cv.put(COL_FIRST, "Jane");
cv.put(COL_LAST, "Doe");
cv.put(COL_PHONE, "8885552345");
db.insert(TABLE_NAME, null, cv);
cv = new ContentValues(3);
cv.put(COL_FIRST, "Jill");
cv.put(COL_LAST, "Doe");
cv.put(COL_PHONE, "8885553456");
db.insert(TABLE_NAME, null, cv);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
//For now, clear the database and re-create
db.execSQL("DROP TABLE IF EXISTS "+TABLE_NAME);
onCreate(db);
}
}
Overall, this helper is fairly simple, creating a single table to keep a list of our friends with just three columns for housing text data. For the purposes of this example, three row values are inserted. Now let’s take a look at a ContentProvider that will expose this database to other applications: see Listings 6-26 and 6-27.
Listing 6-26. Manifest Declaration for ContentProvider
<manifest xmlns:android="http://schemas.android.com/apk/res/android" ...>
<application ...>
<provider android:name=".FriendProvider"
android:authorities="com.examples.sharedb.friendprovider">
</provider>
</application>
</manifest>
Listing 6-27. ContentProvider for a Database
public class FriendProvider extends ContentProvider {
public static final Uri CONTENT_URI =
Uri.parse("content://com.examples.sharedb.friendprovider/friends");
public static final class Columns {
public static final String _ID = "_id";
public static final String FIRST = "firstName";
public static final String LAST = "lastName";
public static final String PHONE = "phoneNumber";
}
/* Uri Matching */
private static final int FRIEND = 1;
private static final int FRIEND_ID = 2;
private static final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
matcher.addURI(CONTENT_URI.getAuthority(), "friends", FRIEND);
matcher.addURI(CONTENT_URI.getAuthority(), "friends/#", FRIEND_ID);
}
SQLiteDatabase db;
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
int result = matcher.match(uri);
switch(result) {
case FRIEND:
return db.delete(ShareDbHelper.TABLE_NAME, selection, selectionArgs);
case FRIEND_ID:
return db.delete(ShareDbHelper.TABLE_NAME, "_ID = ?",
new String[]{uri.getLastPathSegment()});
default:
return 0;
}
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
long id = db.insert(ShareDbHelper.TABLE_NAME, null, values);
if(id >= 0) {
return Uri.withAppendedPath(uri, String.valueOf(id));
} else {
return null;
}
}
@Override
public boolean onCreate() {
ShareDbHelper helper = new ShareDbHelper(getContext());
db = helper.getWritableDatabase();
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
int result = matcher.match(uri);
switch(result) {
case FRIEND:
return db.query(ShareDbHelper.TABLE_NAME, projection, selection,
selectionArgs, null, null, sortOrder);
case FRIEND_ID:
return db.query(ShareDbHelper.TABLE_NAME, projection, "_ID = ?",
new String[]{uri.getLastPathSegment()}, null, null, sortOrder);
default:
return null;
}
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
int result = matcher.match(uri);
switch(result) {
case FRIEND:
return db.update(ShareDbHelper.TABLE_NAME, values, selection, selectionArgs);
case FRIEND_ID:
return db.update(ShareDbHelper.TABLE_NAME, values, "_ID = ?",
new String[]{uri.getLastPathSegment()});
default:
return 0;
}
}
}
A ContentProvider must be declared in the application’s manifest with the authority string that it represents. This allows the provider to be accessed from external applications, but the declaration is still required even if you use the provider only internally within your application. The authority is what Android uses to match Uri requests to the provider, so it should match the authority portion of the public CONTENT_URI.
The six required methods to override when extending ContentProvider are query(), insert(), update(), delete(), getType(), and onCreate(). The first four of these methods have direct counterparts in SQLiteDatabase, so the database method is simply called with the appropriate parameters. The primary difference between the two is that the ContentProvider method passes in a Uri, which the provider should inspect to determine which portion of the database to operate on.
These four primary CRUD methods are called on the provider when an activity or other system component calls the corresponding method on its internal ContentResolver (you see this in action in Listing 6-27).
To adhere to the Uri convention mentioned in the first part of this recipe, insert() returns a Uri object created by appending the newly created record ID onto the end of the path. This Uri should be considered by its requester to be a direct reference back to the record that was just created.
The remaining methods (query(), update(), and delete()) adhere to the convention by inspecting the incoming Uri to see whether it refers to a specific record or to the whole table. This task is accomplished with the help of the UriMatcher convenience class. The UriMatcher.match() method compares a Uri to a set of supplied patterns and returns the matching pattern as an int, or UriMatcher.NO_MATCH if one is not found. If a Uri is supplied with a record ID appended, the call to the database is modified to affect only that specific row.
A UriMatcher should be initialized by supplying a set of patterns with UriMatcher.addURI(); Google recommends that this all be done in a static context within the ContentProvider, so it will be initialized the first time the class is loaded into memory. Each pattern added is also given a constant identifier that will be the return value when matches are made. There are two wildcard characters that may be placed in the supplied patterns: the pound (#) character will match any number, and the asterisk (*) will match any text.
Our example has created two patterns to match. The initial pattern matches the supplied CONTENT_URI directly, and it is taken to reference the entire database table. The second pattern looks for an appended number to the path, which will be taken to reference just the record at that ID.
Access to the database is obtained through a reference given by the ShareDbHelper in onCreate(). The size of the database that is used should be considered when deciding whether this method will be appropriate for your application. Our database is quite small when it is created, but larger databases may take a long time to create, in which case the main thread should not be tied up while this operation is taking place; getWritableDatabase() may need to be wrapped in an AsyncTask and done in the background in these cases. Now let’s take a look at a sample activity accessing the data: see Listings 6-28 and 6-29.
Listing 6-28. AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.examples.sharedb" android:versionCode="1" android:versionName="1.0">
<uses-sdk android:minSdkVersion="4" />
<application android:icon="@drawable/icon" android:label="@string/app_name">
<activity android:name=".ShareActivity" android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider android:name=".FriendProvider"
android:authorities="com.examples.sharedb.friendprovider">
</provider>
</application>
</manifest>
Listing 6-29. Activity Accessing the ContentProvider
public class ShareActivity extends FragmentActivity implements
LoaderManager.LoaderCallbacks<Cursor>, AdapterView.OnItemClickListener {
private static final int LOADER_LIST = 100;
SimpleCursorAdapter mAdapter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getSupportLoaderManager().initLoader(LOADER_LIST, null, this);
mAdapter = new SimpleCursorAdapter(this,
android.R.layout.simple_list_item_1, null,
new String[]{FriendProvider.Columns.FIRST},
new int[]{android.R.id.text1}, 0);
ListView list = new ListView(this);
list.setOnItemClickListener(this);
list.setAdapter(mAdapter);
setContentView(list);
}
@Override
public void onItemClick(AdapterView<?> parent, View v, int position, long id) {
Cursor c = mAdapter.getCursor();
c.moveToPosition(position);
Uri uri = Uri.withAppendedPath(FriendProvider.CONTENT_URI, c.getString(0));
String[] projection = new String[]{FriendProvider.Columns.FIRST,
FriendProvider.Columns.LAST,
FriendProvider.Columns.PHONE};
//Get the full record
Cursor cursor = getContentResolver().query(uri, projection, null, null, null);
cursor.moveToFirst();
String message = String.format("%s %s, %s", cursor.getString(0),
cursor.getString(1), cursor.getString(2));
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
cursor.close();
}
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
String[] projection = new String[]{FriendProvider.Columns._ID,
FriendProvider.Columns.FIRST};
return new CursorLoader(this, FriendProvider.CONTENT_URI,
projection, null, null, null);
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
mAdapter.swapCursor(data);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
mAdapter.swapCursor(null);
}
}
Important This example requires the Support Library to provide access to the Loader pattern in Android 1.6 and above. If you are targeting Android 3.0+ in your application, you may replace FragmentActivity with Activity and getSupportLoaderManager() with getLoaderManager().
This example queries the FriendsProvider for all its records and places them into a list, displaying only the first-name column. In order for the Cursor to adapt properly into a list, our projection must include the ID column, even though it is not displayed.
If the user taps any of the items in the list, another query is made of the provider using a Uri constructed with the record ID appended to the end, forcing the provider to return only that one record. In addition, an expanded projection is provided to get all the column data about this friend.
The returned data is placed into a Toast and raised for the user to see. Individual fields from the cursor are accessed by their column index, corresponding to the index in the projection passed to the query. The Cursor.getColumnIndex() method may also be used to query the cursor for the index associated with a given column name.
A Cursor should always be closed when it is no longer needed, as we do with the Cursor created after a user click. The only exceptions to this are Cursor instances created and managed by the Loader.
Figure 6-3 shows the result of running this sample to display the provider content.
Figure 6-3. Information from a ContentProvider
18.116.85.72