DbTradeAlert for Android: Update Quotes

First post in this series: Introduction to DbTradeAlert

Previous post: Finish the Watchlist UI


Now that the most interesting parts of the UI are in place it’s time to show more interesting data. For now DbTradeAlert will download quotes on demand. At a later stage that will happen automatically every hour, too.

1. Add a Refresh Menu Item

Android Studio conveniently creates a menu for each app and therefore you just need an additional menu item to be able to communicate a demand for new quotes. But this item should be readily accessible unlike the Settings item that is hidden in the overflow menu – three vertical dots to the right of the app’s name.

1.1 Create the Icon

For life outside the overflow menu a menu item needs an icon. You can either provide your own, use one from the SDK’s icon set, or download one from Google’s Material icons. If you choose to use Android’s standard icons remember to copy them to your project as they change with Android versions.

As always I convinced Android Studio to do all the work for me:

  1. In Android Studio’s Project view locate the “res/drawable” folder
  2. In the folder’s context menu select New | Image Asset
  3. In the Generate Icons window, Configure Image Asset step
    1. Select “Action Bar and Tab Icons”
    2. Enter “ic_action_refresh” as Name
    3. Choose fitting Clipart
    4. Select “HOLO_DARK” as Theme because the icon will have a blue backround
    5. Click Next
  4. In the Confirm Icon Path step click Finish

The Project view now will show a subfolder named “ic_action_refresh.png” with four files in the drawable folder. But if you go to the drawable folder on disk it’s empty. Instead there are subfolders like “drawable-hdpi” and “drawable-mdpi” that each contain a version of the icon for a different screen density. When the app runs it will automatically use the best fitting icon.

1.2 Create the Menu Item

Menus are defined by xml files in “…\app\src\main\res\menu” and the apps current menu is in menu_watchlist_list.xml. Add a menu item to refresh quotes:

Of course this menu item references the new icon. It also has a “showAsAction” attribute of “ifRoom” which means the icon will show up to the left of the overflow menu. Note that the attribute’s namespace is “app” while the other attributes are from the “android” namespace. As a nice addition Android Studio will show the icon’s preview on the left side of the editor.

A quick sanity check before moving on:

  • Test the app – the icon shows up to the left of the overflow menu; the Settings item is still in the overflow menu
  • Optional: commit the changes

The icon’s color is actually a light gray while the app’s title and the overflow menu’s dots are white. But that’s still better than anything my drawing skills would produce.

2. Ask for Permissions

Android apps traditionally needed to specify everything they want to use – like contacts, camera, or SD card – in their manifest so the user could make an informed decision whether to install the app.

Marshmallow changed that and apps now request a permission when they need it for the first time. This way the user gets an idea about why the app is requesting a permission. Of course the user can deny the permission or revoke it later and from Marshmallow on apps have to deal with that.

But DbTradeAlert gets lucky here: it only uses so-called normal permissions – like accessing the Internet – which are granted automatically. If your app uses so-called dangerous permissions – like access contacts or camera – make shure it can deal with denied and revoked permissions if it runs on Marshmallow and targets API levels starting with 23.

Google distinguishes between normal and dangerous permissions by how they affect the user’s privacy and the device’s operability. For example an app needs no permission to access its own directory on the SD card (unless running on API level 18 or lower) but accessing any directory outside that is considered dangerous.

DbTradeAlert needs to access the internet and check the network state beforehand. That leads to two permission requests in AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.dbremes.dbtradealert">

    <!-- uses-permission elements need to precede application element -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">

        <!-- ... -->
    </application>
</manifest>

3. Download Quotes

To download quotes will take a few seconds even on a good connection. And waiting for the download to finish would make the app unresponsive which is an absolute no-no for any app. In fact Android will show the dreaded ANR (Application Not Responding) dialog if the app is unresponsive for 5 seconds. And that’s just asking to get uninstalled.

That’s why operations like downloads need to execute on a background thread and the usual solution is to derive a class from AsyncTask, do the work in doInBackground() and signal completion from onPostExecute(). Let’s start with getting the infrastructure ready and download the quotes. Storing them and updating the UI will stay on the TODO list.

The first step is to create the class QuoteRefresherAsyncTask:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.AsyncTask;
import android.util.Log;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;

import de.dbremes.dbtradealert.DbAccess.DbHelper;

public class QuoteRefresherAsyncTask extends AsyncTask<Context, Void, Void> {
    private static final String CLASS_NAME = "QuoteRefresherAsyncTask";
    private static final String EXCEPTION_MESSAGE = "Exception caught";
    private Context context;

    @Override
    protected Void doInBackground(Context... params) {
        this.context = params[0];
        String baseUrl = "http://download.finance.yahoo.com/d/quotes.csv";
        String url = baseUrl + "?f=" + DbHelper.FormatParameter + "&s=" + getSymbolParameterValue();
        String quotes = "";
        try {
            if (isConnected()) {
                quotes = downloadQuotes(url);
                Log.d(CLASS_NAME, "quotes=" + quotes);
                // TODO: store quotes and update UI
            } else {
                // TODO: inform user
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, EXCEPTION_MESSAGE, e);
        }
        return null;
    } // doInBackground()

    private String downloadQuotes(String urlString) throws IOException {
        String result = "";
        InputStream inputStream = null;
        try {
            URL url = new URL(urlString);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setReadTimeout(10000 /* milliseconds */);
            conn.setConnectTimeout(15000 /* milliseconds */);
            conn.setRequestMethod("GET");
            conn.setDoInput(true);
            // Starts the query
            conn.connect();
            int responseCode = conn.getResponseCode();
            if (responseCode == 200) {
                inputStream = conn.getInputStream();
                result = getStringFromStream(inputStream);
            } else {
                // TODO: inform user
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
        }
        return result;
    } // downloadQuotes()

    private String getStringFromStream(InputStream inputStream) throws IOException {
        // Elaborate solution as this won't work because inputStream.available() always returns 0:
        // byte[] data = new byte[inputStream.available()];
        // inputStream.read(data);
        StringBuilder sb = new StringBuilder();
        BufferedReader rd = new BufferedReader(new InputStreamReader(inputStream));
        String line;
        while ((line = rd.readLine()) != null) {
            sb.append(line + "\n");
        }
        return sb.toString();
    } // getStringFromStream()

    private String getSymbolParameterValue() {
        String result = "";
        DbHelper dbHelper = new DbHelper(this.context);
        Cursor cursor = dbHelper.readAllSecuritySymbols();
        StringBuilder sb = new StringBuilder();
        while (cursor.moveToNext()) {
            sb.append(cursor.getString(0) + "+");
        }
        cursor.close();
        String symbols = sb.toString();
        if (sb.length() > 0) {
            symbols = symbols.substring(0, symbols.length() - 1);
        }
        // Index symbols like "^SSMI" start with a "^" which is not allowed in URLs
        try {
            symbols = URLEncoder.encode(symbols, "UTF-8");
            result += symbols;
        } catch (UnsupportedEncodingException e) {
           Log.e(CLASS_NAME, EXCEPTION_MESSAGE, e);
        }
        return result;
    } // getSymbolParameterValue()

    private boolean isConnected() {
        ConnectivityManager connectivityManager
                = (ConnectivityManager) context.getSystemService(context.CONNECTIVITY_SERVICE);
        NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
        return (networkInfo != null && networkInfo.isConnected());
    } // isConnected()

} // class QuoteRefresherAsyncTask

The 3 provided generic types when extending AsyncTask determine the parameter types for the overridable handlers doInBackground(), onProgressUpdate(), and onPostExecute(). QuoteRefresherAsyncTask only needs a parameter for doInBackground() which is the Application context needed to create an instance of DbHelper. onProgressUpdate() isn’t needed as the data will arrive almost instantaneously and onPostExecute() doesn’t need a parameter as it won’t access doInBackground()’s result.

The first task for doInBackground() is to create the URL. As the quotes will be downloaded from Yahoo Finance the base URL points to http://download.finance.yahoo.com/d/quotes.csv.

An additional Format parameter like “aa2bc4d1ghl1nopp2st1vx” controls what fields each quote should contain: “a” means Ask, “a2” means Average Daily Volume, and so on. The format parameter’s value is provided by DbHelper because it determines the order of columns which DbHelper has to parse afterwards.

The last parameter determines the symbols for which to get quotes and has a “+” between each symbol like “NESN.VX+NOVN.VX”. getSymbolParameterValue() creates that string and URL encodes it because index symbols start with a “^” which is verboten in URLs.

Next, doInBackground() needs to check if the app can access the Internet by calling isConnected(). As only a small amount of data will be downloaded – 488 bytes for the 4 sample stocks’ fields – restricting that only to Wi-Fi access isn’t necessary and currently the user has to explicitly request the download anyway. The NetworkInfo object could provide that information and a lot more like whether roaming is active.

When Internet is available downloadQuotes() creates a connection and if that is successful calls getStringFromStream() to read the downloaded data. The result passed back to doInBackground() looks like this:

86.60,3076949,86.59,"EUR","5/26/2016",86.48,87.15,86.59,"BAYER N",87.00,87.15,"-0.64%","BAYN.DE","12:01pm",1263778,"GER"
73.75,5109651,73.70,"CHF","5/26/2016",73.30,73.85,73.70,"NESTLE N",73.30,73.40,"+0.41%","NESN.VX","12:00pm",1466593,"VTX"
79.15,5067592,79.10,"CHF","5/26/2016",78.80,79.40,79.15,"NOVARTIS N",79.25,78.90,"+0.32%","NOVN.VX","12:01pm",1489267,"VTX"
97.41,2100289,97.39,"EUR","5/26/2016",97.01,97.95,97.39,"SIEMENS N",97.13,97.12,"+0.28%","SIE.DE","12:01pm",600079,"GER"

What’s left to do now is start the download from WatchlistListActivity.onOptionsItemSelected():

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();
        switch (id) {
            case R.id.action_refresh: {
                Context context = getApplicationContext();
                new QuoteRefresherAsyncTask().execute(context);
                return true;
            }
            case R.id.action_settings: {
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    }
    // ...
}

This also shows how to get the Application context from inside an Activity – no need for an Application class. Note that onOptionsItemSelected() returns true when it handled the action and otherwise lets the super class deal with it.

That’s it for downloading quotes and the TODOs will be addressed in the next sections.

4. Parse and Store Quotes

Once the quotes have been downloaded DbHelper.updateOrCreateQuotes() needs to parse and store them:

public class DbHelper extends SQLiteOpenHelper {
    private static final String CLASS_NAME = "DbHelper";
    public final static String DATE_TIME_FORMAT_STRING = "yyyy-MM-dd'T'HH:mm";
    private static final String DB_NAME = "dbtradealert.db";
    private static final int DB_VERSION = 1;
    private static final String EXCEPTION_CAUGHT = "Exception caught";
    // strings for logging
    private final static String CURSOR_COUNT_FORMAT = "%s: cursor.getCount() = %d";
    private final static String INSERT_RESULT_FORMAT = "%s: result of db.insert() into %s: %d";
    private final static String UPDATE_RESULT_FORMAT = "%s: result of db.update() for %s: %d";

    // ...

    private String getDataTimeStringFromStrings(String lastTradeDateString,
                                                   String lastTradeTimeString) {
        String lastTradeDateTimeString = null;
        Date lastTradeDate = null; // d1
        Date lastTradeTime = null; // t1
        if ("N/A".equals(lastTradeDateString) == false) {
            // Step 1: calculate lastTradeDate
            // It seems timezone matches the app's timezone so no conversion needed
            // lastTradeDateString is formatted as US date, e.g. 5/26/2016
            SimpleDateFormat lastTradeDateFormat = new SimpleDateFormat("MM/dd/yyyy");
            try {
                lastTradeDate = lastTradeDateFormat.parse(lastTradeDateString);
            } catch (ParseException e) {
                Log.e(CLASS_NAME, EXCEPTION_CAUGHT, e);
            }
            // Step 2: calculate lastTradeTime
            if ("N/A".equals(lastTradeTimeString) == false) {
                // SimpleDateFormat can't handle missing space between time and am / pm
                lastTradeTimeString = lastTradeTimeString.replace("am", " am").replace("pm", " pm");
                // lastTradeDate is in 12 hour format, e.g. 1:50pm
                // Need to specify Locale.US to avoid ParseException on am / pm part when that isn't
                // used in the default locale
                SimpleDateFormat lastTradeTimeFormat = new SimpleDateFormat("hh:mm a", Locale.US);
                try {
                    lastTradeTime = lastTradeTimeFormat.parse(lastTradeTimeString);
                } catch (ParseException e) {
                    Log.e(CLASS_NAME, EXCEPTION_CAUGHT, e);
                }
            }
        }
        // Step 3: combine lastTradeDate and lastTradeTime
        if (lastTradeDate != null && lastTradeTime != null) {
            // not using Calendar class for performance reasons
            @SuppressWarnings("deprecation")
            Date lastTradeDateTime = new Date(lastTradeDate.getTime()
                    + lastTradeTime.getHours() * 60 * 60 * 1000
                    + lastTradeTime.getMinutes() * 60 * 1000);
            // Convert to international format
            SimpleDateFormat dateTimeFormat = new SimpleDateFormat(DATE_TIME_FORMAT_STRING);
            lastTradeDateTimeString = dateTimeFormat.format(lastTradeDateTime);
        }
        return lastTradeDateTimeString;
    } // getDataTimeStringFromStrings()

    private Float getFloatFromPercentString(String s) {
        s = s.replace("%", "");
        return getFloatFromString(s);
    } // getFloatFromPercentString()

    private Float getFloatFromString(String s) {
        Float result = Float.NaN;
        try {
            result = Float.parseFloat(s);
        } catch (NumberFormatException x) {
            // Probably empty string or "N/A" - return Float.NaN;
            if ("N/A".equals(s) == false) {
                Log.e(CLASS_NAME, EXCEPTION_CAUGHT, x);
            }
        }
        return result;
    } // getFloatFromString()

    // ...

    public void updateOrCreateQuotes(String quoteCsv) {
        final String methodName = "updateOrCreateQuotes";
        Log.v(CLASS_NAME, String.format("%s: quoteCsv = %s", methodName, quoteCsv));
        // region Example
        // Calling http://download.finance.yahoo.com/d/quotes.csv?s=BAYN.DE+NESN.VX+NOVN.VX+SIE.DE&f=aa2bc4d1ghl1nopp2st1vx
        // gets back a csv file with 4 lines like this:
        // 85.50,3118324,85.47,"EUR","5/27/2016",85.23,85.93,85.49,"BAYER N",85.65,85.65,"-0.19%","BAYN.DE","10:40am",799639,"GER"
        // 74.45,5109847,74.40,"CHF","5/27/2016",73.85,74.50,74.40,"NESTLE N",74.05,74.25,"+0.20%","NESN.VX","10:40am",1360058,"VTX"
        // 79.55,5053093,79.50,"CHF","5/27/2016",79.20,79.80,79.50,"NOVARTIS N",79.30,79.35,"+0.19%","NOVN.VX","10:40am",1263633,"VTX"
        // 97.80,2098357,97.79,"EUR","5/27/2016",97.25,97.94,97.80,"SIEMENS N",97.44,97.70,"+0.10%","SIE.DE","10:40am",308498,"GER"
        // Split lines and parse each according to QuoteDownloadFormatParameter
        // This will break if values include commas, see QuoteDownloadFormatParameter!
        // endregion Example
        SQLiteDatabase db = this.getWritableDatabase();
        try {
            db.beginTransaction();
            String quoteCsvRow = null;
            String[] quoteCsvRows = quoteCsv.split("\r?\n|\r");
            for (int i = 0; i < quoteCsvRows.length; i++) {
                quoteCsvRow = quoteCsvRows[i];
                String[] values = quoteCsvRow.split(",");
                // Delete any surrounding quotes
                for (int j = 0; j < values.length; j++) {
                    values[j] = values[j].replace("\"", "");
                }
                // Extract values (ordered by index of column in quoteCsv based on QuoteDownloadFormatParameter)
                Float ask = getFloatFromString(values[0]); // a
                Integer averageDailyVolume = getIntegerFromString(values[1]); // a2
                Float bid = getFloatFromString(values[2]); // b
                String currency = values[3]; // c4
                String lastTradeDateTime
                        = getDataTimeStringFromStrings(values[4], values[13]); // d1, t1
                // ...
                Float percentChange = getFloatFromPercentString(values[11]); // p2
                String symbol = values[12]; // s
                Integer volume = getIntegerFromString(values[14]); // v
                String stockExchangeName = values[15]; // x
                if (Float.isNaN(lastTrade) == false) {
                    long securityId = getSecurityIdFromSymbol(db, symbol);
                    // Store values (ordered alphabetically)
                    ContentValues contentValues = new ContentValues();
                    contentValues.put(Quote.ASK, ask);
                    // ...
                    contentValues.put(Quote.VOLUME, volume);
                    // Just try an Update as this will only fail for a newly added security
                    int updateResult = db.update(Quote.TABLE,
                            contentValues, Quote.SECURITY_ID + " = ?",
                            new String[] {String.valueOf(securityId)});
                    Log.d(CLASS_NAME, String.format(UPDATE_RESULT_FORMAT,
                            methodName, Quote.TABLE, updateResult));
                    if (updateResult == 0) {
                        Long insertResult = db.insert(Quote.TABLE,
                                null, contentValues);
                        Log.d(CLASS_NAME, String.format(INSERT_RESULT_FORMAT,
                                methodName, Quote.TABLE, insertResult));
                    }
                } else {
                    Log.e(CLASS_NAME,
                            String.format("%s(): Invalid symbol '%s'", methodName, symbol));
                }
            }
            db.setTransactionSuccessful();
            Log.d(CLASS_NAME, methodName + ": success!");
        } finally {
            db.endTransaction();
        }
    } // updateOrCreateQuotes()
    // ...
}

The first step is to split the csv file into its individual rows and then to split each row into its individual values. All values except numbers are surrounded by double quotes which are stripped next.

Then each value in the current row is extracted according to the order defined by FormatParameter. Most values have to be converted to Float, Integer, or Date. getFloatFromString() and getIntegerFromString() simply call the type’s parse methods and return Float.NaN respectively null when that fails. getDataTimeStringFromStrings() basically does the same but for two values except that it needs to get around some quirks regarding “am” / “pm” indicators.

A quick note regarding exception logging: one should use Android’s logging infrastructure by calling Log.e() to get the most out of the logcat window’s features. Using e.PrintStackTrace() doesn’t help with that. On the other hand the Log.e() overload that takes a Throwable expects additional context info in its 2nd parameter. I haven’t found that useful as the log message already contains the stack trace and the value that threw up the parse method.

Now that the values have been extracted they are ready to go into the database – if the value of lastTrade could be determined. If not that’s usually indicative of an invalid symbol and the data is simply ignored. Otherwise after stuffing everything into a ContentValues object the code just tries an UPDATE as this will only fail for a newly added security. If the UPDATE fails a new record is INSERTed.

Note that while SQLite has an upsert statement (INSERT OR REPLACE) it doesn’t do an update but a delete followed by an insert! For autoincrementing primary keys that means the record gets a new primary key which obviously wrecks the database.

The final step is to call updateOrCreateQuotes() from QuoteRefresherAsyncTask.doInBackground():

public class QuoteRefresherAsyncTask extends AsyncTask<Context, Void, Void> {
    // ...
    @Override
    protected Void doInBackground(Context... params) {
        this.context = params[0];
        String baseUrl = "http://download.finance.yahoo.com/d/quotes.csv";
        String url = baseUrl + "?f=" + DbHelper.FormatParameter + "&s=" + getSymbolParameterValue();
        String quoteCsv = "";
        try {
            if (isConnected()) {
                quoteCsv = downloadQuotes(url);
                DbHelper dbHelper = new DbHelper(this.context);
                dbHelper.updateOrCreateQuotes(quoteCsv);
                // TODO: and update UI
            } else {
                // TODO: inform user
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, EXCEPTION_CAUGHT, e);
        }
        return null;
    } // doInBackground()
    // ...
}

Now try a download:

  • Test the app by tapping the Refresh icon – as DbTradeAlert still lacks the means to update its UI you need to restart the app to see the updated quotes
  • Optional: commit the changes

6. Update the UI

Updating the UI after the new quotes are stored will require two additions to DbTradeAlert:

  1. From QuoteRefresherAsyncTask inform WatchlistListActivity that it should update its UI
  2. Extend WatchlistListActivity so that it can update its UI

Let’s tackle them one at a time.

6.1 Communicate from QuoteRefresherAsyncTask to WatchlistListActivity

Communication from an AsyncTask to an activity has a problem: Android can destroy any activity on a whim. For example the current activity will be destroyed and recreated when the user changes the device’s orientation so the activity can use a different layout. Passing that activity to QuoteRefresherAsyncTask would create a memory leak because the additional reference prevents the activity from being garbage collected. The AsyncTask would also communicate with a defunct activity. And using a WeakReference would only solve the first part of the problem.

Broadcasts to the rescue! QuoteRefresherAsyncTask declares what intent – that is an object describing a desired action – it will broadcast and uses LocalBroadcastManager to send the intent. While it’s alive WatchlistListActivity will dynamically register itself as a recipient for this kind of intent and use a BroadcastReceiver to pick them up:

public class QuoteRefresherAsyncTask extends AsyncTask<Context, Void, Void> {
    private static final String CLASS_NAME = "QuoteRefresherAsyncTask";
    public static final String BROADCAST_ACTION_NAME = "QuoteRefresherAction";
    public static final String BROADCAST_EXTRA_NAME = "Message";
    public static final String BROADCAST_EXTRA_REFRESH_COMPLETED = "Refresh completed";
    private Context context;
    // ...

    @Override
    protected Void doInBackground(Context... params) {
        this.context = params[0];
        String baseUrl = "http://download.finance.yahoo.com/d/quotes.csv";
        String url = baseUrl
                + "?f=" + DbHelper.QuoteDownloadFormatParameter
                + "&s=" + getSymbolParameterValue();
        String quoteCsv = "";
        try {
            if (isConnected()) {
                quoteCsv = downloadQuotes(url);
                DbHelper dbHelper = new DbHelper(this.context);
                dbHelper.createOrUpdateQuotes(quoteCsv);
                sendLocalBroadcast(BROADCAST_EXTRA_REFRESH_COMPLETED);
            } else {
                // TODO: inform user
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, EXCEPTION_CAUGHT, e);
        }
        return null;
    } // doInBackground()

    // ...

    private void sendLocalBroadcast(String message) {
        Intent intent = new Intent(BROADCAST_ACTION_NAME);
        intent.putExtra(BROADCAST_EXTRA_NAME, message);
        LocalBroadcastManager.getInstance(this.context).sendBroadcast(intent);
    } // sendLocalBroadcast()
}
public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    private static final String CLASS_NAME = "WatchlistListActivity";
    // ...

    private BroadcastReceiver quoteRefresherMessageReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String message = intent.getStringExtra(QuoteRefresherAsyncTask.BROADCAST_EXTRA_NAME);
            if (QuoteRefresherAsyncTask.BROADCAST_EXTRA_REFRESH_COMPLETED.equals(message))
            {
                Log.d("BroadcastReceiver",
                        "quoteRefresherMessageReceiver triggered UI update");
            }
            Log.d("BroadcastReceiver",
                    "quoteRefresherMessageReceiver message = '" + message + "'");
        }
    };

    // ...

    @Override
    protected void onPause() {
        LocalBroadcastManager.getInstance(this).unregisterReceiver(quoteRefresherMessageReceiver);
        Log.d(CLASS_NAME, "onPause(): quoteRefresherMessageReceiver unregistered");
        super.onPause();
    } // onPause()

    @Override
    public void onResume() {
        super.onResume();
        LocalBroadcastManager.getInstance(this).registerReceiver(quoteRefresherMessageReceiver,
                new IntentFilter(QuoteRefresherAsyncTask.BROADCAST_ACTION_NAME));
        Log.d(CLASS_NAME, "onResume(): quoteRefresherMessageReceiver registered");
    } // onResume()
}

DbTradeAlert uses a local broadcast because it’s more secure and more efficient than broadcasts that can reach other apps.

Some additional info about this usage of broadcasts and intents because the next post will build on it: intents that use a string to define their action are called implicit intents because they specify their recipients only implicitly – Android uses intent filters to find out which classes will receive the intents. Explicit intents specify their recipients explicitly by specifying the recipient’s class. For performance and security reasons you would prefer explicit intents. But in this case a LocalBroadcastManager is used which already provides the performance and security benefits. Another reason to use an implicit intent is that dynamically registered BroadcastReceivers cannot receive explicit intents.

To try the new messaging infrastructure first set a filter of “quoteRefresherMessageReceiver” in Android Studio’s logcat window. Then

  1. Run the app – message
    “… D/WatchlistListActivity: onResume(): quoteRefresherMessageReceiver registered”
  2. Tap Refresh – message
    “… D/BroadcastReceiver: quoteRefresherMessageReceiver triggered UI update” and “… ‘Refresh completed'”
  3. Tap the phone’s home button – message
    “… D/WatchlistListActivity: onPause(): quoteRefresherMessageReceiver unregistered”
  4. Optional: commit the changes

To clear logcat when running this repeatedly make shure to use the Clear button to the left of the logcat window. This will clear the device’s logcat, too. The context menu’s Clear item won’t do that.

What could still happen is that the activity get’s destroyed after starting a download and that finishes before the new activity has registered itself as a receiver. I can live with that and just tap Refresh again.

When you just need to update a View a possible solution is to implement the AsyncTask as an inner class of the activity. You can then use findViewById() in onPostExecute() to get hold of the View because onPostExecute() runs on the UI thread.

While we are at it let’s report errors back, too:

public class QuoteRefresherAsyncTask extends AsyncTask<Context, Void, Void> {
    private static final String CLASS_NAME = "QuoteRefresherAsyncTask";
    public static final String BROADCAST_ACTION_NAME = "QuoteRefresherAction";
    public static final String BROADCAST_EXTRA_ERROR = "Error: ";
    public static final String BROADCAST_EXTRA_NAME = "Message";
    public static final String BROADCAST_EXTRA_REFRESH_COMPLETED = "Refresh completed";
    private Context context;
    // ...

    @Override
    protected Void doInBackground(Context... params) {
        this.context = params[0];
        String baseUrl = "http://download.finance.yahoo.com/d/quotes.csv";
        String url = baseUrl
                + "?f=" + DbHelper.QuoteDownloadFormatParameter
                + "&s=" + getSymbolParameterValue();
        String quoteCsv = "";
        try {
            if (isConnected()) {
                quoteCsv = downloadQuotes(url);
                DbHelper dbHelper = new DbHelper(this.context);
                dbHelper.createOrUpdateQuotes(quoteCsv);
            } else {
                sendLocalBroadcast(BROADCAST_EXTRA_ERROR + "no Internet!");
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, EXCEPTION_CAUGHT, e);
        }
        return null;
    } // doInBackground()

    // ...

    private String downloadQuotes(String urlString) throws IOException {
        String result = "";
        InputStream inputStream = null;
        try {
            URL url = new URL(urlString);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setReadTimeout(10000 /* milliseconds */);
            conn.setConnectTimeout(15000 /* milliseconds */);
            conn.setRequestMethod("GET");
            conn.setDoInput(true);
            // Starts the query
            conn.connect();
            int responseCode = conn.getResponseCode();
            if (responseCode == 200) {
                inputStream = conn.getInputStream();
                result = getStringFromStream(inputStream);
            } else {
                sendLocalBroadcast(BROADCAST_EXTRA_ERROR
                        + "download failed (response code " + responseCode + ")!");
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
        }
        return result;
    } // downloadQuotes()

    // ...

    @Override
    protected void onPostExecute(Void result) {
        sendLocalBroadcast(BROADCAST_EXTRA_REFRESH_COMPLETED);
    } // onPostExecute()

    private void sendLocalBroadcast(String message) {
        Intent intent = new Intent(BROADCAST_ACTION_NAME);
        intent.putExtra(BROADCAST_EXTRA_NAME, message);
        LocalBroadcastManager.getInstance(this.context).sendBroadcast(intent);
    } // sendLocalBroadcast()
}
public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...

    private BroadcastReceiver quoteRefresherMessageReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String message = intent.getStringExtra(QuoteRefresherAsyncTask.BROADCAST_EXTRA_NAME);
            if (message.equals(QuoteRefresherAsyncTask.BROADCAST_EXTRA_REFRESH_COMPLETED)) {
                Log.d("BroadcastReceiver",
                        "quoteRefresherMessageReceiver triggered UI update");
            }
            else if (message.startsWith(QuoteRefresherAsyncTask.BROADCAST_EXTRA_ERROR)) {
                Toast.makeText(WatchlistListActivity.this, message, Toast.LENGTH_SHORT).show();
            }
            Log.d("BroadcastReceiver",
                    "quoteRefresherMessageReceiver message = '" + message + "'");
        }
    };

    // ...
}
Error: no internet!

Reporting errors

Test the final step:

  1. Set the phone to airplane mode
  2. Run the app and tap refresh – you’ll see a message that an error occured because Internet is not available
  3. Take the phone out of airplane mode
  4. Optional: commit the changes

6.2 Update the Screen

Updating the screen is pretty straightforward: reload the quotes and inform the views about the changed data. They will take care of updating their UI themselves.

First add refreshAllWatchLists() to WatchlistListActivity:

public class WatchlistListActivity extends AppCompatActivity {
    // ...
    private void refreshAllWatchlists() {
        final String methodName = "refreshAllWatchlists";
        Cursor watchlistsCursor = this.dbHelper.readAllWatchlists();
        try {
            final int watchListIdColumnIndex
                    = watchlistsCursor.getColumnIndex(WatchlistContract.Watchlist.ID);
            while (watchlistsCursor.moveToNext()) {
                long watchListId = watchlistsCursor.getLong(watchListIdColumnIndex);
                RecyclerView recyclerView = (RecyclerView) mViewPager.findViewWithTag(watchListId);
                if (recyclerView != null) {
                    WatchlistRecyclerViewAdapter adapter
                            = (WatchlistRecyclerViewAdapter) recyclerView.getAdapter();
                    Cursor quotesCursor = this.dbHelper.readAllQuotesForWatchlist(watchListId);
                    adapter.changeCursor(quotesCursor);
                    Log.v(CLASS_NAME, String.format(
                            "%s(): changed cursor for recyclerView with tag = %d",
                            methodName, watchListId));
                } else {
                    Log.v(CLASS_NAME, String.format(
                            "%s(): cannot find recyclerView with tag = %d",
                            methodName, watchListId));
                }
            }
        }
        finally {
            DbHelper.closeCursor(watchlistsCursor);
        }
    } // refreshAllWatchlists()

    // ...
}

For each watchlist in the database the local ViewPager instance is asked to find a corresponding RecyclerView instance – the required tag will be added in the coming steps. When that RecyclerView is found its WatchlistRecyclerViewAdapter.changeCursor() method – not added yet either – is called with the new quotes.

DbHelper.closeCursor() checks if the cursor isn’t null and then calls its close() method.

With the sample data all RecyclerViews will be found. If you create many watchlists those that you haven’t looked at yet will not exist yet and findViewWithTag() will return null. That’s OK because they will show current data when they get created later anyway.

The next step is to call refreshAllWatchLists() from BroadcastReceiver.onReceive():

public class WatchlistListActivity extends AppCompatActivity {
    private static String title;
    // ...

    private BroadcastReceiver quoteRefresherMessageReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String message = intent.getStringExtra(QuoteRefresherAsyncTask.BROADCAST_EXTRA_NAME);
            if (message.equals(QuoteRefresherAsyncTask.BROADCAST_EXTRA_REFRESH_COMPLETED)) {
                Log.d("BroadcastReceiver",
                        "quoteRefresherMessageReceiver triggered UI update");
                refreshAllWatchLists();
                boolean addTimestamp = true;
                updateTitle(addTimestamp);
            }
            else if (message.startsWith(QuoteRefresherAsyncTask.BROADCAST_EXTRA_ERROR)) {
                Toast.makeText(WatchlistListActivity.this, message, Toast.LENGTH_SHORT).show();
            }
            Log.d("BroadcastReceiver",
                    "quoteRefresherMessageReceiver message = '" + message + "'");
        }
    };

    private String getTime() {
        String result = "";
        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm");
        // Seems not to be necessary:
        //sdf.setTimeZone(TimeZone.getDefault());
        result = sdf.format(new Date());
        return result;
    } // getTime()

    // ...

    private void updateTitle(boolean addTimestamp) {
        int resId = getApplicationContext().getApplicationInfo().labelRes;
        String appName = getApplicationContext().getString(resId);
        title = appName + (addTimestamp ? " @ " + getTime() : "");
        setTitle(title);
    } // updateTitle()
}

So refreshAllWatchLists() is called when QuoteRefresherAsyncTask has already stored the new quotes. After updating the UI DbTradeAlert adds a timestamp to its title – determined from the app’s resources – so the user can see at a glance when the quotes were last updated.

Remove the timestamp in WatchlistListActivity.onOptionsItemSelected() so the user has an easy way to see whether quotes are still updating:

public class WatchlistListActivity extends AppCompatActivity {
    private static final String TITLE_INSTANCE_STATE = "Title";
    // title is static to survive a user switching off the screen because
    // Android will only use savedInstanceState when the activity is about
    // to be recreated
    private static String title;
    // ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlist_list);

        // Set app title as it may have an added timestamp indicating the last refresh
        if (savedInstanceState != null) {
            title = savedInstanceState.getString(TITLE_INSTANCE_STATE);
        }
        if (TextUtils.isEmpty(title) == false) {
            setTitle(title);
        }

        // ...
    }

    // ...

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();
        switch (id) {
            case R.id.action_refresh: {
                boolean addTimestamp = false;
                updateTitle(addTimestamp);
                Context context = getApplicationContext();
                new QuoteRefresherAsyncTask().execute(context);
                return true;
            }
            case R.id.action_settings: {
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    }

    // ...

    @Override
    public void onSaveInstanceState(Bundle savedInstanceState) {
        savedInstanceState.putString(TITLE_INSTANCE_STATE, title);

        // Always call the superclass so it can save the view hierarchy state
        super.onSaveInstanceState(savedInstanceState);
    } // onSaveInstanceState()

    // ...
}

This code also shows the effort needed to keep the timestamp once it has been added:

  • If Android thinks the activity is about to be recreated it calls onSaveInstanceState() so activities can save what’s needed. That instance state is then provided in onCreate() and in onRestoreInstanceState(). A user rotating the device will create this scenario.
  • This will not happen if the user switches the screen off or uses the back button. In this case only onPause() and onResume() will be called and savedInstanceState will be null in onCreate(). DbTradeAlert simply uses a static field to keep the title’s value in this scenario.
  • The third scenario is for example when a user swipes the app away from the recent apps list – in this case even onPause() isn’t called.

Now let’s add the missing pieces. First create a changeCursor() method in WatchlistRecyclerViewAdapter:

public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {
    // ...
    private Cursor cursor;
    // ...

    public void changeCursor(Cursor newCursor) {
        if (newCursor != this.cursor) {
            this.cursor = newCursor;
            notifyDataSetChanged();
        }
    } // changeCursor()

    // ...
}

After changing the cursor a call to the super class’ notifyDataSetChanged() is all that’s needed to have the UI update itself. Some things to note:

  • The cursor field isn’t final anymore
  • notifyDataSetChanged() has specialized overloads available in case you know which items have changed. They avoid unnecessary redraws and also provide nice and consistent animations if for example an item was removed
  • CursorAdapter.changeCursor() and CursorAdapter.swapCursor() sound similar but only CursorAdapter.changeCursor() closes the old cursor

The only missing piece now is the RecyclerView’s tag. That’s a single line in WatchlistFragment.onCreateView():

public class WatchlistFragment extends Fragment {
    // ...

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_watchlist, container, false);
        view.setTag(this.watchlistId);

        // Set the adapter
        if (view instanceof RecyclerView) {
            Context context = view.getContext();
            RecyclerView recyclerView = (RecyclerView) view;
            recyclerView.setLayoutManager(new LinearLayoutManager(context));
            dbHelper = new DbHelper(context);
            Cursor cursor = dbHelper.readAllQuotesForWatchlist(this.watchlistId);
            DbHelper.Extremes quoteExtremes
                    = dbHelper.getQuoteExtremesForWatchlist(this.watchlistId);
            DbHelper.Extremes targetExtremes
                    = dbHelper.getTargetExtremesForWatchlist(this.watchlistId);
            recyclerView.setAdapter(new WatchlistRecyclerViewAdapter(
                    cursor, quoteExtremes, targetExtremes, this.listener));
        }
        return view;
    }

    // ...
}
Timestamp for last update in title bar

Timestamp for last update in title bar

Time to try the new functionality:

  1. Run the app and tap refresh – after a few seconds the new quotes show and the app’s title has a timestamp added
  2. Wait a minute and tap refresh again – the app’s title loses its timestamp for some seconds
  3. Optional: commit the changes

Next post: Schedule Quote Updates

Additional Resources

Advertisements
This entry was posted in Uncategorized and tagged , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s