Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / Android

Creating and using a content provider

4.41/5 (6 votes)
14 Feb 2018CPOL12 min read 11.3K   212  
How to use a content provider rather than a collection class (e.g., ArrayList) to hold data

Introduction

One of the first Android apps I made many years ago (circa Froyo) was one that went through my phone's contacts and showed their birthday, age, days until next birthday, and some other tidbits, all in a list format. It has worked flawlessly ever since. Even though I try to remember, at least weekly, to open the app and check for any upcoming birthdays, I started to realize that the app would be even more helpful if I did not have to go to it for checking. It should instead come to me when a birthday was happening. I had that train of thought for years. Recently I decided to do something about it.

Given that many versions of Android had come out since that app's creation, I knew I needed to update it with a newer SDK, some smarter logic, and maybe even a more modern UI. Some of my primary goals were: 1) use the notification bar to notify me of any upcoming birthdays (I also want to add anniversaries), 2) create a service activity that runs in the background to monitor the contacts database and restart itself when the device is powered on, 3) make use of a RecyclerView and CardViews instead of a ListView for displaying the event information.

All these new things! In times past, anytime I've needed to show a list of things, I've done so with an ArrayAdapter holding some sort of collection (which is then tied to a ListView). That has worked fine in those instances, but it may not always be the best solution. So I set a secondary goal to see if I could make use of a content provider to store the event information. That way, the service running in the background could update the content database whether the app was running or not. Then when the app was started, it would simply show what was in the content database.

Background

Going back to the 80s when I started coding, and continuing through today, when I want to learn how a particular set of functions or API works, I will make up a problem and then use those functions and API to solve the problem. This approach has worked out very well for me. For this app update, I had no experience with notifications, service activities, RecyclerViews, or creating a content provider. So I temporarily postponed any updates to the aforementioned app and instead set out to learn about each of these individually.

I ended up creating a few test apps to exercise each of these features, but one of them proved just a tad more useful than the others in terms of sharing. It's a well known fact in the programming world that the "Hello, world!" program is often used to illustrate the basic syntax of a programming language and thus is often the very first program people write when they are new to a language. I can't help but think that a temperature/weather app holds the same status as there seems to be no shortage of them on either the App Store or Google Play.

While I certainly was not wanting to invent anything new, I was interested, however, in using weather-related data as input to my test app. I was already a member of Weather Underground, and they had a very easy to use API (which is really just URL-based), so that was the natural choice for me. I decided to use the forecast command for no other reason than it provided just enough information (four days worth) to test my app with.

Code

Since this test app was an exercise in creating and using a content provider, I decided I would just let the JSON contents drive the layout of the underlying database. Near the top of the JSON file is an array: txt_forecast/forecastday. It contains 8 items, two for each of the four days in the forecast. For each pair, one is the forecast summary for the day and the other is the forecast summary for the night. Right after that is another array: simpleforecast/forecastday. It contains 4 items, one for each of the four days in the forecast. For each item, it contains more detailed forecast information. That lends itself to a database containing two tables: one with 8 rows that contains summary information, and another with 4 rows that contains detailed information. The two tables will have a 2-1 relationship, like:
 

Day 1 Night 1 Day 2 Night 2 Day 3 Night 3 Day 4 Night 4
Day 1 details Day 2 details Day 3 details Day 4 details


There are dozens of other sites on the web detailing how to make a content provider so I'm not going to drill down to that level. I'll just go over what I did in this test app, which was mainly incorporating a second table.

SQLiteOpenHelper

Extending the ContentProvider class requires overriding several methods, as well as extending the SQLiteOpenHelper class. For the latter, the whole of ForecastDatabaseHelper, which is an inner class, looks like:

private class ForecastDatabaseHelper extends SQLiteOpenHelper
{
    private static final String DATABASE_NAME = "forecasts.db";
    private static final int DATABASE_VERSION = 1;
    private static final String FORECASTS_TABLE = "forecasts";
    private static final String DETAILS_TABLE = "details";

    private static final String FORECASTS_TABLE_CREATE =
        "CREATE TABLE " + FORECASTS_TABLE + " ("
        + KEY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
        + KEY_PERIOD + " INTEGER, "
        + KEY_ICON + " BLOB, "
        + KEY_TITLE + " TEXT, "
        + KEY_FORECAST + " TEXT, "
        + KEY_DETAIL + " INTEGER);"; // maps to KEY_PERIOD in the DETAILS_TABLE table

    private static final String DETAILS_TABLE_CREATE =
        "CREATE TABLE " + DETAILS_TABLE + " ("
        + KEY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
        + KEY_PERIOD + " INTEGER, "
        + KEY_DATE + " TEXT, "
        + KEY_HIGHTEMP + " TEXT, "
        + KEY_LOWTEMP + " TEXT, "
        + KEY_CONDITIONS + " TEXT, "
        + KEY_ICON + " BLOB, "
        + KEY_PRECIP + " TEXT, "
        + KEY_SNOW + " TEXT, "
        + KEY_WIND + " TEXT, "
        + KEY_HUMIDITY + " TEXT);";

    //========================================================

    public ForecastDatabaseHelper( Context context, String name, SQLiteDatabase.CursorFactory factory, int version )
    {
        super(context, name, factory, version);
    }

    //========================================================

    @Override
    public void onCreate( SQLiteDatabase db )
    {
        db.execSQL(FORECASTS_TABLE_CREATE);
        db.execSQL(DETAILS_TABLE_CREATE);
    }

    //========================================================

    @Override
    public void onUpgrade( SQLiteDatabase db, int oldVersion, int newVersion )
    {
        db.execSQL("DROP TABLE IF EXISTS " + FORECASTS_TABLE);
        db.execSQL("DROP TABLE IF EXISTS " + DETAILS_TABLE);
        onCreate(db);
    }
}

Since the database class is private, details on what it looks like are kept away from the user of the content provider. This will be more evident later on.

ForecastProvider

Dealing with a content provider means most, if not all, transactions revolve around a URI. As such, we must publish the URI as it will be used to access the content provider from within the test app via a content resolver. This looks like:

public static final Uri FORECASTS_CONTENT_URI = Uri.parse("content://com.example.forecastprovider/forecasts");
public static final Uri DETAILS_CONTENT_URI   = Uri.parse("content://com.example.forecastprovider/details");

Now in the main activity when we are interacting with either table, we do so by passing one of these Uri objects to the content resolver methods.

In the provider's onCreate() method, we simply need to create an instance of ForecastDatabaseHelper, like:

@Override
public boolean onCreate()
{
    try
    {
        dbHelper = new ForecastDatabaseHelper(getContext(),
                                              ForecastDatabaseHelper.DATABASE_NAME,
                                              null,
                                              ForecastDatabaseHelper.DATABASE_VERSION);
    }
    catch(Exception e)
    {
        Log.e(TAG, "Error creating provider: " + e.getMessage());
    }

    return true;
}

Four common actions that are performed on a database are querying, inserting, deleting,and updating. For querying, we start by requesting a read-only version of the database. We then need to decode the request based on the URI (which will either be all content or a single row). The UriMatcher object that handles this decoding is created and populated like:

private static UriMatcher uriMatcher = null;

static
{
    // allocate the URI object, where a URI ending in 'forecasts' will correspond to a request
    // for all forecasts, and 'forecasts/[rowId]' will represent a single forecast row
    // the same for details
    uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    uriMatcher.addURI("com.example.forecastprovider", "forecasts", FORECASTS);
    uriMatcher.addURI("com.example.forecastprovider", "forecasts/#", FORECASTS_ID);
    uriMatcher.addURI("com.example.forecastprovider", "details", DETAILS);
    uriMatcher.addURI("com.example.forecastprovider", "details/#", DETAILS_ID);
}

After decoding, we then apply the selection, projection, and sort order parameters to the database before finally returning a Cursor. Our override of query() looks like:

@Nullable
@Override
public Cursor query( @NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder )
{
    SQLiteDatabase database = dbHelper.getReadableDatabase();

    SQLiteQueryBuilder qb = new SQLiteQueryBuilder();

    Cursor c = null;
    String orderBy = sortOrder;

    int nUriType = uriMatcher.match(uri);
    if (nUriType == FORECASTS || nUriType == FORECASTS_ID)
    {
        qb.setTables(ForecastDatabaseHelper.FORECASTS_TABLE);

        if (nUriType == FORECASTS_ID)
            qb.appendWhere(KEY_ID + "=" + uri.getPathSegments().get(1));

        if (TextUtils.isEmpty(sortOrder))
            orderBy = KEY_PERIOD;
    }
    else if (nUriType == DETAILS || nUriType == DETAILS_ID)
    {
        qb.setTables(ForecastDatabaseHelper.DETAILS_TABLE);

        if (nUriType == DETAILS_ID)
            qb.appendWhere(KEY_ID + "=" + uri.getPathSegments().get(1));

        if (TextUtils.isEmpty(sortOrder))
            orderBy = KEY_PERIOD;
    }

    // apply the query to the underlying database
    c = qb.query(database, projection, selection, selectionArgs, null, null, orderBy);

    // register the context's ContentResolver to be notified if the cursor result set changes
    c.setNotificationUri(getContext().getContentResolver(), uri);

    return c;
}

For insertion, deletion, and updating transactions, it's mostly just an exercise in mapping content provider requests to the database equivalents. Each method first attempts to decode the URI to figure out which table to modify. After that, calls to the matching database methods are made. This looks like:

@Nullable
@Override
public Uri insert( @NonNull Uri uri, @Nullable ContentValues values )
{
    SQLiteDatabase database = dbHelper.getWritableDatabase();
    Uri _uri = null;

    switch(uriMatcher.match(uri))
    {
        case FORECASTS:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert(ForecastDatabaseHelper.FORECASTS_TABLE, null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(FORECASTS_CONTENT_URI, lRowId);
            }

            break;
        }

        case DETAILS:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert(ForecastDatabaseHelper.DETAILS_TABLE, null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(DETAILS_CONTENT_URI, lRowId);
            }

            break;
        }

        default:
            throw new SQLException("Failed to insert forecast_row into " + uri);
    }

    getContext().getContentResolver().notifyChange(_uri, null);
    return _uri;
}

//============================================================

@Override
public int delete( @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs )
{
    SQLiteDatabase database = dbHelper.getWritableDatabase();

    switch(uriMatcher.match(uri))
    {
        case FORECASTS:
            database.delete(ForecastDatabaseHelper.FORECASTS_TABLE, selection, selectionArgs);
            break;

        case DETAILS:
            database.delete(ForecastDatabaseHelper.DETAILS_TABLE, selection, selectionArgs);
            break;
    }

    getContext().getContentResolver().notifyChange(uri, null);
    return 0;
}

//============================================================

@Override
public int update( @NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs )
{
    SQLiteDatabase database = dbHelper.getWritableDatabase();
    int nCount = 0;
    String strSegment;

    switch(uriMatcher.match(uri))
    {
        case FORECASTS:
            nCount = database.update(ForecastDatabaseHelper.FORECASTS_TABLE,
                                     values,
                                     selection,
                                     selectionArgs);
            break;

        case FORECASTS_ID:
            strSegment = uri.getPathSegments().get(1);
            nCount = database.update(ForecastDatabaseHelper.FORECASTS_TABLE, values,
                                     KEY_ID + "=" + strSegment + (! TextUtils.isEmpty(selection) ? " AND (" + selection + ')' : ""),
                                     selectionArgs);
            break;

        case DETAILS:
            nCount = database.update(ForecastDatabaseHelper.DETAILS_TABLE,
                                     values,
                                     selection,
                                     selectionArgs);
            break;

        case DETAILS_ID:
            strSegment = uri.getPathSegments().get(1);
            nCount = database.update(ForecastDatabaseHelper.DETAILS_TABLE, values,
                                     KEY_ID + "=" + strSegment + (! TextUtils.isEmpty(selection) ? " AND (" + selection + ')' : ""),
                                     selectionArgs);
            break;

        default:
            throw new IllegalArgumentException("Unknown URI: " + uri);
    }

    getContext().getContentResolver().notifyChange(uri, null);
    return nCount;
}

That's the meat and potatoes of the content provider and its underlying database. It needs to be registered in the mainfest by creating a provider node within the application tag, like:

<provider android:name=".ForecastProvider" android:authorities="com.example.forecast.provider"/>

Let's look at how the above parts are used in the main part of the app. Instead of adding forecast items to a ListView directly, we'll be adding them to the content provider, ForecastProvider, which in turn populates an associated ListView through a CursorAdapter.

JSON, JSON, wherefore art thou JSON

Downloading a JSON file from the Weather Underground site is straight forward. It basically just means opening an HTTP connection, reading a stream from that connection, and then parsing the return value, which is a BufferedReader object in this case. The code to do this looks like:

StringBuilder builder = new StringBuilder("");
...
URL url = new URL(Utility.getWundergroundUrl(strZIP));
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
if (connection.getResponseCode() == HttpURLConnection.HTTP_OK)
{
    String strLine;

    BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
    while ((strLine = reader.readLine()) != null)
        builder.append(strLine);

    reader.close();
    connection.disconnect();
}

With the StringBuilder object now containing the contents of the downloaded JSON file, we can go through the process of pulling out all of the pieces of interest. JSON is a widely publicized subject so I'm not going to go into detail here other than to show the pieces I culled from the forecast data. For this, we are only interested in the txt_forecast/forecastday array, like:

ContentResolver cr = getContentResolver();
String strUnit = prefs.getString("UNIT", "0");

JSONObject object = new JSONObject(builder.toString());
JSONArray array = object.getJSONObject("forecast").getJSONObject("txt_forecast").getJSONArray("forecastday");

for (int x = 0; x < array.length(); x++)
{
    JSONObject obj = array.getJSONObject(x);

    ForecastInfo info = new ForecastInfo();
    info.m_nPeriod    = obj.getInt("period");             // should be 0-7
    info.m_icon       = getIconBytes(obj.getString("icon_url"));
    info.m_strTitle   = obj.getString("title");
    info.m_nDetail    = (int) (info.m_nPeriod / 2.0) + 1; // should be 1-4

    if (strUnit.equals("1"))
        info.m_strForecast = obj.getString("fcttext_metric");
    else // if (strUnit.equals("0"))
        info.m_strForecast = obj.getString("fcttext");

    addNewForecast(cr, info);
}

A few items to note here are: 1) since there are only 8 forecast items in the array, the period member should be a number in the 0-7 range, 2) the icon field in the forecasts table is a BLOB type so we need to convert the Bitmap data to an array of bytes for storing, 3) since there are two rows in the forecasts table for every one row in the details table, the detail member is used to hold that "mapping."

With the ForecastInfo object populated, we then proceed to add a new row to the forecasts table. This looks like:

try
{
    ContentValues values = new ContentValues();
    values.put(ForecastProvider.KEY_PERIOD,   forecastInfo.m_nPeriod);
    values.put(ForecastProvider.KEY_ICON,     forecastInfo.m_icon);
    values.put(ForecastProvider.KEY_TITLE,    forecastInfo.m_strTitle);
    values.put(ForecastProvider.KEY_FORECAST, forecastInfo.m_strForecast);
    values.put(ForecastProvider.KEY_DETAIL,   forecastInfo.m_nDetail);

    String where = ForecastProvider.KEY_PERIOD + " = " + forecastInfo.m_nPeriod;

    // since the 'forecast' action always gives today plus 3 more days, and those change every day, our database will always have just 8 rows in it
    // it's like an 8-item window that is always moving forward, with older days falling off and newer days coming on
    Cursor query = cr.query(ForecastProvider.FORECASTS_CONTENT_URI, null, where, null, null);
    if (query != null)
    {
        // if this object's period does not exist, add it, else update it
        if (query.getCount() == 0)
            cr.insert(ForecastProvider.FORECASTS_CONTENT_URI, values);
        else
            cr.update(ForecastProvider.FORECASTS_CONTENT_URI, values, where, null);

        query.close();
    }
}
catch(Exception e)
{
    Log.e("Test2", "Error adding ForecastInfo object: " + e.getMessage());
}

As was alluded to before, we don't add to the database tables directly, but do so through a ContentResolver which uses the URI to know which table to modify. The underlying ContentProvider takes care of communicating directly with the database.

The ForecastDetails data is handled in an almost identical fashion. About the only foreseeable difference would be the distinction between imperial and metric numbers. Since the JSON file contains both, we aren't having to do any conversions between them. I had been using this app for several weeks when I found myself wanting to know the actual "feels like" temperature for a given day's forecast. That is not provided by Weather Underground's forecast command so I did have to add a bit of code to do the various conversions because the wind chill formula requires both the air temperature and wind speed to be in imperial units (fahrenheit and miles per hour, respectively). This looks like:

JSONArray array = object.getJSONObject("forecast").getJSONObject("simpleforecast").getJSONArray("forecastday");

for (int x = 0; x < array.length(); x++)
{
    JSONObject obj = array.getJSONObject(x);
    JSONObject o = obj.getJSONObject("date");

    ForecastDetails details = new ForecastDetails();
    details.m_nPeriod       = obj.getInt("period");
    details.m_strDate       = String.format(Locale.getDefault(), "%s, %s %s, %s", o.getString("weekday"), o.getString("monthname"), o.getString("day"), o.getString("year"));
    details.m_strConditions = obj.getString("conditions");
    details.m_icon          = getIconBytes(obj.getString("icon_url"));
    details.m_strHumidity   = obj.getString("avehumidity") + " %";

    if (strUnit.equals("1"))
    {
        Celsius celsiusHigh = new Celsius(obj.getJSONObject("high").getString("celsius"));
        Celsius celsiusLow  = new Celsius(obj.getJSONObject("low").getString("celsius"));
        WindKph windKph     = new WindKph(obj.getJSONObject("avewind").getString("kph"));

        details.m_strHighTemp = String.format(Locale.getDefault(), "%.0f\u2103 (%.0f\u2103)", celsiusHigh.dTemperature, celsiusHigh.getWindChill(windKph));
        details.m_strLowTemp  = String.format(Locale.getDefault(), "%.0f\u2103 (%.0f\u2103)", celsiusLow.dTemperature, celsiusLow.getWindChill(windKph));
        details.m_strPrecip   = obj.getJSONObject("qpf_allday").getString("mm") + " mm";
        details.m_strSnow     = obj.getJSONObject("snow_allday").getString("cm") + " cm";
        details.m_strWind     = windKph.nSpeed + " kph from the " + obj.getJSONObject("avewind").getString("dir");
    }
    else // if (strUnit.equals("0"))
    {
        Fahrenheit fahrenheitHigh = new Fahrenheit(obj.getJSONObject("high").getString("fahrenheit"));
        Fahrenheit fahrenheitLow  = new Fahrenheit(obj.getJSONObject("low").getString("fahrenheit"));
        WindMph windMph           = new WindMph(obj.getJSONObject("avewind").getString("mph"));

        details.m_strHighTemp = String.format(Locale.getDefault(), "%.0f\u2109 (%.0f\u2109)", fahrenheitHigh.dTemperature, fahrenheitHigh.getWindChill(windMph));
        details.m_strLowTemp  = String.format(Locale.getDefault(), "%.0f\u2109 (%.0f\u2109)", fahrenheitLow.dTemperature, fahrenheitLow.getWindChill(windMph));
        details.m_strPrecip   = obj.getJSONObject("qpf_allday").getString("in") + " in.";
        details.m_strSnow     = obj.getJSONObject("snow_allday").getString("in") + " in.";
        details.m_strWind     = windMph.nSpeed + " mph from the " + obj.getJSONObject("avewind").getString("dir");
    }

    addNewDetail(cr, details);
}

You can find the details of the Temperature and Wind classes in the downloadable code.

Earlier I mentioned that I sometimes use an ArrayAdapter to hold a collection of data (e.g., ArrayList). That adapter is then associated with a ListView. When data in the collection changes, the ListView is notified that it needs to refresh itself by calling the adapter's notifyDataSetChanged() method. When dealing with ContentProvider, SimpleCursorAdapter, and CursorLoader objects, things change. While there is more code involved, the pros will outweigh the cons if your project's design can justify the need to have data stored in a content provider rather than an array.

The app

I use two different SimpleCursorAdapter objects for this app: one for the main list of forecasts, and the other for the details dialog. They each use a different query to pull data from the content provider's database. You can see those queries in the downloadable code. A point worth mentioning is that both adapters make use of the setNewValue() method to set the ImageView's icon. By default, setNewValue() deals with TextView and ImageView views. As was mentioned earlier, a Bitmap is not being stored in the BLOB column of either table but rather an array of bytes that comprise the Bitmap. To handle this, we simply need to check which view is currently being processed. If it is an ImageView view, we intercede like:

public boolean setViewValue( View view, Cursor cursor, int columnIndex )
{
    if (view.getId() == R.id.image)
    {
        try
        {
            byte[] icon = cursor.getBlob(columnIndex);

            ImageView image = (ImageView) view;
            image.setImageBitmap(Utility.getImage(icon));

            return true;
        }
        catch (Exception e)
        {
            Log.e("Test2", e.getMessage());
        }
    }

    return false; // handle things normally
}


While the intent of this article was not the app itself, but more about the journey on how I got there, pictures of the finished product are always a nice touch. I'm a very visual person so these four pictures are worth at least 4,000 words!

When the app is started for the first time, it does not know what ZIP Code you are wanting to monitor so a Snackbar is in place to let you know that. Clicking the Settings button will take you to the Preferences screen where you can either provide a ZIP Code or opt to monitor your current location.




Once a location has been established, either from a specific ZIP Code or the current location, that area's 4-day forecast is displayed.

Clicking on one of the days brings up a dialog showing a bit more detail about that day's forecast.


The Preferences screen allows you to change what location is being monitored, what unit of measurement is used (i.e., imperial or metric), and how shading is to look in regards to grouping the days. Regarding that last part, lists of items are always a bit easier to process visually if one item is distinguishable from another, even slightly. I would typically add a few lines of code to the adapter's getView() method to alternate colors depending on whether it was an odd or even row. I went one step further for this app and gave the option of also alternating colors between days. That way it visually groups both Monday items together, both Tuesday items together, etc.

In the downloadable code, you may notice that I call registerOnSharedPreferenceChangeListener() in order to be notified of any changes made to the preferences. However, in the handler for that notification, I don't do anything other than make a note of the new ZIP code (which is used when a new location is detected). The reason I don't request a new forecast here is because when the SettingsActivity is dismissed, MainActivity gets recreated and its onResume() method calls retrieveForecasts().

It's worth mentioning that by enabling the Use current location checkbox, the app will update itself as your location changes. This might prove beneficial but at the expense of slightly more battery consumption.



The current location is obtained using a FusedLocationProviderClient object and extending the LocationCallback class. Creating the object and requesting location updates looks like:

FusedLocationProviderClient mFusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
...
LocationRequest locationRequest = new LocationRequest();
locationRequest.setInterval(1200000);      // 20 minutes
locationRequest.setFastestInterval(15000); // 15 seconds
locationRequest.setPriority(LocationRequest.PRIORITY_LOW_POWER);
...
MyLocationCallback mLocationCallback = new MyLocationCallback();
mFusedLocationClient.requestLocationUpdates(locationRequest, mLocationCallback, null /* Looper */);

Since this app's requirement was ZIP Code based, I did not need micro accuracy so the permission requested in the AndroidManifest.xml file is ACCESS_COARSE_LOCATION and the location request's priority is set to low which is "city" level. Once a new location has been detected, the results are sent to the callback's onLocationResult() method. From the LocationResult parameter, we can reverse geocode the latitude and longitude to get the ZIP Code, like:

public void onLocationResult( LocationResult locationResult )
{
    try
    {
        Location loc = locationResult.getLastLocation();
        if (loc != null)
        {
            double lat = loc.getLatitude();
            double lng = loc.getLongitude();

            Geocoder geoCoder = new Geocoder(MainActivity.this, Locale.getDefault());
            List<Address> addresses = geoCoder.getFromLocation(lat, lng, 1);
            if (addresses != null && ! addresses.isEmpty())
            {
                // only bother updating the preference and retrieving forecasts if the ZIP code changed
                String strNewZIP = addresses.get(0).getPostalCode();
                if (! strNewZIP.equals(m_strZIP))
                {
                    m_strZIP = strNewZIP;

                    SharedPreferences.Editor editor = prefs.edit();
                    editor.putString("ZIP", m_strZIP);
                    editor.commit();

                    // a different location has been detected so retrieve the forecast for it
                    retrieveForecasts();
                }
            }
        }
    }
    catch (Exception e)
    {
        Log.e("Test2", "  Error getting ZIP Code from location: " + e.getMessage());
    }
}

Epilogue

As mobile apps go, this one is very elementary with no fluff or extra bells/whistles. It has served its purpose of teaching me about creating and using a content provider. I am now in a position to take what I learned here, coupled with those other test apps, and pick up where I left off on the birthday/anniversary app that was mentioned at the start of this article. Stay tuned -- it may coming soon to a theater near you.

Enjoy!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)