Chapter     6

Persisting Data

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):

  1. Name: This is an instance of EditTextPreference, which stores a string value. Tapping this item will present a text box so that the user can type a new preference value.
  2. Enable More Settings: This is an instance of CheckBoxPreference, which stores a boolean value. Tapping this item will toggle the checked status of the check box.
  3. More Settings: Tapping this item will load another PreferenceScreen with more items.

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.

9781430263227_Fig06-01.jpg

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.

PreferenceFragment

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

KITKAT SECURITY

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:

  • onCreateView(): Construct a new layout to be used for this preference element in the list. This is called the first time an instance of this preference is needed. If multiple elements of the same type exist, these views will be recycled when possible. If you don’t override this, the default view with a title and summary will be displayed.
  • onBindView(): Attach the data for this current preference to the view constructed in onCreateView(), which is passed into this method as a parameter. This will be called every time the preference is about to be displayed.
  • getSummary(): Override the summary value displayed in the standard UI layout. This is only useful if you don’t override onCreateView()/onBindView().
  • onClick(): Handle an event when the user taps on this item in the list.

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:

  • onGetDefaultValue(): This method will be called to allow you to read the android:defaultValue attribute from the preference’s XML definition. You will receive the TypedArray where the attributes live and the index necessary to obtain the value using whichever typed method makes sense for the preference value.
  • onSetInitialValue(): Locally set the value of this preference instance. The restorePersistedValue flag indicates whether the value should come from SharedPreferences, or from the default value. The default value parameter is the instance returned from onGetDefaultValue().

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.

9781430263227_Fig06-02.jpg

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:

  • Internal storage: Protected directory space to read and write file data.
  • External storage: Externally mountable space to read and write file data. Requires the WRITE_EXTERNAL_STORAGE permission in API Level 4+. Often, this is a physical SD card in the device.
  • Assets: Protected read-only space inside the APK bundle. Good for local resources that can’t or shouldn’t be compiled.

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.

Internal Storage

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.

External System Directories

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

  • Environment.getExternalStoragePublicDirectory(String type)
    • Returns a common directory where all applications store media files. The contents of these directories are visible to users and other applications. In particular, the media placed here will likely be scanned and inserted into the device’s MediaStore for applications such as the Gallery.
    • Valid type values include DIRECTORY_PICTURES, DIRECTORY_MUSIC, DIRECTORY_MOVIES, and DIRECTORY_RINGTONES.
  • Context.getExternalFilesDir(String type)
    • Returns a directory on external storage for media files that are specific to the application. Media placed here will not be considered public, however, and won’t show up in MediaStore.
    • This is external storage, however, so it is still possible for users and other applications to see and edit the files directly: there is no security enforced.
    • Files placed here will be removed when the application is uninstalled, so it can be a good location in which to place large content files the application needs that one may not want on internal storage.
    • Valid type values include DIRECTORY_PICTURES, DIRECTORY_MUSIC, DIRECTORY_MOVIES, and DIRECTORY_RINGTONES.
  • Context.getExternalCacheDir()
    • Returns a directory on internal storage for app-specific temporary files. The contents of this directory are visible to users and other applications.
    • Files placed here will be removed when the application is uninstalled, so it can be a good location in which to place large content files the application needs that one may not want on internal storage.

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.

6-6. Managing a Database

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:

  • A text field for the person’s name
  • A date field for the date this record was entered

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.

A Note About Upgrading

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:

  • Version 1: First release of the application
  • Version 2: Application upgrade to include phone-number field
  • Version 3: Application upgrade to include date entry inserted

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.

Using the Database

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.

  • selection: SQL WHERE clause for the given query.
  • selectionArgs: If question marks are in the selection, these items fill in those fields.
  • groupBy: SQL GROUP BY clause for the given query.
  • having: SQL ORDER BY clause for the given query.
  • orderBy: SQL ORDER BY clause for the given query.
  • limit: Maximum number of results returned from the query.

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:

  • Return all rows where the value matches a given parameter.
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).

  • Return the last 10 rows inserted into the database.
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.

  • Return rows where a date field is within a specified range (within the year 2000, in this example).
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.

  • Return rows where an integer field is within a specified range (between 7 and 10 in the example).
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.

Extra Credit

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.

9781430263227_Fig06-03.jpg

Figure 6-3. Information from a ContentProvider

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.116.85.72