DbTradeAlert for Android: Schedule Quote Updates – Part 1

First post in this series: Introduction to DbTradeAlert

Previous post: Update Quotes


As its name implies DbTradeAlert wants to alert the user when a target has been reached. For that DbTradeAlert needs to:

  • Constantly monitor targets
  • Provide signals so they reach a user even with his phone in the pocket

1. Constantly Monitor Targets

1.1 Some Theory

To be able to constantly monitor targets the app needs to constantly update quotes which by now only happens when the user taps Refresh. For several reasons “constantly” doesn’t mean real-time though:

  • The app’s target group of I-check-my-portfolio-every-weekend-and-that’s-enough people certainly doesn’t need it
  • Yahoo Finance doesn’t provide real-time data for most markets; for example while Frankfurt Stock Exchange reports real-time all other German stock exchanges’ reports are delayed by 15 minutes and Swiss Exchange’s reports are even delayed by 30 minutes
  • Real-time information is useless without real-time action which DbTradeAlert doesn’t provide
  • A phone doesn’t provide the availability which relying on real-time quotes would require
  • Constantly downloading quotes can get expensive without an unlimited data plan

For those reasons DbTradeAlert will only update quotes once per hour. Of course this needs to happen even if the app isn’t active or the device is asleep. A short rundown of the classes that can run tasks periodically in regard to these requirements:

  • AlarmManager:
    • Can start an app and even wake up the device
    • Schedule gets lost when device reboots
  • GcmNetworkManager: replaced by Firebase JobDispatcher
  • Firebase JobDispatcher: backport of JobScheduler that may or may not qualify – found it too late and on Android Marshmallow and later it won’t do what DbTradeAlert needs (see Doze and App Standby below)
    • Works with pre-Lollipop Android
    • Requires Google Play Services
    • Schedule survives reboots but not Google Play Services update
  • JobScheduler works like AlarmManager and additionally:
    • Jobs can be restricted to conditions like connectivity
    • Schedule survives reboots but not Google Play Services update
    • Requires API 21 / Lollipop
  • ScheduledExecutorService: only works as long as the app runs
  • TimerTask: only works as long as the app runs

While it’s possible to use a service to get around the requirement of a running app it would waste memory and battery life – Android pauses apps for a reason. And while my Moto G2 runs Marshmallow most developers will want their apps to be able to run on Pre-Lollipop Android so AlarmManager wins.

With Doze mode and App Standby mode Android Marshmallow adds another twist to scheduling tasks. I’ll skip dealing with this here both to make things easier to follow and provide readers interested in Marshmallow requirements with a coherent solution later.

Scheduling quote updates will be a huge change so let’s see what’s ahead:

  1. To create an hourly schedule DbTradeAlert needs:
    1. to register a new class extending BroadcastReceiver as a receiver of the BOOT_COMPLETED action to recreate the schedule after each reboot
    2. to create the schedule for AlarmManager in this new class’ onReceive() handler
    3. to send a broadcast from WatchlistListActivity.onCreate() to the new class to initially create the schedule
  2. The AlarmManager needs to start a quote refresh. But as there is no more user interaction involved the device could fall asleep at any time. A special pattern is advised to prevent that:
    1. AlarmManager sends a broadcast; the process is guaranteed to stay alive until the receiver’s onReceive() method returns
    2. The work can’t be done in onReceive() itself as it would block the UI because it runs on the main thread
    3. To stop the device from falling asleep before the work is finished the app needs to hold a wakelock for that time
    4. To make using the wakelock easier the WakefulBroadcastReceiver exists; this class requires the work to be done by an IntentService which in contrast to normal services runs on a worker thread
  3. It makes no sense to download quotes when the respective exchanges are closed. Therefore the IntentService will check – for now hard-coded – opening hours of exchanges when the refresh wasn’t started manually. For that the app needs:
    1. the IntentService being able to differentiate between both instantiations

This requires the following changes to DbTradeAlert:

  1. Replace QuoteRefresherAsyncTask with a new class deriving from IntentService
  2. Create a new class extending WakefulBroadcastReceiver that instantiates the new IntentService when receiving the broadcast from AlarmManager and holds a wakelock until the IntentService tells it to release that lock
  3. Replace the existing start of QuoteRefresherAsyncTask by an instantiation of the new IntentService specifying that this is a manual refresh

Let’s start with the replacement of QuoteRefresherAsyncTask.

1.2 Create QuoteRefresherService

As it will be started by a WakefulBroadcastReceiver QuoteRefresher’s super class had to be changed from AsyncTask to IntentService. Accordingly QuoteRefresherAsyncTask and its file were renamed to QuoteRefresherService.

public class QuoteRefresherService extends IntentService {
    private static final String CLASS_NAME = "QuoteRefresherService";
    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";
    public static final String INTENT_EXTRA_IS_MANUAL_REFRESH = "isManualRefresh";
    private static final String exceptionMessage = "Exception caught";
    // ...
    
    private boolean areExchangesOpenNow() {
        final String methodName = "areExchangesOpenNow";
        boolean result = false;
        Calendar now = Calendar.getInstance();
        int hourOfDay = now.get(Calendar.HOUR_OF_DAY);
        if (hourOfDay >= 9 && hourOfDay <= 18) {
            int dayOfWeek = now.get(Calendar.DAY_OF_WEEK);
            if (dayOfWeek != Calendar.SATURDAY && dayOfWeek != Calendar.SUNDAY) {
                result = true;
            } else {
                Log.d(CLASS_NAME, String.format(
                        "%s(): Exchanges closed on weekends (day = %d)",
                        methodName, dayOfWeek));
            }
        } else {
            Log.d(CLASS_NAME, String.format(
                    "%s(): Exchanges closed after hours (hour = %d)",
                    methodName, hourOfDay));
        }
        if (result) {
            Log.d(CLASS_NAME, String.format(
                    "%s(): Exchanges open", methodName));
        }
        return result;
    }// areExchangesOpenNow()

    @Override
    protected void onHandleIntent(Intent intent) {
        String baseUrl = "http://download.finance.yahoo.com/d/quotes.csv";
        String url = baseUrl
                + "?f=" + DbHelper.QuoteDownloadFormatParameter
                + "&s=" + getSymbolParameterValue();
        String quoteCsv = "";
        try {
            boolean isManualRefresh = intent.getBooleanExtra(INTENT_EXTRA_IS_MANUAL_REFRESH, false);
            if (isManualRefresh || areExchangesOpenNow()) {
                if (isConnected()) {
                    quoteCsv = downloadQuotes(url);
                    DbHelper dbHelper = new DbHelper(this);
                    dbHelper.updateOrCreateQuotes(quoteCsv);
                } else {
                    sendLocalBroadcast(BROADCAST_EXTRA_ERROR + "no Internet!");
                    Log.d(CLASS_NAME, BROADCAST_EXTRA_ERROR + "no Internet!");
                }
            } else {
                Log.d(CLASS_NAME,
                        "onHandleIntent(): exchanges closed and not a manual refresh - skipping alarm");
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, exceptionMessage, e);
            if (e instanceof UnknownHostException) {
                // java.net.UnknownHostException:
                // Unable to resolve host "download.finance.yahoo.com":
                // No address associated with hostname
                sendLocalBroadcast(BROADCAST_EXTRA_ERROR + "broken Internet connection!");
                Log.d(CLASS_NAME, BROADCAST_EXTRA_ERROR + "broken Internet connection!");
            }
            // TODO: cannot rethrow in else case as that doesn't match overridden methods signature?
        }
        finally {
            QuoteRefreshAlarmReceiver.completeWakefulIntent(intent);
        }
    } // onHandleIntent()

    // ...
}

All the work is done in onHandleIntent() which keeps the code from doInBackground() and just adds some error checking and reporting. Losing the ability to pass a Context isn’t a problem because IntentService is indirectly derived from Context. But the intent parameter’s extras come in handy to transport isManualRefresh.

As already noted there is no way to avoid waking up the device without requiring API 21 – the app can only avoid to start the radio now. onHandleIntent() therefore checks for isManualRefresh and areExchangesOpenNow().

After completing work the super class’ completeWakefulIntent() is called to signal the WakefulBroadcastReceiver to release the wakelock – no onPostExecute() needed.

1.3 Start QuoteRefresherService

The manual start of QuoteRefresherAsyncTask is changed to instantiate QuoteRefresherService and set isManualRefresh:

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: {
                boolean addTimestamp = false;
                updateTitle(addTimestamp);
                Context context = getApplicationContext();
                Intent service = new Intent(context, QuoteRefresherService.class);
                service.putExtra(QuoteRefresherService.INTENT_EXTRA_IS_MANUAL_REFRESH, true);
                startService(service);
                return true;
            }
            case R.id.action_settings: {
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    }

    // ...
}

As a service QuoteRefresherService has to be declared in AndroidManifest.xml – see line 14 in the excerpt below:

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

    <!-- other uses-permission elements ... -->
    <uses-permission android:name="android.permission.WAKE_LOCK" />

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

        <!-- activity element ... -->

        <receiver android:name=".QuoteRefreshAlarmReceiver" >
        </receiver>

        <service android:name=".QuoteRefresherService" />
    </application>
</manifest>

1.4 Create QuoteRefreshAlarmReceiver

Now to the new WakefulBroadcastReceiver. QuoteRefreshAlarmReceiver is the new class that receives broadcasts from AlarmManager to start QuoteRefresherService:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.content.Intent;
import android.support.v4.content.WakefulBroadcastReceiver;
import android.util.Log;

public class QuoteRefreshAlarmReceiver extends WakefulBroadcastReceiver {
    // Logging tag can have at most 23 characters
    final static String CLASS_NAME = "QuoteRefreshAlarmRec.";

    @Override
    public void onReceive(Context context, Intent intent) {
        Log.d(CLASS_NAME,
                "onReceive(): quote refresh alarm received; starting QuoteRefresherService");
        Intent service = new Intent(context, QuoteRefresherService.class);
        startWakefulService(context, service);
    }
}

It just creates the appropriate intent and passes it to the super class’ startWakefulService(). As a WakefulBroadcastReceiver holds a wakelock the appropriate permission has to be declared in AndroidManifest.xml – see line 5 in the excerpt above.

Any class eligible for receiving broadcasts needs to be registered with Android. As AlarmManager isn’t part of the app it cannot use local broadcasts. And broadcasts will arrive even if the app isn’t alive so the registration has to be permanent. This is achieved by extending AndroidManifest.xml – see lines 11 to 12 in the excerpt above.

1.5 Create QuoteRefreshScheduler

QuoteRefreshScheduler is the new class that creates the schedule for AlarmManager:

package de.dbremes.dbtradealert;
e(addTimestamp
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

public class QuoteRefreshScheduler extends BroadcastReceiver {
    final static String CLASS_NAME = "QuoteRefreshScheduler";

    @Override
    public void onReceive(Context context, Intent intent) {
        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        int requestCode = 0;
        Intent newIntent = new Intent(context, QuoteRefreshAlarmReceiver.class);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode,
                newIntent, PendingIntent.FLAG_UPDATE_CURRENT);
                alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
                        AlarmManager.INTERVAL_HOUR, pendingIntent);
        // Log what was done
        String scheduleCreationType;
        if (intent.getAction() != null
                && intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) {
            scheduleCreationType = "after reboot";
        } else {
            scheduleCreationType = "initially";
        }
        Log.d(CLASS_NAME, "onReceive(): quote refresh schedule created " + scheduleCreationType);
    } // onReceive()
} // class QuoteRefreshScheduler

It uses an explicit intent. That means because the receiver’s class is specified Android will deliver the broadcast only to instances of this class and ignore any intent filters. The intent is then wrapped into an PendingIntent to make it usable from outside the app – for example by AlarmManager. As FLAG_UPDATE_CURRENT is specified any existing schedule for this intent will be overwritten with the new one.

The last step is to actually create the schedule: setInexactRepeating() allows Android to save energy by batching alarms. So INTERVAL_HOUR results in intervals between 1 and 2 hours which is good enough for the app’s purpose. Note that since API level 19 even setRepeating() works like this and the only difference left is the ability to specify not only predefined intervals.

Specifying ELAPSED_REALTIME_WAKEUP does two things:

  • It’s based on the time passed since last boot and not on a wall clock like RTC_WAKEUP; this avoids having to deal with things like daylight saving time and timezone changes
  • It wakes up the device if it’s asleep

Lastly setInexactRepeating()’s second parameter determines at which time to start and the code sets it to now.

This is the broadcast receiver’s registration:

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

    <!-- other uses-permission elements ... -->
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

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

        <!-- activity element ... -->

        <receiver android:name=".QuoteRefreshAlarmReceiver" />
        <receiver android:name=".QuoteRefreshScheduler">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>

        <service android:name=".QuoteRefresherService" />
    </application>
</manifest>

This not only registers QuoteRefreshScheduler as a broadcast receiver but also makes it receive the BOOT_COMPLETED broadcast so the app can recreate its schedule after each reboot. And finally the app asks for permission to receive the ACTION_BOOT_COMPLETED broadcast from Android.

Before you run the app take a snapshot of the device’s existing alarms. Snapshots like this will be the only way to check if a new alarm is scheduled as planned because there is no way to programmatically get a list of even your own app’s alarms. And seeing the sheer amount of alarms waiting to go off will also make Google’s decision to batch them more understandable.

To create a snapshot of the device’s alarms go to Android Studio’s Terminal window and enter this line:
C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys alarm > %TEMP%\dumpsys_alarm1.txt

This will create the file C:\Users\<AccountName>\AppData\Local\Temp\dumpsys_alarm1.txt (path on Windows 8.1) – more than 3.600 lines for my device. Searching for the package name – “de.dbremes.dbtradealert” in my case – should return no results.

Now run the app. Android Studio’s logcat window should then contain a line like this:
“… D/QuoteRefreshScheduler: onReceive(): quote refresh schedule created initially”

After that take another snapshot of the device’s alarms and name it “dumpsys_alarm2.txt”. Open the file and search for your package name again. You should find the new alarm schedule now which will look like this:

Batch{78a4fff num=7 start=1527556349 end=1527580188 flgs=0x8}:
(... more alarms ...)

ELAPSED_WAKEUP #3: Alarm{63ded1b type 2 when 1526400000 de.dbremes.dbtradealert}
tag=*walarm*:de.dbremes.dbtradealert/.QuoteRefreshAlarmReceiver
type=2 whenElapsed=+5m45s877ms when=+5m45s877ms
window=+45m0s0ms repeatInterval=3600000 count=0 flags=0x0
operation=PendingIntent{47e5eb8: PendingIntentRecord{15f9c8d de.dbremes.dbtradealert broadcastIntent}}
(... more alarms ...)

Explanation:

  • Batch with ID 78a4fff has 7 alarms
  • The new alarm is the 3rd alarm in the batch, has ID 63ded1b, is of type ELAPSED_WAKEUP / 2, and was set by package “de.dbremes.dbtradealert”
  • It will go off in 5 minutes, 45 seconds, 877 milliseconds and send a broadcast to “de.dbremes.dbtradealert/.QuoteRefreshAlarmReceiver”
  • Its repeat interval is 3.600.000 milliseconds or 1 hour
  • count=0 means the alarm was never skipped

Near the bottom you’ll additionally find alarm statistics for your package:

u0a146:de.dbremes.dbtradealert +18ms running, 1 wakeups:
+18ms 1 wakes 1 alarms, last -15s103ms:
*walarm*:de.dbremes.dbtradealert/.QuoteRefreshAlarmReceiver

In this case the app received 1 alarm which did wake up the device if necessary. Processing that alarm took 18 milliseconds and the last alarm happened 15 seconds ago.

There is no official documentation of the output’s format. And note that force-stopping an app destroys all its alarm schedules.

So in this case after about 6 minutes the reports should update and the app’s title should be expanded by a timestamp like “DbTradeAlert @ 10:46” – unless the exchanges are closed (remember to correct the timezone if you use an emulator – its timezone defaults to UTC).

Android Studio’s logcat window should also contain lines like these:
... D/QuoteRefreshScheduler: onReceive(): quote refresh schedule created initially
... D/QuoteRefreshAlarmRec.: onReceive(): quote refresh alarm received; starting QuoteRefresherService
... D/QuoteRefresherService: areExchangesOpenNow(): Exchanges open
... D/QuoteRefresherService: downloadQuotes(): got 487 characters
... ...
... D/DbHelper: updateOrCreateQuotes(): success!
... D/QuoteRefresherService: onHandleIntent(): quotes updated - initiating screen refresh
... D/BroadcastReceiver: quotesRefreshedBroadcastReceiver triggered UI update
... D/WatchlistListActivity: refreshAllWatchLists(): changed cursor for recyclerView with tag = 1
... D/WatchlistListActivity: refreshAllWatchLists(): changed cursor for recyclerView with tag = 2

If that didn’t work check which log entry is the first one missing. Then check the correspoding class’ declaration and possibly required permission in AndroidManifest.xml – there is usually a missing or wrong entry.

Once the initial schedule is created and alarms go off as expected: test if the app recreates the schedule on reboots. The easiest test is to send the BOOT_COMPLETED broadcast directly to your app. Beware that sending this simply to all apps may cause havoc and some Nexus devices seem to actually reboot. That said enter this line into Android Studio’s Terminal window:
C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -c android.intent.category.HOME -n de.dbremes.dbtradealert/.QuoteRefreshScheduler

The response should look like this:

Broadcasting: Intent { act=android.intent.action.BOOT_COMPLETED cat=[android.intent.category.HOME] cmp=de.dbremes.dbtradealert/.QuoteRefreshScheduler (has extras) }
Broadcast completed: result=0

And Android Studio’s logcat window should show entries like those:
... D/QuoteRefreshScheduler: onReceive(): quote refresh schedule created after reboot
... D/QuoteRefreshAlarmRec.: onReceive(): quote refresh alarm received; starting QuoteRefresherService
... ...

If recreating the schedule worked you should of course try an actual reboot. And if you use a VCS it’s a good time to check in all those changes now.

Next post: DbTradeAlert for Android: Schedule Quote Updates – Part 2

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