DbTradeAlert for Android: Schedule Quote Updates

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 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. this new class to create the schedule for AlarmManager in its 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 implementing this 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.

1.6 Handling Marshmallow’s Doze and App Standby Modes

Now let’s have a look at the addditional requirements Android Marshmallow creates with Doze mode and App Standby mode. They both aim at saving battery power and affect all apps runnning on Marshmallow irrespective of their target SDK.

Because there is enough documentation – and speculation – on the Internet I’ll skip the theory. Fast facts:

  • Both modes defer regular AlarmManager alarms and JobScheduler jobs for inactive devices (Doze) and apps (App Standby)
  • Alarms created by setAndAllowWhileIdle() or setExactAndAllowWhileIdle() – methods added with Marshmallow – continue to go off
  • Both modes take away network access and wake locks from affected apps
  • Users can exempt an app from most of both modes’ restrictions in Settings | Battery | Overflow | Battery optimization
  • Users can manually toggle an app between active and standby in Settings | Developer Options | Inactive Apps

Let’s see how those battery optimizations affect DbTradeAlert. First change the schedue to 1 minute:

alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, 1 * 60 * 1000, 1 * 60 * 1000, pendingIntent);

Then create the new schedule by deleting the app from the device before running it from Android Studio. Its logcat window should then show a message for the schedule creation followed by:

... D/QuoteRefreshAlarmRec.: onReceive(): quote refresh alarm received; starting QuoteRefresherService
... ...
... D/QuoteRefresherService: downloadQuotes(): got 485 characters
... ...
... D/DbHelper: updateOrCreateQuotes(): success!
... D/QuoteRefresherService: onHandleIntent(): quotes updated - initiating screen refresh
... D/BroadcastReceiver: quotesRefreshedBroadcastReceiver triggered UI update

Those entries repeat about every minute. After a while the screen shuts off resulting in:
… D/WatchlistListActivity: onPause(): quoteRefresherMessageReceiver unregistered

From then on quotesRefreshedBroadcastReceiver doesn’t run anymore but the app still receives quote refresh alarms about once a minute, gets data from the internet, and writes to and reads from the database.

Now let’s see how the app behaves in Doze mode. To force the device into Doze mode execute this line in Android Studio’s Terminal window:

C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle force-idle

The answer should be “Now forced in to idle mode”. If the answer is “Unable to go idle; not enabled” execute this line:

C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle enable

The answer should be “Idle mode enabled”. Now you can force the device into Doze mode.

After that switch to the logcat window. On my device the alarms still reach the app every minute (they shouldn’t, more on this later) but QuoteRefresherService reports an exception like this:

java.net.SocketTimeoutException: failed to connect to download.finance.yahoo.com/66.196.66.213 (port 80) after 15000ms

That means the app has no network access when the device is in Doze mode.

To fix that exempt the app from battery optimization:

  1. On the device go to Settings | Battery | Overflow | Battery optimization
  2. Select All Apps
  3. Find and tap DbTradeAlert
  4. In the dialog select “Don’t optimize” and tap “Finish”

After that go back to the logcat window. The app can load quotes from the internet again.

You can check the device’s state via this command:

C:\Users\<AccountName\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle > %TEMP%\dumpsys_deviceidle1.txt

Open “dumpsys_deviceidle1.txt” (path on Windows 8.1 is “C:\Users\<AccountName>\AppData\Local\Temp\”). Near the bottom will be a line like this: “mState=IDLE”

To get the device back to normal just turn the screen on. If you then create the file again like above the line will read “mState=ACTIVE”.

Of course your users won’t know that they need to exempt DbTradeAlert from battery optimization. Here is how to ask them for it:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...

    @SuppressWarnings("NewApi")
    private void ensureExemptionFromBatteryOptimizations() {
        if (Utils.isAndroidBeforeMarshmallow() == false) {
            String packageName = getPackageName();
            PowerManager powerManager = getSystemService(PowerManager.class);
            if (powerManager.isIgnoringBatteryOptimizations(packageName) == false) {
                String explanation = "DbTradeAlert needs to download quotes even when in background!";
                Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
                Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
                        .setData(Uri.parse("package:" + packageName));
                startActivity(intent);
            }
        }
    } // ensureExemptionFromBatteryOptimizations()

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

        this.dbHelper = new DbHelper(this);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        // Set up the ViewPager with the watchlist adapter.
        WatchlistListPagerAdapter watchlistListPagerAdapter
                = new WatchlistListPagerAdapter(getSupportFragmentManager(), dbHelper);
        mViewPager = (ViewPager) findViewById(R.id.container);
        mViewPager.setAdapter(watchlistListPagerAdapter);
        // Request user to whitelist app from Doze and App Standby
        ensureExemptionFromBatteryOptimizations();
        // Create initial quote refresh schedule (just overwrite existing ones)
        Log.d(CLASS_NAME, "onCreate(): creating quote refresh schedule");
        createQuoteRefreshSchedule();
    } // onCreate()

    // ...
}
package de.dbremes.dbtradealert;

import android.database.Cursor;
import android.os.Build;

public class Utils {
    public static  boolean isAndroidBeforeMarshmallow() {
        return Build.VERSION.SDK_INT < Build.VERSION_CODES.M;
    } // isAndroidBeforeMarshmallow()

    // ...
} // class Utils
<?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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

    <!-- application element -->

</manifest>

WatchlistListActivity.ensureExemptionFromBatteryOptimizations() shows a Toast explaining the app’s needs while asking for exemption from battery optimization. Asking for exemption from battery optimization requires android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS but the Toast is optional.

Without the “@SuppressWarnings(“NewApi”)” annotation Android Studio would complain that the overload of Context.getSystemService() taking a class and PowerManager.isIgnoringBatteryOptimizations() require API level 23.

Be aware that quite a few apps got banned from the Play Store for asking users to exempt them from battery optimization – or maybe even for just asking for android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS in the manifest. Be sure to check Google’s “Acceptable Use Cases for Whitelisting” before you implement this.

Asking for exemption

Asking for exemption

That said first delete the app from the device. Only then run it from Android Studio so onCreate() gets trigggered. When the app starts you’ll see a dialog and an explaining Toast. Tap “Yes” to give DbTradeAlert network access even when the device is in Doze mode or the app is in App Standby mode.

Optionally check if the app is now exempt from battery optimization:
C:\Users\\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle > %TEMP%\dumpsys_deviceidle2.txt

Under “Whitelist user apps:” you should see “de.dbremes.dbtradealert”.

Surprisingly the alarms kept going off every minute even when my device was in Doze mode. It turns out that my Moto G 2nd Gen. doesn’t support the new battery optimization even though it runs Marshmallow and the guys from Cyanogen Mod got it working for that device. My experience with emulators – both Google’s and Visual Studio’s – was even worse. So here is what should work even on a device that properly supports Doze and App Standby:

package de.dbremes.dbtradealert;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import android.util.Log;

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

    @Override
    @SuppressWarnings("NewApi")
    public void onReceive(Context context, Intent intent) {
        // Create schedule for quote refresh
        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);
        // Create schedule for quote refresh (every hour, starting 1 hour from now)
        if (Utils.isAndroidBeforeMarshmallow()) {
                alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
                        AlarmManager.INTERVAL_HOUR, pendingIntent);
            } else {
                alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR, pendingIntent);
        }
        // Use only for testing Doze and App Standby modes!
//        alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
//                1 * 60 * 1000, 1 * 60 * 1000, pendingIntent);
        // Log what was done
        String scheduleCreationType = "";
        if (intent.getAction() != null) {
            if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
                scheduleCreationType = "after reboot";
            } else {
                scheduleCreationType = "by " + intent.getAction();
            }
        }
        Log.d(CLASS_NAME,
                "onReceive(): quote refresh schedule created " + scheduleCreationType);
    } // onReceive()
} // class QuoteRefreshScheduler

AlarmManager.setAndAllowWhileIdle() cannot create repeating alarms. If you want to see how the result of “adb shell dumpsys alarm” reflects this:

  1. Reinstall the app now in Android Marshmallow or a later version and take a quick snapshot of its alarms – repeatInterval will be 0
  2. Wait for the alarm to go off and take another snapshot – the alarm definition is gone and only the statistics part is left
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) {
        scheduleNextQuoteRefresh(context);
        Log.d(CLASS_NAME,
                "onReceive(): quote refresh alarm received; starting QuoteRefresherService");
        Intent service = new Intent(context, QuoteRefresherService.class);
        startWakefulService(context, service);
    } // onReceive()

    private void scheduleNextQuoteRefresh(Context context) {
        // Starting with Android Marshmallow only
        // AlarmManager.setAndAllowWhileIdle() works in Doze / App Standby mode
        // and unlike setInexactRepeating() cannot set repeating alarms. So apps
        // need to set the next alarm themselves each time an alarm goes off.
        if (Utils.isAndroidBeforeMarshmallow() == false) {
            Log.d(CLASS_NAME,
                    "scheduleNextQuoteRefresh(): ");
            Intent intent = new Intent(context, QuoteRefreshScheduler.class);
            intent.setAction("QuoteRefreshAlarmReceiver.scheduleNextQuoteRefresh()");
            context.sendBroadcast(intent);
        }
    } // scheduleNextQuoteRefresh()

} // class QuoteRefreshAlarmReceiver

QuoteRefreshAlarmReceiver simply tells QuoteRefreshScheduler to create another alarm. For logging purposes it also sets an action and WatchlistListActivity.createQuoteRefreshSchedule() will do this too.

As I have no device supporting battery optimization properly I didn’t bother to test DbTradeAlert with App Standby. And hey: It works on my machine! 🙂

2. Provide Signals so They Reach a User Even With His Phone in the Pocket

Currently signals only show up in the respective security’s reports. To actually alert the user DDbTradeAlert will use Andoid’s notification infrastructure like for example mail apps do.

Again this can be broken down to separate steps:

  1. Create the SQL to find triggered signals
  2. Build and send notifications

2.1. Create the SQL to Find Triggered Signals

Finding the triggered signals actually needs two queries: one to find the securities’ max prices and one to find the triggered signals. DbHelper’s updateSecurityMaxPrice() generates this SQL:

SELECT s._id, max_price, max_price_date, s.symbol, days_high, SUBSTR(last_price_date_time, 0, 11)
 FROM security s
	LEFT JOIN quote q ON q.security_id = s._id
 WHERE COALESCE(max_price, 0) < COALESCE(days_high, 0)
	AND COALESCE(max_price_date, '') < SUBSTR(last_price_date_time, 0, 11)

This finds all securities where Quote.DAYS_HIGH is greater than Security.MAX_PRICE. As a sanity check Security.MAX_PRICE_DATE must also be before Quote.LAST_PRICE_DATE_TIME.

SUBSTR(last_price_date_time, 0, 11) returns the date part from last_price_date_time. COALESCE(max_price, 0) returns max_price if that isn’t NULL and otherwise 0 – remember that the DB may contain NULL for any field except LAST_PRICE and _ID.

If any securities to update are found their MAX_PRICE and MAX_PRICE_DATE fields are updated by using Security.ID as a key and of course in a transaction. This has to be done in a separate step because SQLite doesn’t support JOINs in UPDATEs.

The SQL used by DbHelper.readAllTriggeredSignals() to find triggered signals is a bit longer:

/* Lower target signal */ 
 SELECT 'low' AS actual_name, days_low AS actual_value, 'L' AS signal_name, lower_target AS target_value, s.symbol
 FROM security s
	LEFT JOIN quote q ON q.security_id = s._id
 WHERE (last_price_date_time >= date('now','-1 day'))
	AND (
		lower_target IS NOT NULL
		AND days_low IS NOT NULL
		AND days_low <= lower_target ) UNION ALL /* Upper target signal */ SELECT 'high' AS actual_name, days_high AS actual_value, 'U' AS signal_name, upper_target AS target_value, s.symbol FROM security s LEFT JOIN quote q ON q.security_id = s._id WHERE (last_price_date_time >= date('now','-1 day'))
	AND (
		upper_target IS NOT NULL
		AND days_high IS NOT NULL
		AND days_high >= upper_target
	) 
 UNION ALL 
 /* Trailing stop loss signal */
 SELECT 'low' AS actual_name, days_low AS actual_value, 'T' AS signal_name, max_price * (100 - trailing_target) / 100 AS target_value, s.symbol
 FROM security s
	LEFT JOIN quote q ON q.security_id = s._id
 WHERE (last_price_date_time >= date('now','-1 day'))
	AND (
		trailing_target IS NOT NULL
		AND days_low IS NOT NULL
		AND days_low <= max_price * (100 - trailing_target) / 100
	) 
 ORDER BY s.symbol ASC

This consists of a UNION of 3 queries, each finding a type (lower, trailing, upper) of triggered signal (you’d only have to provide field names in the 1st query). Each query first checks if the quotes are up to date, then if the respective values are not NULL, and finally if the target was hit.

2.2. Build and Send Notifications

Notifications require an icon so you’ll need to add one before building them. Android Studio doesn’t provide anything suitable this time but the SDK does at:
C:\Users\<AccountName>\AppData\Local\Android\sdk\platforms\android-23\data\res\

Like the Refresh button’s icon this one has to be usable on various devices so you need to copy “emo_im_money_mouth.png” four times to the respective subfolder in your project. In my case they are below:
C:\Users\Admin\Documents\AndroidStudioProjects\DbTradeAlert\app\src\main\res\

And the source folders are:

  • drawable-hdpi
  • drawable-mdpi
  • drawable-xhdpi
  • drawable-xxhdpi

Android Studio will pick the new icons up immediately – awesome!

After that sendNotificationForTriggeredSignals() can build and send the notifications:

public class QuoteRefresherService extends IntentService {
    // ...

    private String buildNotificationLineFromCursor(Cursor cursor) {
        String result = null;
        String actualName = cursor.getString(0);
        float actualValue = cursor.getFloat(1);
        String signalName = cursor.getString(2);
        float signalValue = cursor.getFloat(3);
        String symbol = cursor.getString(4);
        result = String.format(Locale.getDefault(),
                "%s(): %s = %01.2f; %s = %01.2f", symbol, actualName,
                actualValue, signalName, signalValue);
        return result;
    } // buildNotificationLineFromCursor()

    @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);
                        // Notify user of triggered signals even if app is sleeping
                        dbHelper.updateSecurityMaxPrice();
                        sendNotificationForTriggeredSignals(dbHelper);
                } 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 reefresh - skipping alarm");
            }
        } catch (IOException e) {
              // ...
            }
            Log.e(CLASS_NAME, exceptionMessage, e);
        }
    } // onHandleIntent()

    // ...

    private void sendNotificationForTriggeredSignals(DbHelper dbHelper) {
        final String methodName = "sendNotificationForTriggeredSignals";
        Cursor cursor = dbHelper.readAllTriggeredSignals();
        if (cursor.getCount() > 0) {
            Context context = getApplicationContext();
            NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                    .setColor(Color.GREEN)
                    .setDefaults(Notification.DEFAULT_ALL)
                    .setNumber(cursor.getCount())
                    .setSmallIcon(R.drawable.emo_im_money_mouth);
            // Specify which intent to show when user taps notification
            Intent watchlistListIntent = new Intent(this, WatchlistListActivity.class);
            PendingIntent watchlistListPendingIntent
                    = PendingIntent.getActivity(context, 0, watchlistListIntent, 0);
            builder.setContentIntent(watchlistListPendingIntent);
            // Build back stack
            TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
            stackBuilder.addParentStack(WatchlistListActivity.class);
            stackBuilder.addNextIntent(watchlistListIntent);
            // Create notification
            if (cursor.getCount() == 1) {
                cursor.moveToFirst();
                String s = buildNotificationLineFromCursor(cursor);
                builder.setContentTitle("Target hit").setContentText(s);
                Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
            } else {
                builder.setContentTitle(cursor.getCount() + " Targets hit");
                NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
                builder.setStyle(inboxStyle);
                while (cursor.moveToNext()) {
                    String s = buildNotificationLineFromCursor(cursor);
                    inboxStyle.addLine(s);
                    Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                }
            }
            // Show notification
            NotificationManager notificationManager = (NotificationManager) context
                    .getSystemService(Context.NOTIFICATION_SERVICE);
            // Show new notification or update pending one
            final int notificationId = 1234;
            notificationManager.notify(notificationId, builder.build());
        }
        Log.d(CLASS_NAME,
                String.format("%s(): created %d notifications", methodName, cursor.getCount()));
        DbHelper.closeCursor(cursor);
    } // sendNotificationForTriggeredSignals()
}

After reading triggered signals the code first sets up a NotificationCompat.Builder object specifying the newly added icon with a circle of Win color behind it. Notification.DEFAULT_ALL will cause the device to use default sound, vibration and notification lights. And the amount of notifications will be visible separately.

The code then sets the intent to show when a user taps the notification with setContentIntent(). And it creates the backstack is so the device’s Back button works as expected.

If only one signal was triggered a single line of text created by buildNotificationLineFromCursor() and the title complete the notification.

If multiple signals were triggered the builder’s style will be NotificationCompat.InboxStyle which allows for multiple lines of text. Again buildNotificationLineFromCursor() creates those lines.

Finally a NotificationManager instance fires off the notification. Note that notificationId can be any number but should be unique. It’s used to update a notification with new content.

Everything happens in QuoteRefresherService.onHandleIntent() to inform the user even if his device is asleep.

The screenshots show the initial icon in the top left corner visualizing the notification – the phone will vibrate and sound depending on settings too – and the expanded notification. You see Visual Studio’s emulator because on a real device the USB related notifications would obscure DbTradeAlert.

Notification icon top left

Notification icon top left

Opened notification

Opened notification

The notification shows a trailing stop loss triggered for NOVN.VX. The trailing target is at 92.07 CHF (sample data: 90 % of the stock’s maximum price of 102.30 CHF) and the day’s low was at 78.90 CHF. It’s visible in the watchlist’s report as well (“T” and red background color in signal TextView).

Android Marshmallow added a twist to notifications: users can turn them off. They can do that for each app in Settings | Sound & notification | App notifications and turn off its notifications, prevent notifications from peeking – which DbTradeAlert doesn’t use – or even allow them in “Do not disturb” mode. Each individual app’s Settings screen also got a Notifications item for configuration.

To check if an app is blocked from sending notifications use NotificationManagerCompat.areNotificationsEnabled() and NotificationManagerCompat.getImportance() let’s you know whether your app’s notifications are priorized. Android provides no way to nag the user for enabling notifications or change their priority because the user has already decided to block explicitly this app’s notifications.

If the device uses a pin or pattern lock there is a fourth setting: hide sensitive notification content. In that case you should create different types of notifications by calling NotificationCompat.Builder.setVisibility() with one of the visibility types like NotificationCompat.VISIBILITY_SECRET. DbTradeAlert doesn’t give away any secrets so I didn’t bother with diffferent visibility types.

Next post: Add Security and Watchlist Management

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