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 CardView
s 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, RecyclerView
s, 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);";
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
{
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;
}
c = qb.query(database, projection, selection, selectionArgs, null, null, orderBy);
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:
{
long lRowId = database.insert(ForecastDatabaseHelper.FORECASTS_TABLE, null, values);
if (lRowId > -1)
{
_uri = ContentUris.withAppendedId(FORECASTS_CONTENT_URI, lRowId);
}
break;
}
case DETAILS:
{
long lRowId = database.insert(ForecastDatabaseHelper.DETAILS_TABLE, null, values);
if (lRowId > -1)
{
_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");
info.m_icon = getIconBytes(obj.getString("icon_url"));
info.m_strTitle = obj.getString("title");
info.m_nDetail = (int) (info.m_nPeriod / 2.0) + 1;
if (strUnit.equals("1"))
info.m_strForecast = obj.getString("fcttext_metric");
else
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;
Cursor query = cr.query(ForecastProvider.FORECASTS_CONTENT_URI, null, where, null, null);
if (query != null)
{
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
{
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;
}
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);
locationRequest.setFastestInterval(15000);
locationRequest.setPriority(LocationRequest.PRIORITY_LOW_POWER);
...
MyLocationCallback mLocationCallback = new MyLocationCallback();
mFusedLocationClient.requestLocationUpdates(locationRequest, mLocationCallback, null );
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())
{
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();
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!