DbTradeAlert for Android – Enter the Play Store

First post in this series: Introduction to DbTradeAlert

Previous post: Add Backup


There are three prerequisites to distribute your app to millions with Google’s Play Store:

  1. Create a Google account
  2. Register for a publisher account – and pay a USD 25 developer registration fee
  3. Upload your app
App in Play Store

App in Play Store

Some tips to make life easier when following this post:

  • This post focuses on just publishing an app. You may want to add things like Firebase Analytics or AdMob before that which will be covered in later posts.
  • If you don’t like to be tracked by Google don’t use your normal browser to interact with the Play Store where you have to log in.
  • Do use a password manager like KeePass because you’ll have to come up with and store 3 new very important passwords.
  • You’ll need Internet access for quite a few of the following steps.

1. Create a Google Account

To create a Google account go to https://play.google.com/apps/publish/ and create one. You’ll need to specify:

  • First and last name
  • Password
  • Date of birth – depending on the services you use you’ll need to be at least 18 years old
  • Gender
  • Country

Your username is created from the email address you specify – either an existing one or a new gmail.com address. You don’t need to specify a phone number or an existing email address.

If you didn’t use a Gmail address for signing up you’ll get a verification mail after filling out the form. Click the link in that mail to finish creating your Google account.

2. Register for a Publisher Account

If you created a Google account in the previous section you’ll already be logged into the Google Play Developer Console. Otherwise go to https://play.google.com/apps/publish/ and log in with your Google account. In any case your Google account will be extended to a publisher acccount so you don’t have to juggle two accounts.

The first step in the Google Play Developer Console is to accept the Developer Distribution Agreement. The next step is to pay a USD 25 developer registration fee which will also attach a credit card to your account. Be aware that an additional charge for abroad payments may apply – 1.75 % in my case. The payment also doesn’t include VAT.

3. Publish Your App

The Play Store requires a signed release build of each app – debug builds must not apply.

3.1 Sign Your Release Build

Signing a build adds a certificate to the app so nobody can tamper with it. It also ensures only the original publisher can update an app. That in turn means that even you cannot update your app if you lose the key or the certificate expires. Changing a password is no problem though – keytool on the command line will do that.

To create a signed release build of the app:

  1. In Android Studio click Build | Generate Signed APK
  2. In the Generate Signed APK window:
    1. If you already have a keystore specify it in the Key store path field
    2. Otherwise click Create new and in the New Key Store window:
      1. Specify the path to create a new .jks file – this needs to stay private so don’t add it to the project directory / VCS if others have access to that
      2. Enter a strong password for the key store – again store it somewhere secure
      3. Enter an alias for the key store’s first key – for example Key like “DbTradeAlertKey” if you want to use app specific keys
      4. Enter a strong password for the first key – again store it somewhere secure
      5. Chose a validity – 25 years should be fine
      6. Fill in the cerificate fields – many are not mandatory though; I just entered initial, last name, country code
      7. Click OK – the New Key Store window closes
    3. The Generate Signed APK window’s Key alias and password fields will be filled automatically
    4. Click Next
    5. The Generate Signed APK window’s next step shows the apk’s path and that it will be a release build
    6. Click Finish

The output path – C:\Users\Admin\Documents\AndroidStudioProjects\DbTradeAlert\app in my case – will now contain a file named “app-release.apk”. Not very descriptive so let’s change it.

3.2 Change the APK Name

Changing the name of the APK (Android application package) is not required as only the applicationId – “de.dbremes.dbtradealert” in this case – needs to be unique. To avoid confusion it’s still a good idea to have unique APK names.

Open “app/build.gradle (Module: app)” – note that there is also “build.gradle (Project: DbTradeAlert)” – in Android Studio and add one line:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        applicationId "de.dbremes.dbtradealert"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
        archivesBaseName = "${parent.name}-${android.defaultConfig.versionName}"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}
// ...

After applying the changes click Sync now in the top right corner.

This will produce an APK named “DbTradeAlert-1.0-release.apk”. Before that archivesBaseName defaulted to “app” because that’s the name of the module Gradle builds. “app” is now replaced by the parent project’s name “DbTradeAlert” and the versionName “1.0”.

Some notes about the various names:

  • APK name: doesn’t show up anywhere but in your file system
  • AndroidManifest.xml:
    • application’s label attribute: determines the app’s name in settings
    • activity’s label attribute: determines the app’s name in launcher
    • intent-filter’s label attribute: overrides the activity’s label attribute
  • The name you’ll specify when uploading to the Play Store is what people see in the Play Store

Also note that versionName is only meaningful to users. Android uses versionCode to determine the app version.

And yes, Android’s build system uses a programming language based on Groovy and not some XML based description like Ant. Gradle provides an object model with lots of hooks and a domain specific language – way too much to cover here.

3.3 Upload the App

Time to upload the APK. That’s done using the Google Play Developer Console at https://play.google.com/apps/publish/. If you aren’t happy with their translation to your language or need to create screenshots for an international audience just add “&hl=en” to the URL to force an English UI – you cannot change that in the UI.

On https://play.google.com/apps/publish/:

  1. Log in if logged out
  2. Go to All applications
  3. Click Add new application
  4. In the Add new application window
    1. Select a default language – “en-US” in my case
    2. Enter a title – “DbTradeAlert” in my case; that’s what Play Store users see
    3. Click Upload APK – this will create the new app as a draft, select it, and show its APK pane
  5. In the APK pane click Upload your first APK to Production
  6. Drop the APK on the popup window – after some processing the APK pane shows the new APK’s details
  7. Click Save Draft in the top right – the “APK” item on the left should get a green checkmark.

Note that the app’s status is “Draft in Prod”. It’s missing a lot of required details and couldn’t be published now anyway. To see a list of those requirements click “Why can’t I publish” in the top right corner.

If the upload failed with an error message complaining about debug build, missing certificate or unaligned APK check the steps listed in the previous section.

It seems Google evaluates apps as soon as the APK is uploaded. If an app is rejected you’ll get a mail roughly telling the reason.

The next section shows how to fill the paperwork.

3.4 Provide the Store Listing

Google wants to know a lot for its Store Listing:

  • PRODUCT DETAILS
    • Language and Title: you already provided those
    • Short description: whatever you can fit in 80 characters
    • Full description: you may want to use an optimization strategy here so users actually find your app. The description permits a limited set of html tags for
      formatting.
  • GRAPHIC ASSETS
    • Screenshots: I added 8/8 screenshots for phones
    • Hi-res icon: blew C:\Users\Admin\AppData\Local\Android\sdk\platforms\android-24\data\res\drawable-xxhdpi up to 512 pixels
    • Feature graphic: Must be exactly 1024 w x 500 h – and yes, that’s landscape! I just rescaled and rotated one of the screenshots and hope the app doesn’t get featured 🙂
  • CATEGORIZATION
    • Application type: Applications
    • Category: Finance
    • Content rating: Everyone
    • New content rating: see next section
  • CONTACT DETAILS
    • Email: as used to register for Play Store; Website and Phone not mandatory
  • PRIVACY POLICY
    • Privacy Policy: Not submitting a privacy policy URL at this time.

Click Save Draft in the top right – the “Store listing” item on the left should get a green checkmark.

3.5 Provide the Content Rating

You need to complete the content rating questionnaire:

  1. On the left side click Content Rating
  2. Explanation shows up – read and click Continue
  3. Welcome screen:
    1. Enter and confirm email address
    2. Select app category: UTILITY, PRODUCTIVITY, COMMUNICATION, OR OTHER
    3. Shows additonal questions to answer …
    4. After that click Save questionaire
    5. Calculate rating button becomes active – click it
    6. You’ll get a list with the various ratings for your app and an Apply rating button
    7. Click the Apply rating button

The “Content Rating” item on the left should get a green checkmark. The IARC certificate will show up in a few hours and you’ll also receive a mail with it.

3.6 Provide Information about Pricing & Distribution

Lots of items to mark in this one:

  1. Optionally select any special characteristics of your app like belonging to a User category like Designed for Families or a Device category like Android Wear
  2. Select whether the app is Paid or Free – apps using AdMob are free, see below
  3. Select one or multiple countries to distribute to
  4. If required fill in information about User category or Device category
  5. Mark Consent options
  6. Optionally check Marketing opt-out
  7. Mark Content guidelines
  8. Mark US export laws

Click Save Draft in the top right – the “Pricing & Distribution” item on the left should get a green checkmark.

3.7 Actually Publish

The app status should now be READY TO PUBLISH. Click Publish App in the top right and app status should change to PENDING PUBLICATION.

Google will now take several hours to publish the app – 2.5 in my case when submitting at 1500 UTC.

After the app status changed to PUBLISHED new navigation options will appear – kind of a Firebase Analytics light but with the option to download the raw data:

  • Statistics
  • User Acquisition
  • Ratings & Reviews
  • Crashes & ANRs
  • Optimization Tips
  • Pre-Launch Report
  • Promotions

You won’t be able to connnect your app to AdMob or other Google / Firebase services yet. That’s only possible once Google has indexed the app which may take a day or two.

Next post: Integrate Google Play Services

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

Creating a Development Environment for Android Apps on Windows – Part 4: Set up a Public Git Repository

First post in this series: Introduction to DbTradeAlert

Previous post: Install Git


Of course setting up a public Git repository is optional when you create a development environment. I actually did this only when the project was nearly finished – that’s why it’s name “DbTradeAlert” is sprinkled all over this post.

Publishing to a public repository makes the code available to others and creates a free backup facility.

There are various hosting services for Git repositories with the most prominent by far being GitHub. Others are BitBucket, Source Forge, CodePlex, and Google’s recent creation Cloud Source Repositories.

I went with GitHub mostly because it’s the most prominent and I have no experience with it. A potential problem is that only public repositories are free on GitHub but that’s not a problem for DbTradeAlert.

Still a long way to get to this stage ...

Still a long way to get to this stage …

8. Create a GitHub Account

Creating a GitHub account is straightforward – only username, email address and password are required. The default plan of unlimited public repositories for free is sufficient for public projects like DbTradeAlert.

Now you need to decide whether you want to use HTTPS or SSH to access GitHub:

  • Using HTTPS requires no further preparations as it uses your GitHub account’s credentials. To use SSL you need to generate keys and specify them in your GitHub account as well as in TortoiseGit.
  • You can use a credential helper with HTTPS so you don’t have to enter name and passwort for every push.
  • The HTTPS port will always be open while the SSH port may be closed.
  • With SSH you can use per-project credentials
  • If someone finds out your SSH credentials they still cannot access the account.

Both protocols are secure and if you implemented SSH access you and everyone accessing the repository can still use HTTPS. GitHub have changed their mind about which protocol they prefer and currently recommend HTTPS.

If you will use HTTPS just proceed to add a repository.

To use SSH first generate an SSH key:

  1. Start C:\Program Files\TortoiseGit\bin\puttygen.exe
  2. In PuttyGen click Generate and move your mouse cursor to generate randomness until the key has been generated
  3. Optionally enter and confirm a key passphrase – caching aside you’ll have to enter that password each time TortoiseGit connects to GitHub
  4. Copy the generated key comment
  5. Click Save private key
  6. Use the copied key comment to name the file – important:
    1. Do NOT save this file in the repository as you don’t want your private key to become public!
    2. Be shure to backup this file
  7. Keep the PuttyGen window open

Now go back to your GitHub account in the browser:

  1. In the top right corner select Settings from the dropdown list
  2. In the Settings page select SSH and GPG keys
  3. In the SSH and GPG keys page click New SSH key
  4. Copy the key comment from PuttyGen and paste it into the Title field
  5. Copy the public key from PuttyGen and paste it into the Key field – make shure to copy all of it as Ctrl+A doesn’t work
  6. Click Add SSH key
  7. Enter your GitHub password to continue
  8. Close the PuttyGen window

After a moment you’ll get an email informing you about the new SSH key to prevent misuse.

9. Add a Repository

DbTradeAlert is a somewhat special case because the project already exists in a local repository. It’s easier to create the project in GitHub and then connect TortoiseGit to it.

To add a repository to your GitHub account:

  1. Go to https://github.com/
  2. Log in
  3. Click New repository
  4. On the Create a new repository page
    1. Enter Repository name – “DbTradeAlert for Android” in my case which results in a DbTradeAlert-for-Android repository
    2. Optionally enter a description
    3. Leave Public checked – you need a paid plan to select Private
    4. Initialize this repository with a README: good idea as this file is displayed on the repository’s landing page
    5. Add .gitignore: leave this at None because the project already has a .gitignore file
    6. Add a License: I chose Apache 2.0 because it explicitly states that no trademark rights are granted and changes to the code must be noted. Android uses the same license.
    7. Click Create repository

Initializing the repository with a README is mandatory for creating an empty project simply because Git cannot handle empty folders.

10. Configure TortoiseGit to Connect to the new Repository

Before TortoiseGit can use the remote repository you have to tell it how to connect:

  1. In the project directory’s context menu select TortoiseGit | Settings
  2. Click OK in the message window telling you about Git’s hierarchical configuration
  3. In the Settings window
    1. In the left pane select Git | Remote
    2. In the URL field
      1. To use HTTPS copy the repository’s URL – https://github.com/ghdbre/DbTradeAlert-for-Android in my case
      2. To use SSH create an approproiate URL – git@github.com:ghdbre/DbTradeAlert-for-Android.git in my case
    3. The remote field will be initialized with “origin” automatically
    4. If you use SSH specify the file with your private key in the Putty key field
    5. Click Add new/Save – the Remote list has a new entry named “origin”
    6. Click OK

11. Publish the Project

Until now Git has only commited changes to the local repository. Now it’s time to commit to the remote repository. Because having two kinds of commits would be confusing committing to the remote repository is called “push”. Getting changes from the remote repository is called “pull”.

11.1 Pull New Files from GitHub

If you had GitHub create files – like Readme.md or LICENSE – in the new repository you’ll need to pull them into your local repository first:

  1. In the project directory’s context menu select TortoiseGit | Pull …
  2. In the Pull window
    1. Remote should default to “origin”
    2. AutoLoad Putty Key should be checked
    3. Click OK
  3. You’ll get a TortoiseGitPlink security alert – click Yes to always trust the host

After that TortoiseGit downloads the new files to your local repository and you are ready to push your project. Close the Progress window.

11.2 Fill Readme.md

Readme.md is the file that’s presented right on your GitHub project’s home page and it should contain the reason why anyone would download your project. I chose to fill it with the introduction to DbTradeAlert for end users – just copied everything from the web page and applied markdown to it. There are sites on the web that provide a preview for the result.

After that commit Readme.md to the local repository.

11.3 Push Project to GitHub Repository

Finally push the project to the new GitHub repository:

  1. In the project directory’s context menu select TortoiseGit | Push …
  2. In the Push window
    1. Ref | Local should default to “master”
    2. Destination | Remote should default to “origin”
    3. AutoLoad Putty Key should be checked
    4. Click OK

If you use HTTPS you’ll have to enter name and password for your GitHub account.

After that TortoiseGit compresses and uploads the project’s files to your GitHub repository. Close the Progress window.

To check the result go back to your GitHub repository and refresh the page – you should now see your project’s files and the contents of Readme.md below that list.

11.4 Add an Image to GitHub’s Landing Page

The text in Readme.md might not be enough to get people interested but a screenshot is worth a thousand words. But where do you store that file?

Most people will want to store it on GitHub itself for ease of maintenance. And the easiest way to do that is to create an issue where you attach the screenshot:

  1. In the project’s GitHub page open the Issues tab
  2. In the Issues tab click New issue
  3. Enter a description like “Screenshot for landing page”
  4. Drag your screenshot into the input area
  5. Copy the resulting URL (https://cloud.githubusercontent.com/assets/<endless string of characters>.png)
  6. Click Submit new issue
  7. Close the new issue

Now that you sneaked the screenshot on GitHub’s server add a link to it in Readme.md:

  1. Open Readme.md in an editor
  2. Enter the link
    1. in markup: ![App at work](https://cloud.githubusercontent.com/assets/<endless string of characters>.png) if you don’t need formatting
    2. in HTML: <img src=”https://cloud.githubusercontent.com/assets/<endless string of characters>.png” align=”left” /> to enjoy full control
  3. Commit the change
  4. Push the change to GitHub

Refreshing your project’s landing page should now show the new eye-catcher.

Note that this is a totally unofficial procedure and may stop working in the future. GitHub also provides personal / organizational and project pages if you need more than just a screenshot and a bit of text.

Next post: Create the Application

Additional Ressources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Backup

First post in this series: Introduction to DbTradeAlert

Previous post: Add Settings and Finishing Touches


Adding backup requires just a few lines of code or even nothing at all. But it requires an astounding amount of explanations so lets start with explanations.

Using Android’s Built-In Backup

Starting with Marshmallow Android will back up your app’s data and settings automatically. The only requirement for that is the user’s consent during Google account creation. This will also backup their WiFi passwords which is a dealbreaker for many people. That said Auto Backup just works – no effort required by you or your app’s users.

If your app targets API levels below 23 (Marshmallow) or runs on a Pre-Marshmallow device it can still use Android’s built-in backup but you have to work for it:

  1. Register for the Android backup service and put the key it issued into the app’s manifest
  2. Extend BackupAgentHelper and mention it in the app’s manifest
  3. Use FileBackupHelper and SharedPreferencesBackupHelper to point to individual files for backup
  4. Notify Google of data changes by calling BackupManager.dataChanged()

No matter which way the app uses Android’s built-in backup: it just automagically works. That also means you and your app’s users have no access to individual files and cannot control when data is backed up. And Google only keeps the last version – tough luck if that is the wrong one.

I needed access to DbTradeAlert’s database file because of the somewhat special requirement to switch between a database for personal use and one for screenshots. And being able to copy an SQLite database back and forth between the phone and a computer means you can do for example bulk updates with a tool like DB Browser for SQLite. So I needed more of an import / export functionality than a backup mechanism.

DbTradeAlert.db in DB Browser for SQLite

DbTradeAlert.db in DB Browser for SQLite

Importing and Exporting a Database

Again thanks to Stack Overflow coding that was less work than explaining it. This time let’s start with the code changes.

First step is to extend the app’s menu:

It gets an additional entry with two sub menu items to export and import the database.

The next step is to handle these sub menu items in WatchlistListActivity:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...
    private long copyFile(File sourceFile, File targetFile) {
        final String methodName = "copyFile";
        long bytesTransferred = 0;
        FileChannel sourceChannel = null;
        FileChannel targetChannel = null;
        try {
            try {
                sourceChannel = new FileInputStream(sourceFile).getChannel();
                Log.v(CLASS_NAME, String.format(
                        "%s(): sourcefile = %s", methodName, sourceFile));
                targetChannel = new FileOutputStream(targetFile).getChannel();
                Log.v(CLASS_NAME, String.format(
                        "%s(): targetFile = %s", methodName, targetFile));
                bytesTransferred
                        = targetChannel.transferFrom(sourceChannel, 0, sourceChannel.size());
                Log.v(CLASS_NAME, String.format(
                        "%s(): bytesTransferred = %d", methodName, bytesTransferred));
            } finally {
                if (sourceChannel != null) {
                    sourceChannel.close();
                }
                if (targetChannel != null) {
                    targetChannel.close();
                }
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, "Exception caught", e);
            Toast.makeText(this, e.toString(), Toast.LENGTH_LONG).show();
        }
        return bytesTransferred;
    } // copyFile()

    // ...

    private void copyDatabase(boolean isExport) {
        File sourceDb;
        File targetDb;
        String message;
        File externalFilesDir = getExternalFilesDir(null);
        if (externalFilesDir != null) {
            if (isExport) {
                sourceDb = getDatabasePath(DbHelper.DB_NAME);
                targetDb = new File(externalFilesDir, DbHelper.DB_NAME);
            } else {
                sourceDb = new File(externalFilesDir, DbHelper.DB_NAME);
                targetDb = getDatabasePath(DbHelper.DB_NAME);
            }
            long bytesCopied = copyFile(sourceDb, targetDb);
            if (isExport) {
                MediaScannerConnection.scanFile(WatchlistListActivity.this,
                        new String[]{targetDb.getAbsolutePath()}, null, null);
            }
            message = (isExport ? "Exported to " : "Imported from ")
                    + targetDb + " (" + bytesCopied + " bytes copied)";
        } else {
            message = "External files directory not available. SD card unmounted?";
        }
        Log.d(CLASS_NAME, message);
        Toast.makeText(this, message, Toast.LENGTH_LONG).show();
    } // copyDatabase()

    // ...

    @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.
        Intent intent;
        int id = item.getItemId();
        switch (id) {
            case R.id.action_export_database: {
                boolean isExport = true;
                copyDatabase(isExport);
                return true;
            }
            case R.id.action_import_database: {
                // Have user confirm overwriting of DB by import
                new AlertDialog.Builder(this)
                        .setIcon(android.R.drawable.ic_dialog_alert)
                        .setMessage("Overwrite existing database? No undo!")
                        .setNegativeButton("Cancel", null)
                        .setPositiveButton(android.R.string.yes,
                                new DialogInterface.OnClickListener() {
                                    public void onClick(DialogInterface dialog, int whichButton) {
                                        boolean isExport = false;
                                        copyDatabase(isExport);
                                    }
                                })
                        .setTitle("Overwrite DB?")
                        .show();
                return true;
            }            
            // ...
            default:
                return super.onOptionsItemSelected(item);
        }
    } // onOptionsItemSelected()

    // ...
}

Exporting a database is straightforward as WatchlistListActivity.copyDatabase() can rely on built-in methods to determine the directory of the app’s database as well as the target directory. In this instance it’s “/data/user/0/de.dbremes.dbtradealert/databases/” and “/storage/emulated/0/Android/data/de.dbremes.dbtradealert/files/”.

In both cases “0” stands for the first user created on the device – Android is a multi-user environment since 4.2 (API 17). And as Android only permits to specify the database’s name in SQLiteOpenHelper’s constructor it’s no wonder it knows where to find the database. Context.getExternalFilesDir() is where things get interesting.

The most important point about Context.getExternalFilesDir() is that “External” means shared with the outside world – it doesn’t necessarily mean physically extern like an SD card. I’ll spare you the dirty details of internal vs. external vs. SD card storage and recommend CommonsWare’s 5 part blog post about that if you can take it. Oh and in addition to the details he mentioned in 2014 Android 6.0 / Marshmallow complicated things further with adopted vs. portable SD cards.

DbTradeAlert just needs to copy the database file to or from a directory that’s accessible from a computer – a.k.a. shared – and Context.getExternalFilesDir() points to that directory. Some points to note about Context.getExternalFilesDir():

  • The directory is app specific and starting with API level 19 an app doesn’t need permissions to read from or write to it.
  • The directory gets created automatically when copyFile() does its job.
  • You can pass values like Environment.DIRECTORY_PICTURES to get specialized directories.
  • The method returns null if the external storage it points to isn’t mounted. I don’t know the scenario in which the primary external storage would be on removable media though.
  • Its sibling getExternalFilesDirs() (note plural) returns the same as getExternalFilesDir() in its first entry. The other entries point to secondary external storage which will be on removable media. Check their state with Environment.getExternalStorageState() before accessing them.
  • The directory and everything in it gets deleted when a user uninstalls the app.

Another important point is the call to MediaScannerConnection.scanFile() – without it the new file wouldn’t be visible for a connected computer. That’s because Android uses MTP (Media Transfer Protocol) to share files with the world outside since version 3.1 / Honeycomb / API level 12. With MTP a connected computer has no direct acccess to the file system but asks a server on the device to read or write directories and files. And that server needs to be informed when a new file or directory is created outside special directories for pictures, movies and such.

The only point that’s left is to ask for the permission for writing to external storage on devices with Android prior to API level 19:

<?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" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" />

    <!-- application element -->
</manifest>

To try it out:

  1. In this scenario it’s a given but a user has to make shure
    1. the device is connected to the computer
    2. the USB usage notification says “USB for file transfers”
  2. In DbTradeAlert’s overflow menu select Copy Database | Export Database
  3. In the computer’s explorer find the device and go to “Internal Storage\Android\data\de.dbremes.dbtradealert\files”
  4. Copy dbtradealert.db to change it for example with DB Browser for SQLite – it’s write-protected in its current directory
Importing a database

Importing a database

Importing a database will overwrite the app’s database and DbTradeAlert reminds the user of that. Note that an AlertDialog runs asynchronously and therefore cannot pass its result back but does its work in a callback.

And a note of caution: while SQLite is pretty robust importing the database during the app’s hourly update of quotes will probably corrupt it. So check the last update’s timestamp to avoid possible collisions.

To try the import:

  1. Make changes to the exported database on your computer
  2. Copy dbtradealert.db back to the device’s “Internal Storage\Android\data\de.dbremes.dbtradealert\files” folder
  3. In DbTradeAlert’s overflow menu select Copy Database | Import Database
  4. Accept the risk
  5. Do your changes show up in the respective Edit screen?

Next post: Enter the Play Store

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Settings and Finishing Touches

First post in this series: Introduction to DbTradeAlert

Previous post: Add Reminders


1. Add Settings

Android provides a quite sophisticated infrastructure for app settings – it even creates the settings screen’s layout for you. DbTradeAlert will use 2 settings:

  • Disable automatic download of quotes; shows how to act on settings changes and how to cancel alarms – to save battery or data or to avoid interruptions I would just set the phone in airplane mode.
  • Set the time at which exchanges are considered open; you’ll want to change this setting for example when moving out of your timezone.

1.1. Create a Basic Settings screen

Because a settings screen will basically create itself the app only needs some xml files that define what goes into that screen.

Create a new directory “xml” in the “res” directory. Then add a new file “preferences.xml” to that directory and fill it like this:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
    <CheckBoxPreference android:key="auto_refresh_preference" android:defaultValue="true" android:summary="Download quotes every hour" android:title="Auto Refresh"/>
</PreferenceScreen>

For now the app’s settings consist of a single checkbox controlling whether it automatically downloads and evaluates quotes.

Create a new Java class “SettingsFragment” and fill it like this:

package de.dbremes.dbtradealert;

import android.os.Bundle;
import android.preference.PreferenceFragment;

public class SettingsFragment extends PreferenceFragment {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preferences);
    } // onCreate()
} // class SettingsFragment

This class just connects itself to the preferences.xml file.

Now create an empty activity “SettingsActivity” and fill it like this:

package de.dbremes.dbtradealert;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;

public class SettingsActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, new SettingsFragment())
                .commit();
    } // onCreate()
} // class SettingsActivity

This class just overwrites its screen contents with SettingsFragment’s. You can delete the automatically created “activity_settings.xml”.

Finally make the Settings screen available from the main screen’s overflow menu:

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.
        Intent intent;
        int id = item.getItemId();
        switch (id) {
        // ...
            case R.id.action_settings: {
                intent = new Intent(this, SettingsActivity.class);
                startActivity(intent);
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    } // onOptionsItemSelected()

    // ...
} // class WatchlistListActivity
Basic Settings screen

Basic Settings screen

Check if the Settings screen loads:

  1. Start the app and select “Settings” from its overflow menu
  2. The Settings screen appears with its single checkbox checked

1.2. Act on Settings Changes

When the user disables the automatic download of quotes DbTradeAlert’s alarm needs to be cancelled. The first step for that is to get notified when the setting has changed:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;

public class SettingsActivity extends AppCompatActivity
        implements SharedPreferences.OnSharedPreferenceChangeListener {
    private static final String CLASS_NAME = "SettingsActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, new SettingsFragment())
                .commit();
    } // onCreate()

    @Override
    protected void onPause() {
        super.onPause();
        PreferenceManager.getDefaultSharedPreferences(this)
                .unregisterOnSharedPreferenceChangeListener(this);
    } // onPause()

    @Override
    protected void onResume() {
        super.onResume();
        PreferenceManager.getDefaultSharedPreferences(this)
                .registerOnSharedPreferenceChangeListener(this);
    } // onResume()

    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
        Log.d(CLASS_NAME, "onSharedPreferenceChanged: key = " + key);
        if (key.equals("auto_refresh_preference")) {
            Intent intent = new Intent(this, QuoteRefreshScheduler.class);
            sendBroadcast(intent);
        }
    } // onSharedPreferenceChanged()

} // class SettingsActivity

The SettingsActivity registers itself as a listener for preference changed events and in onSharedPreferenceChanged() sends a broadcast to QuoteRefreshScheduler if auto_refresh_preference has changed – that’s exactly how the initial schedule was created in WatchlistListActivity.startQuoteRefreshScheduleCreation().

The next step is to extend QuoteRefreshScheduler to handle disabled auto-refreshs:

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.preference.PreferenceManager;
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) {
        int requestCode = 0;
        Intent newIntent = new Intent(context, QuoteRefreshAlarmReceiver.class);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode,
                newIntent, PendingIntent.FLAG_UPDATE_CURRENT);
        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        boolean isAutoRefreshEnabled = PreferenceManager
                .getDefaultSharedPreferences(context).getBoolean("auto_refresh_preference", false);
        if (isAutoRefreshEnabled == false) {
            alarmManager.cancel(pendingIntent);
            Log.d(CLASS_NAME, "onReceive(): quote refresh cancelled");
        } else {
            // Create schedule for quote refresh
            if (Utils.isAndroidBeforeMarshmallow()) {
                alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        AlarmManager.INTERVAL_HOUR, AlarmManager.INTERVAL_HOUR, pendingIntent);
            } else {
                alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        60 * 60 * 1000, 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
                    && 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

If auto-refresh is disabled QuoteRefreshScheduler cancels the alarm. That works by passing the same PendingIntent that was used to create the alarm.

To test if things work as expected disable auto-refresh. Then either wait an hour to see if the alarm doesn’t go off anymore 🙂 or check the file produced by “adb shell dumpsys alarm …”.

1.3. Integrate Network Usage Related Settings Into Android

Disabling auto-refresh affects network usage. Google recommends adding an intent filter for activities achieving this to provide users direct access to the respective setting from the system’s data usage screen for that app. The intent filter looks like this:

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

    <!-- uses-permission elements -->

    <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">
        <!-- activity elements -->
        <activity android:name=".SettingsActivity">
            <intent-filter>
                <action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
        <!-- activity elements -->

        <!-- receiver elements -->

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

</manifest>
Network usage related settings integrated

Network usage related settings integrated

To try it out:

  1. After installing the app’s new version go to Settings | Apps | DbTradeAlert | Data usage
  2. The screen will show an active App Settings button and tapping it leads to the app’s Settings screen

Without the intent filter that button would be disabled. Users can also go via Settings | Data usage | <AppName> but the app might not be listed if it didn’t use much data.

1.4. Add a Setting for the Exchanges’ Business Days

DbTradeAlert defaults to business days being from Monday to Friday. But for example a lot of countries with predominantly Muslim population have their weekend on Friday and Saturday. So let’s add a setting for this.

Again everything starts with the XML but this time it’s a bit more involved: the set of days to choose from needs to be defined in an additional xml file and the default set of business days as well.

Add a new file “arrays.xml” to the “res/values” directory and fill it like this:

<resources>
    <string-array name="business_days_preference_default_values">
        <item>2</item>
        <item>3</item>
        <item>4</item>
        <item>5</item>
        <item>6</item>
    </string-array>
    <string-array name="business_days_preference_entries">
        <item>Monday</item>
        <item>Tuesday</item>
        <item>Wednesday</item>
        <item>Thursday</item>
        <item>Friday</item>
        <item>Saturday</item>
        <item>Sunday</item>
    </string-array>
    <string-array name="business_days_preference_values">
        <item>2</item>
        <item>3</item>
        <item>4</item>
        <item>5</item>
        <item>6</item>
        <item>7</item>
        <item>1</item>
    </string-array>
</resources>

The “business_days_preference_entries” array defines the values that will be shown to the user and “business_days_preference_values” defines the values that the app will use internally. Note that for Java the first day of the week is Sunday. Finally “business_days_preference_default_values” defines which subset of “business_days_preference_values” will be checked by default.

After that extend preferences.xml with a “MultiSelectListPreference” that makes use of those arrays:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
    <CheckBoxPreference android:key="auto_refresh_preference" android:defaultValue="true" android:summary="Download quotes every hour" android:title="Auto Refresh"/>
    <MultiSelectListPreference android:key="business_days_preference" android:defaultValue="@array/business_days_preference_default_values" android:entries="@array/business_days_preference_entries" android:entryValues="@array/business_days_preference_values" android:summary="Days on which auto refresh is active" android:title="Business Days" />
</PreferenceScreen>
Setting for business days

Setting for business days

When you start the app and go to Settings there will be a new “Business Days” entry. Tap that and you’ll see the default business days checked.
###(screenshot)

If you really mess up the first time – I did – it may be best to delete the app before trying the fix. That’s because settings are persisted to a file and those wrong settings won’t go away – the file survives cache clearing and deleting the app’s data will also delete the database. On an emulator you can download the file to have a look at it. The command for DbTradeAlert would be:

"adb pull /data/data/de.dbremes.dbtradealert/shared_prefs/de.dbremes.dbtradealert_preferences.xml %TEMP%/de.dbremes.dbtradealert_preferences.xml"

On a Windows 8 machine that command will copy the file to C:\Users\<AccountName>\AppData\Local\Temp\de.dbremes.dbtradealert_preferences.xml.

If the app is debuggable you can also access the file in a backup. Caveat: currently the preferences file will only be generated when you open the settings screen for the first time – until then even the shared_prefs directory doesn’t exist.

With the UI in place updating QuoteRefresherService.areExchangesOpenNow() is next:

public class QuoteRefresherService extends IntentService {
    // ...

    private boolean areExchangesOpenNow() {
        final String methodName = "areExchangesOpenNow";
        boolean result = false;
        Calendar now = Calendar.getInstance();
        boolean isBusinessHour = isBusinessHour(now);
        if (isBusinessHour) {
            result = isBusinessDay(now);
        }
        Log.d(CLASS_NAME, String.format(
                "%s(): Exchanges %sopen", methodName, result ? "" : "not "));
        return result;
    }// areExchangesOpenNow()

    // ...

    private boolean isBusinessDay(Calendar now) {
        boolean result = false;
        int thisDayOfWeek = now.get(Calendar.DAY_OF_WEEK);
        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        Set businessDays = sharedPreferences.getStringSet(
                "business_days_preference", Collections.<String>emptySet());
        if (businessDays != null) {
            Iterator<String> iterator = businessDays.iterator();
            while (iterator.hasNext()) {
                String businessDayString = iterator.next();
                if (Integer.valueOf(businessDayString).equals(thisDayOfWeek)) {
                    result = true;
                    break;
                }
            }
        }
        // Log result details
        String s = String.valueOf(thisDayOfWeek) + (result ? " is" : " is not")
                + " a business day (" + businessDays.toString() + ")";
        Log.v(CLASS_NAME, s);
        return result;
    } // isBusinessDay()

    private boolean isBusinessHour(Calendar now) {
        boolean result = false;
        int hourOfDay = now.get(Calendar.HOUR_OF_DAY);
        if (hourOfDay >= 9 && hourOfDay <= 18) {
            result = true;
        }
        // Log result details
        String s = String.valueOf(hourOfDay) + (result ? " is" : " is not")
                + " in business hours (09 - 18)";
        Log.v(CLASS_NAME, s);
        return result;
    }

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

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

        // Without this the app's preferences will be empty until the user opens
        // its Settings screen for the 1st time
        boolean readAgain = false;
        PreferenceManager.setDefaultValues(this, R.xml.preferences, readAgain);

        // ...
    }

    // ...
}

As you saw the app settings automatically initialize from their default values when a user opens the settings screen. But now the app probably accesses its settings before that happens. For that reason PreferenceManager.setDefaultValues() is called. Note that in contrast to some Internet knowledge readAgain does what it says – control the settings cache. The user’s changes will never be overwritten.

Other than that it’s the same logic as before with just a little refactoring. But instead of checking hard-coded values isBusinessDay() now checks business_days_preference’s value. This can only be accessed as an unordered set of strings and there is nothing like isValueInSet(). The Log output looks like this:
... V/QuoteRefresherService: 5 is a business day ([5, 4, 3, 6, 2])

When testing this on Visual Studio’s emulator I found that Java’s Calendar.get(Calendar.HOUR_OF_DAY) returns the hour in 12h format. Even switching to 24h format in the emulator’s settings didn’t change that. Well, works on my phone :-).

1.5. Add a Setting for the Exchanges’ Business Hours

The orignal idea to set the business hours was to use some specialized control so the user can easily select a timespan. But Android doesn’t provide an appropriate control and creating one would be a project on its own. The next best thing would be using two SeekBars but that still has the problem of not preventing the user from entering a starting business hour that is after his ending business hour. Using EditTextPreferences would be even worse in that regard. So DbTradeAlert’s users are presented with a 24 entries in a MultiSelectListPreference – yuck! To make up for that DbTradeAlert will get user friendly summaries like “Hours on which auto refresh is active (09 – 18)” later.

The first step is to extend arrays.xml and preferences.xml which will lead to a settings screen for business hours like this:

Settings screen for business hours

Settings screen for business hours

After that QuoteRefresherService.isBusinessHour() needs to use the new setting:

public class QuoteRefresherService extends IntentService {
    // ...

    private boolean areExchangesOpenNow() {
        final String methodName = "areExchangesOpenNow";
        boolean result = false;
        Calendar now = Calendar.getInstance();
        boolean isBusinessHour = isBusinessHour(now);
        if (isBusinessHour) {
            result = isBusinessDay(now);
        }
        Log.d(CLASS_NAME, String.format(
                "%s(): Exchanges %sopen", methodName, result ? "" : "not "));
        return result;
    }// areExchangesOpenNow()

    // ...

    private boolean isBusinessDay(Calendar now) {
        boolean result = false;
        int thisDayOfWeek = now.get(Calendar.DAY_OF_WEEK);
        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        Set businessDays = sharedPreferences.getStringSet(
                "business_days_preference", Collections.<String>emptySet());
        if (businessDays != null) {
            Utils.BusinessTimesPreferenceExtremes
                    btpe = Utils.getBusinessTimesPreferenceExtremes(businessDays);
            result = (btpe.getFirstBusinessTime() <= thisDayOfWeek && btpe.getLastBusinessTime() >= thisDayOfWeek);
            // Log result details
            String s = String.valueOf(thisDayOfWeek) + (result ? " is" : " is not")
                    + String.format(" in business days (%d - %d)",
                    btpe.getFirstBusinessTime(),
                    btpe.getLastBusinessTime()
            );
            Log.v(CLASS_NAME, s);
        } else {
            Log.e(CLASS_NAME, "business_days_preference not found");
        }
        return result;
    } // isBusinessDay()

    private boolean isBusinessHour(Calendar now) {
        boolean result = false;
        int hourOfDay = now.get(Calendar.HOUR_OF_DAY);
        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        Set businessHours = sharedPreferences.getStringSet(
                "business_hours_preference", Collections.<String>emptySet());
        if (businessHours != null) {
            Utils.BusinessTimesPreferenceExtremes
                    btpe = Utils.getBusinessTimesPreferenceExtremes(businessHours);
            result = (btpe.getFirstBusinessTime() <= hourOfDay && btpe.getLastBusinessTime() >= hourOfDay);
            // Log result details
            String s = String.valueOf(hourOfDay) + (result ? " is" : " is not")
                    + String.format(" in business hours (%02d - %02d)",
                    btpe.getFirstBusinessTime(),
                    btpe.getLastBusinessTime()
            );
            Log.v(CLASS_NAME, s);
        } else {
            Log.e(CLASS_NAME, "business_hours_preference not found");
        }
        return result;
    } // isBusinessHour()

    // ...
}
public class Utils {
    // ...

    /**
     * getBusinessTimesPreferenceExtremes() returns
     * - the first and last business day of the week (for business_days_preference)
     * or
     * - the first and last business hour of the day (for business_hours_preference)
     * @param businessTimesSet must not be null
     */
    public static BusinessTimesPreferenceExtremes getBusinessTimesPreferenceExtremes(
            Set businessTimesSet) {
        ArrayList<String> businessTimesArray = new ArrayList<String>(businessTimesSet);
        Collections.sort(businessTimesArray);
        String firstBusinessTime = businessTimesArray.get(0);
        String lastBusinessTime = businessTimesArray.get(businessTimesArray.size() - 1);
        return new BusinessTimesPreferenceExtremes(
                Integer.valueOf(firstBusinessTime),
                Integer.valueOf(lastBusinessTime));
    } // getBusinessTimesPreferenceExtremes()

    // ...
    /**
     * BusinessTimesPreferenceExtremes holds
     * - the first and last business day of the week (for business_days_preference)
     * or
     * - the first and last business hour of the day (for business_hours_preference)
     */
    public static final class BusinessTimesPreferenceExtremes {
        private final Integer firstBusinessTime;
        private final Integer lastBusinessTime;

        public BusinessTimesPreferenceExtremes(
                Integer firstBusinessTime, Integer lastBusinessTime) {
            this.firstBusinessTime = firstBusinessTime;
            this.lastBusinessTime = lastBusinessTime;
        }

        public Integer getFirstBusinessTime() {
            return firstBusinessTime;
        }

        public Integer getLastBusinessTime() {
            return lastBusinessTime;
        }
    } // class BusinessTimesPreferenceExtremes
} // class Utils

The Set of businessHours is transformed into an ArrayList which provides a sort() method allowing easy access to the minimum and maximum values. With some refactoring isBusinessDay() profits from that, too. And it enables a more readable Log output:
... V/QuoteRefresherService: 18 is in business hours (09 - 18)

This also changed the business logic for isBusinessDay() a bit and assumes continuous business weeks now. That’s inline with the logic for business hours now. And I don’t see a use case for discontinuous business days / hours – allowing users to specify discontinuous business days / hours is actually a limitation of the UI.

1.6. Create User Friendly Summaries for Settings

As noted in the previous section I need to make good for that horrible 24-items list: users will not just see “Days on which auto refresh is active” but also the actual value “(Mon – Fri)” in the business days preference and a similar text in the business hours preference.

Most of the code was already created in the previous section: Utils.getBusinessTimesPreferenceExtremes() determines the first and the last business day / hour. The BusinessTimesPreferenceExtremes class just holds the 2 Integers.

The SettingsActivity is responsible for creating the preference summaries:

public class SettingsActivity extends AppCompatActivity
        implements SharedPreferences.OnSharedPreferenceChangeListener {
    // ...

    private void setBusinessTimesPreferenceSummary(String businessTimesPreferenceKey) {
        SettingsFragment settingsFragment
                = (SettingsFragment) getFragmentManager().findFragmentByTag(SETTINGS_FRAGMENT_TAG);
        MultiSelectListPreference businessTimesPreference
                = (MultiSelectListPreference) settingsFragment
                .findPreference(businessTimesPreferenceKey);
        Set businessDays = businessTimesPreference.getValues();
        Utils.BusinessTimesPreferenceExtremes
                btpe = Utils.getBusinessTimesPreferenceExtremes(businessDays);
        if (businessTimesPreferenceKey.equals(BUSINESS_DAYS_PREFERENCE_KEY)) {
            String[] shortDayNames = DateFormatSymbols.getInstance(Locale.US).getShortWeekdays();
            businessTimesPreference.setSummary(
                    String.format("Days on which auto refresh is active (%s - %s)",
                            shortDayNames[btpe.getFirstBusinessTime()],
                            shortDayNames[btpe.getLastBusinessTime()]));
        } else {
            businessTimesPreference.setSummary(
                    String.format("Hours on which auto refresh is active (%02d - %02d)",
                            btpe.getFirstBusinessTime(), btpe.getLastBusinessTime()));
        }
    } // setBusinessTimesPreferenceSummary()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, new SettingsFragment(), SETTINGS_FRAGMENT_TAG)
                .commit();
        // Without this findFragmentByTag() would return null!
        getFragmentManager().executePendingTransactions();
        setBusinessTimesPreferenceSummary(BUSINESS_DAYS_PREFERENCE_KEY);
        setBusinessTimesPreferenceSummary(BUSINESS_HOURS_PREFERENCE_KEY);
    } // onCreate()

    // ...

    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
        Log.d(CLASS_NAME, "onSharedPreferenceChanged: key = " + key);
        if (key.equals("auto_refresh_preference")) {
            Intent intent = new Intent(this, QuoteRefreshScheduler.class);
            sendBroadcast(intent);
        }
        setBusinessTimesPreferenceSummary(BUSINESS_DAYS_PREFERENCE_KEY);
        setBusinessTimesPreferenceSummary(BUSINESS_HOURS_PREFERENCE_KEY);
    } // onSharedPreferenceChanged()

} // class SettingsActivity

All the work is done in setBusinessTimesPreferenceSummary():

  • Find the SettingsFragment
  • Find the MultiSelectListPreference for business day / hour
  • Get the Set with its values
  • Let Utils.BusinessTimesPreferenceExtremes() find the first and last business day / hour from those values
  • If the method was called to set the business day summary it shows the day’s short names – fixed to US English like everything the UI
  • Otherwise it just shows the values formatted to 2 digits

The method is first called in onCreate() to set the initial preference summaries. It’s important to call FragmentManager.executePendingTransactions() before that or findFragmentByTag() will not find anything. Another necessary addition is to specify a tag when placing SettingsFragment into the activity because setBusinessTimesPreferenceSummary() finds the fragment by its tag.

After a business time related preference changed setBusinessTimesPreferenceSummary() of course needs to be called again.

Summaries now show the actual values

Summaries now show the actual values

When you start the app now and go to the Settings screen you’ll see preference values nicely integrated into the summaries.

Feature complete! Start polishing.

2. Finishing Touches

2.1. Add an icon

The app is still missing an icon. Its notifications got a nice one so let’s use that:

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

    <!-- uses-permission elements -->

    <application android:allowBackup="true" android:icon="@drawable/emo_im_money_mouth" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">
        <!-- ... -->
    </application>

</manifest>

As the icon was already included in the project I just needed to add a reference to it. If you use a new icon remember to copy it even if it’s one of the built-in icons because these are version dependend.

2.2 Format Timestamp in Report Properly

Reports currently show timestamps like “2016-07-14T15:29”. That’s unambiguous but not what most users expect. So let’s just show date and time in the short format that corresponds to the device’s region:

public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {
    // ...

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int reportPosition) {
        // ...
        // LastPriceDateTimeTextView
        int columnIndex = cursor.getColumnIndex(QuoteContract.Quote.LAST_PRICE_DATE_TIME);
        String s = Utils.getDateTimeStringFromDbDateTime(cursor, columnIndex, true);
        viewHolder.LastPriceDateTimeTextView.setText(s);
        if (isLastTradeOlderThanOneDay) {
            viewHolder.LastPriceDateTimeTextView.setBackgroundResource(R.color.colorWarn);
        } else {
            viewHolder.LastPriceDateTimeTextView
                    .setBackgroundColor(android.R.attr.editTextBackground);
        }
        // ...
    }

    // ...
}

Utils.getDateTimeStringFromDbDateTime() already existed. Please note that from Android 5 on there is a bug where DateFormat.getDateTimeInstance() sometimes returns time in 12h format instead of 24h format. It somehow starts to ignore the device’s setting for that. To make getDateTimeInstance() work again just switch the setting (Settings | Date & time | Use 24-hour format) to 12h and back to 24h.

2.3 Make Edit Security Screen Accessible from Reports

Users should be able to open the Edit Security screen by long-tapping a report – for example to read the note. Most of the code is already in place – it just needs to be connected.

Most of the missing part goes into WatchlistListActivity:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...
    private static final int SECURITY_EDIT_REQUEST = 3;
    private static final int WATCHLISTS_MANAGEMENT_REQUEST = 4;
    // ...

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        final String methodName = "onActivityResult";
        switch (requestCode) {
            case REMINDERS_MANAGEMENT_REQUEST:
                // Nothing to do
                break;
            case WATCHLISTS_MANAGEMENT_REQUEST:
                // Even if user tapped Cancel in Manage Watchlists screen he may have OK'd
                // changes in Edit Watchlist screen
                watchlistListPagerAdapter.notifyDataSetChanged();
                break;
            case SECURITIES_MANAGEMENT_REQUEST:
            case SECURITY_EDIT_REQUEST:
                if (resultCode == RESULT_OK) {
                    refreshAllWatchlists();
                }
                break;
            default:
                Log.e(CLASS_NAME, String.format("%s(): unexpected requestCode = %d",
                        methodName, requestCode));
                break;
        }
        super.onActivityResult(requestCode, resultCode, data);
    } // onActivityResult()

    // ...

    @Override
    public void onListFragmentInteraction(String symbol) {
        Intent intent = new Intent(this, SecurityEditActivity.class);
        long securityId = dbHelper.getSecurityIdFromSymbol(symbol);
        intent.putExtra(SecurityEditActivity.SECURITY_ID_INTENT_EXTRA, securityId);
        startActivityForResult(intent, SECURITY_EDIT_REQUEST);
    } // onListFragmentInteraction()

    // ...
}

WatchlistListActivity.onListFragmentInteraction() creates an Intent to call the Edit Security screen and additionally passes the security’s Id. When the user closes the Edit Security screen onActivityResult() is called and handles SECURITY_EDIT_REQUEST just like SECURITIES_MANAGEMENT_REQUEST: all the watchlists are reloaded because a security may be shown in more than one of them.

DbHelper.getSecurityIdFromSymbol() existed as a private method that expected a SQLiteDatabase parameter. Because DbHelper’s clients don’t have to deal with databases I wrapped that method in a public one without the parameter. The public method just obtains a database with getReadableDatabase(), calls the private method and returns its result.

In case you forgot why the symbol is onListFragmentInteraction()’s parameter – I did:

/**
 * A fragment representing a list of Items.
 *

 * Activities containing this fragment MUST implement the {@link OnListFragmentInteractionListener}
 * interface.
 */
public class WatchlistFragment extends Fragment {

    // ...

    /**
     * This interface must be implemented by activities that contain this
     * fragment to allow an interaction in this fragment to be communicated
     * to the activity and potentially other fragments contained in that
     * activity.
     * <p/>
     * See the Android Training lesson <a href= * "http://developer.android.com/training/basics/fragments/communicating.html" * >Communicating with Other Fragments</a> for more information.
     */
    public interface OnListFragmentInteractionListener {
        void onListFragmentInteraction(String symbol);
    }
}
public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {
    // ...

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int reportPosition) {
    // ...
        // setOnClickListener()
        viewHolder.View.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (listener != null) {
                    // Notify the active callbacks interface (the activity, if the
                    // fragment is attached to one) that an item has been selected.
                    listener.onListFragmentInteraction(viewHolder.Symbol);
                }
            }
        });
    }

    // ...

    @Override
    public void onListFragmentInteraction(String symbol) {
        Intent intent = new Intent(this, SecurityEditActivity.class);
        long securityId = dbHelper.getSecurityIdFromSymbol(symbol);
        intent.putExtra(SecurityEditActivity.SECURITY_ID_INTENT_EXTRA, securityId);
        startActivityForResult(intent, SECURITY_EDIT_REQUEST);
    } // onListFragmentInteraction()

    // ...
}

WatchlistFragment defines an OnListFragmentInteractionListener interface and WatchlistRecyclerViewAdapter.onBindViewHolder() passes viewHolder.Symbol to the onListFragmentInteraction method of that interface.

2.4 Add Help

As the post about using DbTradeAlert shows the app’s users need some help.

The original idea was to show GitHub’s  Readme.md page in the app but that had several drawbacks:

  • Doesn’t work offline
  • It seems one cannot force GitHub to serve the mobile view and the default view adds way too much packaging
  • Not all users will be OK with showing content from the Internet in an app
Help screen

Help screen

The solution was to create a static HTML page from that content and put it in the app’s assets folder:

  1. In Android Studio’s project view open the app item’s context menu and select New | Folder | Assets Folder
  2. In the Configure Component window click Finish
  3. Create the Help.html page and put it into the new folder

The next step is to add an empty activity named HelpActivity to the app and fill it with a WebView control.

Then the app’s menu needs a new Help item and clicking that needs to be processed in WatchlistListActivity.onOptionsItemSelected() by starting the HelpActivity which looks like this:

package de.dbremes.dbtradealert;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.WebView;

public class HelpActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_help);
        WebView webView = (WebView) findViewById(R.id.webView);
        webView.loadUrl("file:///android_asset/Help.html");
    } // onCreate()
} // class HelpActivity

HelpActivity.onCreate() simply loads the static file into the WebView control.

The drawback of course is having to edit 3 files when something usage related changes.

2.5 Features that didn’t make it

As always there are more features possible. Some which I debated and rejected or deferred:

  • Widget on the home screen: the app’s target group doesn’t want or need a feature that promotes permanent attention.
  • Show missing functionality: users can stop DbTradeAlert from sending notifications or fail to excempt it from battery optimization. Because that stops the app from working it should inform the user that there is a problem. Maybe I’ll add that later.
  • Animations: while animations when changing between screens are a nice touch they do not really fit the app’s style and purpose.
  • Night mode: Android can automatically switch apps to night mode – usually a darker theme – like your SatNav does in a tunnel. Nice but not needed.
  • Backup: if the user opted into Google’s backup service for his account DbTradeAlert’s data will be backed up by default on devices running Android Marshmallow and beyond. I still have to test that though.
    Being able to export the SQLite database to a PC for editing with SQLite Browser would also be nice but turned out to be tricky – work in progress.
  • Data Binding: support for data binding was anounced at Google I/O 2015 and quietly rolled out in the year’s fall. Data binding should get rid of a lot of boilerplate code and I’ll definitely try it.

Next post: Add Backup

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Reminders

First post in this series: Introduction to DbTradeAlert

Previous post: Add Security and Watchlist Management


Reminders result in trade alerts like signals do. The difference is that a user explicitly sets the due date leading to the trade alert.

I add reminders after completing DbTradeAlert’s core features because a) it’s the only new feature I wanted after using the app for years  and b) to see how Android and DbTradeAlert facilitate additions like this.

1. Store Reminders in the Database

Like for the existing types reminders need for storage:

  • A ReminderContract class defining the type’s database fields
  • A DbHelper.createReminderTable() method to create the database table
  • To call createReminderTable() in DbHelper.createTables()
  • To create some sample data in DbHelper.createSampleData()

I’ll skip diving into the code as you have seen code almost identical to it. The reminder table’s fields are:

  • Due date: the date at which the trade alert will be shown
  • Heading: text that shows in the trade alert
  • ID: SQLite auto-increment field
  • Is active: you can deactivate a reminder to keep its data for future use; inactive reminders won’t trigger trade alerts
  • Notes: explanatory text showing only in the Edit Reminder screen and not in the trade alert

2. Send Notifications for Reminders

If you aren’t interested in your peers’ rants skip the next 4 paragraphs.

How much do you hate users who absolutely want a feature and change their mind as soon as they get that feature? Well, turns out I’m one of them.

Originally I wanted a trade alert for each reminder. That was mostly to try custom actions on notifications – the user should be able to deactivate or delete a reminder from its notification without starting the app.

But the first sentiment when seeing 3 of DbTradeAlert’s icons (1 for signals + 2 for reminders) in the notification area was “wow, that’s intrusive”. And that was before even opening the notification drawer.

Showing more than one trade alert for reminders and getting the actions to work (tap on notification: open Edit Reminder screen with that reminder; tap on Deactivate and Delete buttons: send a broadcast and have the receiver call the respective DbHelper method) turned out to be a bit tricky. That’s because the underlying intents overwrite those for the previous notification which results in all those notification’s actions pointing to the wrong reminder. Of course I had to get that working even while I knew I wasn’t going to use it – SHA-1: c9ce01938895f52db1d7900f8bef6ef890ea05fc. Now let’s continue with a usable implementation.

Trade alerts for reminders will be integrated into the trade alert for triggered signals in QuoteRefresherService.onHandleIntent(). This way trade alerts are sent from the service even if the app itself is asleep.

public class QuoteRefresherService extends IntentService {
    // ...

    private String buildNotificationFromDueReminder(Cursor dueRemindersCursor) {
        String result = "";
        int headingColumnIndex
                = dueRemindersCursor.getColumnIndex(ReminderContract.Reminder.HEADING);
        result = dueRemindersCursor.getString(headingColumnIndex);
        return result;
    } // buildNotificationFromDueReminder()

    // ...

    @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(
                    QUOTE_REFRESHER_BROADCAST_IS_MANUAL_REFRESH_INTENT_EXTRA, false);
            if (isManualRefresh || areExchangesOpenNow()) {
                if (isConnected()) {
                    quoteCsv = downloadQuotes(url);
                    DbHelper dbHelper = new DbHelper(this);
                    dbHelper.updateOrCreateQuotes(quoteCsv);
                    // Notify user of triggered signals and reminders even if app is sleeping
                    dbHelper.updateSecurityMaxPrice();
                    sendNotification(dbHelper);
                    Log.d(CLASS_NAME,
                            "onHandleIntent(): quotes updated - initiating screen refresh");
                    sendLocalBroadcast(QUOTE_REFRESHER_BROADCAST_REFRESH_COMPLETED_EXTRA);
                } else {
                    sendLocalBroadcast(QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "no Internet!");
                    Log.e(CLASS_NAME, QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "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(
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "broken Internet connection!");
                Log.e(CLASS_NAME,
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "broken Internet connection!", e);
            }
            // TODO: cannot rethrow in else case as that doesn't match overridden methods signature?
        } finally {
            QuoteRefreshAlarmReceiver.completeWakefulIntent(intent);
        }
    } // onHandleIntent()

    // ...

    private void sendNotification(DbHelper dbHelper) {
        final String methodName = "sendNotification";
        Cursor dueRemindersCursor = dbHelper.readAllDueReminders();
        Cursor triggeredSignalsCursor = dbHelper.readAllTriggeredSignals();
        try {
            int notificationCount
                    = triggeredSignalsCursor.getCount() + dueRemindersCursor.getCount();
            if (notificationCount > 0) {
                Context context = getApplicationContext();
                NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                        .setColor(Color.GREEN)
                        .setDefaults(Notification.DEFAULT_ALL)
                        .setNumber(notificationCount)
                        .setSmallIcon(R.drawable.emo_im_money_mouth);
                // Tapping notification should lead to app's main screen
                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 (notificationCount == 1) {
                    String contentText;
                    if (dueRemindersCursor.getCount() == 1) {
                        dueRemindersCursor.moveToFirst();
                        contentText = buildNotificationFromDueReminder(dueRemindersCursor);
                    } else {
                        triggeredSignalsCursor.moveToFirst();
                        contentText = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                    }
                    builder.setContentTitle("Notification").setContentText(contentText);
                    Log.v(CLASS_NAME,
                            String.format("%s(): Notification = %s", methodName, contentText));
                } else {
                    // Wrap all notifications into one inboxStyle notification
                    builder.setContentTitle(notificationCount + " Notifications");
                    NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
                    builder.setStyle(inboxStyle);
                    while (dueRemindersCursor.moveToNext()) {
                        String s = buildNotificationFromDueReminder(dueRemindersCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                    while (triggeredSignalsCursor.moveToNext()) {
                        String s = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                }
                // Show notification
                NotificationManager notificationManager = (NotificationManager) context
                        .getSystemService(Context.NOTIFICATION_SERVICE);
                // Update pending notification if existing
                final int notificationId = 1234;
                notificationManager.notify(notificationId, builder.build());
            }
            Log.d(CLASS_NAME, String.format(
                    "%s(): created notification for %d reminders + signals",
                    methodName, notificationCount));
        } finally {
            DbHelper.closeCursor(dueRemindersCursor);
            DbHelper.closeCursor(triggeredSignalsCursor);
        }
    } // sendNotification()
}

You’ll already know most of the code above from implementing the trade alerts for triggered signals.

The last missing part is DbHelper.readAllDueReminders(). Again I’ll skip showing the code as it’s really simple: get Header and Id for each active reminder with a due date before today.

Trade alert with sample reminder

Trade alert with sample reminder

Try it:

  1. Delete the app to force creation of a new database containing the reminder table and a sample reminder due today
  2. Start the app and tap Refresh – a trade alert for “Sample reminder” will show up – possibly combined with trade alerts for triggered signals

Remember that only the topmost notification shows its full contents. For others you need to open the trade alert manually.

3. Add Reminder Management

Reminder management will consist of an Edit Reminder screen and a Manage Reminders screen – just like watchlist and security management.

3.1 Implement the Manage Reminders Screen

Implementing the Manage Reminders screen starts with creating a new blank activity named “RemindersManagementActivity”.

Its layout is created identical to layout/activity_watchlists_management.xml except the ListView is now called “remindersListView”.

Each reminder in remindersListView will be shown in an additional XML layout named layout/layout_reminders_management_detail.xml. Again, it’s created identical to layout/layout_watchlists_management_detail.xml except the TextView is now called “headingTextView”.

And the last new artefact needed is a Java class named “RemindersManagementCursorAdapter”. To implement it just copy WatchlistsManagementCursorAdapter’s contents except the editButtonClickListener.

After that replace:

  • watchlistId with reminderId
  • nameTextView with headingTextView
  • layout_watchlists_management_detail with layout_reminders_management_detail
  • WatchlistsManagementDetailViewHolder with RemindersManagementDetailViewHolder
  • WATCHLIST_DELETED_BROADCAST with REMINDER_DELETED_BROADCAST including the content
  • WatchlistContract’s fields with the respective ReminderContract fields

Rework some additional strings and you’re done (DbHelper.deleteReminder() not implemented yet).

Similarly you can copy most from WatchlistsManagementActivity to RemindersManagementActivity:

  • Create, register and unregister reminderDeletedBroadcastReceiver
  • Code for onCancelButtonClick() and onOkButtonClick()
  • refreshWatchlistsListView() which you rework for refreshing remindersListView (DbHelper.readAllReminders() not implemented yet)
  • Copy and rework onCreate() and you’re done

Now DbHelper needs a readAllReminders() and a deleteReminder() method. Deleting a reminder by its Id is trivial. When reading all reminders select their heading and ID and sort ascending by due date.

The last step is to connect the Manage Reminders screen:

  1. Extend menu/menu_watchlist_list.xml with an action_reminders_management item
  2. Define a REMINDERS_MANAGEMENT_REQUEST
  3. Extend onActivityResult() to accept REMINDERS_MANAGEMENT_REQUEST – nothing to do
  4. Extend onOptionsItemSelected() to start the respective activity if it encounters the action_reminders_management item
Manage Reminders screen

Manage Reminders screen

Test the app:

  1. In the main screen open the overflow menu and tap Manage Reminders
  2. In the Manage Reminders screen tap Delete for the sample reminder and confirm the delete
  3. Tap OK or Cancel to close the Manage Reminders screen
  4. Tap Refresh: you either get no trade alert or one without the sample reminder

A nice addition would be to remove the trade alert if it was only shown due for the deleted reminder. But I’ll ignore that special case.

3.2 Implement the Edit Reminder Screen

Edit Reminder screen

Edit Reminder screen

Implementing the Edit Reminder screen is very similar to implementing the Edit Security screen. Only the multiline TextEdit is a first because the Edit Security screen needed the space for its list of watchlists. Also note that specifying no “android:text” attribute (for easier alignment) on the CheckBox makes it hard to tap.

ReminderEditActivity’s code checks in onCreate() if the activity is in create or update mode and fills the controls appropriately. Some methods like getDateFromEditText() and setTextFromDateColumn() had to be moved to Utils so ReminderEditActivity can use them, too.

While playing around with date inputs I found that setLenient(false) in getDateFromEditText() is a must for DateFormat.parse(). Otherwise “53.2.16” leads to “Thu Mar 24 00:00:00 GMT+01:00 2016”. That’s actually correct as February 2016 has 29 days but most probably not what the user wanted to enter.

An interesting method is onOkButtonClick() because it forces the user to provide a heading and a due date:

public class ReminderEditActivity extends AppCompatActivity {
    // ...

    public void onOkButtonClick(View view) {
        String errorMessage = "";
        Date dueDate = null;
        try {
            dueDate = Utils.getDateFromEditText(this, R.id.dueDateEditText);
        } catch (ParseException e) {
            errorMessage = e.getMessage();
        }
        if (dueDate == null && TextUtils.isEmpty(errorMessage)) {
            errorMessage = "Please enter a due date";
        }
        String heading = Utils.getStringFromEditText(this, R.id.headingEditText);
        if (TextUtils.isEmpty(heading)) {
            errorMessage = "Please enter a heading";
        }
        CheckBox isReminderActiveCheckBox
                = (CheckBox) findViewById(R.id.isReminderActiveCheckBox);
        boolean isReminderActive = isReminderActiveCheckBox.isChecked();
        String notes = Utils.getStringFromEditText(this, R.id.notesEditText);
        if (TextUtils.isEmpty(errorMessage)) {
            this.dbHelper.updateOrCreateReminder(
                    dueDate, heading, isReminderActive, notes, this.reminderId);
            setResult(RESULT_OK, getIntent());
            finish();
        } else {
            Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show();
        }
    } // onOkButtonClick()
}
public class Utils {
    // ...

    public static Date getDateFromEditText(Activity activity, Integer editTextId)
            throws ParseException {
        Date result = null;
        EditText editText = (EditText) activity.findViewById(editTextId);
        if (editText.length() > 0) {
            DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
            // Without setLenient(false) DateFormat would accept weird date inputs. For example
            // "53.2.16" -> "Thu Mar 24 00:00:00 GMT+01:00 2016"
            // ("correctly" as February 2016 has 29 days :-))
            dateFormat.setLenient(false);
            String text = editText.getText().toString();
            result = dateFormat.parse(text);
        }
        return result;
    } // getDateFromEditText()

    // ...
}

Note that you can use EditText.setError() to show an error message and icon right were the user messed up. I chose a Toast because onOkButtonClick() doesn’t have access to the EditTexts.

Necessary extensions to RemindersManagementActivity consist of a onNewButtonClick() handler that starts ReminderEditActivity in create mode and an onActivityResult() handler that calls refreshRemindersListView() if ReminderEditActivity’s resultCode was RESULT_OK.

The RemindersManagementCursorAdapter loses REMINDER_ID_INTENT_EXTRA to ReminderEditActivity. To compensate for that it gets a new editButtonClickListener() that starts ReminderEditActivity in update mode.

And of course DbHelper is affected: readReminder() returns all fields for the specified reminder and updateOrCreateReminder() tries to UPDATE a specified reminder and if that fails does an INSERT. Of course it could instead check if reminderId equals NEW_ITEM_ID.

To try it:

  1. Create a new reminder – first try to leave a mandatory field empty or try an invalid due date
  2. After successful creation the reminder shows up in the Manage Reminders screen
  3. Tap refresh – if the reminder is due and active it should show up in a corresponding trade alert
  4. Edit the reminder and check if the changes were applied correctly

Again it would be nice if changes to a reminder affected its trade alert. And again I’ll ignore that special case.

4. Add an Action to the Trade Alert

Android Jellybean / API level 19 added custom actions on notifications. Before that a user could only tap the notification itself which means only a single action was possible.

It would be nice if DbTradeAlert’s users could go to the Manage Reminders screen directly from the notification if it has content related to at least one reminder. Tapping the notification itself should still lead to the app’s main screen.

For the new action QuoteRefresherService.onHandleIntent() needs an extension:

public class QuoteRefresherService extends IntentService {
    // ...

    private void addOpenManageRemindersScreenAction(NotificationCompat.Builder builder) {
        Intent remindersManagementIntent = new Intent(this, RemindersManagementActivity.class);
        PendingIntent remindersManagementPendingIntent
                = PendingIntent.getActivity(this, 0, remindersManagementIntent, 0);
        int icon = R.drawable.ic_go_search_api_holo_light;
        String title = "Reminders";
        builder.addAction(icon, title, remindersManagementPendingIntent);
    } // addOpenManageRemindersScreenAction()

    // ...

    private void sendNotification(DbHelper dbHelper) {
        final String methodName = "sendNotification";
        Cursor dueRemindersCursor = dbHelper.readAllDueReminders();
        Cursor triggeredSignalsCursor = dbHelper.readAllTriggeredSignals();
        try {
            int notificationCount
                    = triggeredSignalsCursor.getCount() + dueRemindersCursor.getCount();
            if (notificationCount > 0) {
                Context context = getApplicationContext();
                NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                        .setColor(Color.GREEN)
                        .setDefaults(Notification.DEFAULT_ALL)
                        .setNumber(notificationCount)
                        .setSmallIcon(R.drawable.emo_im_money_mouth);
                // Tapping notification should lead to app's main screen
                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 (notificationCount == 1) {
                    String contentText;
                    if (dueRemindersCursor.getCount() == 1) {
                        dueRemindersCursor.moveToFirst();
                        contentText = buildNotificationFromDueReminder(dueRemindersCursor);
                    } else {
                        triggeredSignalsCursor.moveToFirst();
                        contentText = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                    }
                    builder.setContentTitle("Notification").setContentText(contentText);
                    Log.v(CLASS_NAME,
                            String.format("%s(): Notification = %s", methodName, contentText));
                } else {
                    // Wrap all notifications into one inboxStyle notification
                    builder.setContentTitle(notificationCount + " Notifications");
                    NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
                    builder.setStyle(inboxStyle);
                    while (dueRemindersCursor.moveToNext()) {
                        String s = buildNotificationFromDueReminder(dueRemindersCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                    while (triggeredSignalsCursor.moveToNext()) {
                        String s = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                }
                if (dueRemindersCursor.getCount() > 0) {
                    addOpenManageRemindersScreenAction(builder);
                }
                // Show notification
                NotificationManager notificationManager = (NotificationManager) context
                        .getSystemService(Context.NOTIFICATION_SERVICE);
                // Update pending notification if existing
                final int notificationId = 1234;
                notificationManager.notify(notificationId, builder.build());
            }
            Log.d(CLASS_NAME, String.format(
                    "%s(): created notification for %d reminders + signals",
                    methodName, notificationCount));
        } finally {
            DbHelper.closeCursor(dueRemindersCursor);
            DbHelper.closeCursor(triggeredSignalsCursor);
        }
    } // sendNotification()
    // ...
}

Like the notification’s main action the new one is based on an intent which points to the RemindersManagementActivity class. And like any intent that will be executed from outside the app it is wrapped in a PendingIntent. After that the action is added with an appropriate icon and title. Remember to copy the icons to your project even if Android provides them because those icons may change.

Trade alert with custom action for reminders

Trade alert with custom action for reminders

Does it work?

  1. Run the app with a due reminder – its heading and the new action should show in DbTradeAlert’s trade alert
  2. Tap the action – you should be taken to the Manage Reminders screen
  3. Deactivate all reminders, close the Manage Reminders screen and tap Refresh
  4. If you have triggered signals only their lines should show in the trade alert and no action button

Next post: Add Settings and Finishing Touches

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Security and Watchlist Management

First post in this series: Introduction to DbTradeAlert

Previous post: Schedule Quote Updates


By now DbTradeAlert does what it’s supposed to do – inform the user about triggered signals. What’s missing is the ability to manage its entities:

  • Add, edit and delete securities
  • Add, edit and delete watchlists

For both entities the adding and deleting will be started in one activity and the actual editing will be done in a second activity.

Managing watchlists is easier so let’s start with this.

1. Add Watchlists Management

The first step will be to create the Watchlists Management screen and add code to invoke and dismiss it. In the Watchlists Management screen users see a list of all their watchlists and can Tap Edit or New to go to the – not yet existing – Edit Watchlist screen. They can also Tap Delete to get rid of an unused watchlist.

1.1 Add the Watchlists Management Screen

Add a new empty activity named “WatchlistsManagementActivity” (notice plural). After that add 3 Buttons, a ListView, and a TextView to activity_watchlists_management.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" tools:context=".WatchlistsManagementActivity">

    <Button android:id="@+id/newButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentTop="true" android:layout_centerHorizontal="true" android:onClick="onNewButtonClick" android:text="New" />

    <Button android:id="@+id/okButton" style="android:buttonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_alignParentRight="true" android:onClick="onOkButtonClick" android:text="@android:string/ok" />

    <Button android:id="@+id/cancelButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_alignParentLeft="true" android:onClick="onCancelButtonClick" android:text="Cancel" />

    <ListView android:id="@+id/watchlistsListView" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_above="@+id/okButton" android:layout_alignParentLeft="true" android:layout_below="@+id/newButton" />

    <TextView android:id="@+id/emptyTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="false" android:layout_alignParentTop="false" android:layout_centerInParent="true" android:text="(Click 'New' to create a new watchlist)" android:textAppearance="?android:attr/textAppearanceSmall" />
</RelativeLayout>

As the OK button shows Android provides translations for general strings like “OK”, “Cancel” or “New” that it will automatically use depending on the device’s language setting. But internationalizing an app properly is a huge effort which doesn’t make sense for DbTradeAlert and so I use hard-coded strings. The rest of the layout should look familiar by now.

Android Studio will automatically add the activity to AndroidManifest.xml.

Then add code for the Cancel and OK buttons and extend onCreate() to set the screen’s title. Using “android:label” in “layout/activity_watchlists_management.xml” would not work because “android:label” in “AndroidManifest.xml” will overrule it. setTitle() does the trick.

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    public void onCancelButtonClick(View view) {
        setResult(RESULT_CANCELED, getIntent());
        finish();
    } // onCancelButtonClick()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlists_management);
        setTitle("Manage Watchlists");
    } // onCreate()

    public void onOkButtonClick(View view) {
        setResult(RESULT_OK, getIntent());
        finish();
    } // onOkButtonClick()
}

Now add a new entry to the app’s menu:

And finally extend WatchlistListActivity to start the new activity and retrieve its result:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    private static final int WATCHLISTS_MANAGEMENT_REQUEST = 2;
    private WatchlistListPagerAdapter watchlistListPagerAdapter;
    // ...

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        final String methodName = "onActivityResult";
        switch (requestCode) {
            case WATCHLISTS_MANAGEMENT_REQUEST:
                watchlistListPagerAdapter.notifyDataSetChanged();
                break;
            default:
                Log.e(CLASS_NAME, String.format("%s(): unexpected requestCode = ",
                        methodName, requestCode));
                break;
        }
        super.onActivityResult(requestCode, resultCode, data);
    } // onActivityResult()

    // ...

    @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: {
                setTitle(APP_NAME);
                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_watchlists_management: {
                Intent intent = new Intent(this, WatchlistsManagementActivity.class);
                startActivityForResult(intent, WATCHLISTS_MANAGEMENT_REQUEST);
                return true;
            }
            case R.id.action_settings: {
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    }

    // ...
}

In onOptionsItemSelected() you start the activity by calling the superclass’ startActivityForResult() method with an intent specifying the activity’s class. That method also takes an ID that will be passed back to the superclass’ onActivityResult() method once the user has clicked OK or Cancel in the activity. Regardless of resultCode onActivityResult() will initiate a refresh of watchlistListPagerAdapter because while the user may have tapped Cancel in the Manage Watchlists screen he may have OK’d changes in the Edit Watchlist screen.

Not informing watchlistListPagerAdapter about this will result in an exception: “IllegalStateException: The application’s PagerAdapter changed the adapter’s contents without calling PagerAdapter#notifyDataSetChanged!”

To make this possible watchlistListPagerAdapter’s declaration needs to be moved to class level.

Manage Watchlists screen with empty list

Manage Watchlists screen with empty list

Now try out the additions:

  1. Start the app
  2. In its overflow menu tap “Manage Watchlists” – the “Manage Watchlists” screen appears
  3. Note the hint about creating a new watchlist when there is none; that’s the only work emptyTextView will ever do
  4. Note that there is no menu but a distinct title for the screen
  5. In the “Manage Watchlists” screen tap either OK or Cancel – tapping “New” will crash the app
  6. Optional: commit the changes

1.2 Fill List of Existing Watchlists

The next step will be to list the available watchlists. For that DbTradeAlert needs a layout to show each watchlist. To add one:

  1. In the res/layout folder’s context menu select New | XML | Layout XML File
  2. In the Configure Component window:
    1. Layout File Name: “layout_watchlists_management_detail”
    2. Root Tag: “RelativeLayout”
    3. Click Finish

Complete the layout by adding 2 Buttons and a textView:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent">

    <Button android:id="@+id/editButton" style="?android:attr/buttonStyleSmall" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:layout_alignParentTop="true" android:text="Edit" />

    <Button android:id="@+id/deleteButton" style="?android:attr/buttonStyleSmall" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentTop="true" android:layout_toLeftOf="@+id/editButton" android:text="Delete" />

    <TextView android:id="@+id/nameTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignBaseline="@+id/deleteButton" android:layout_alignBottom="@+id/deleteButton" android:layout_alignParentLeft="true" android:text="Default watch list" android:textAppearance="?android:attr/textAppearanceMedium" />

</RelativeLayout>

Like for the previously created lists you’ll need an adapter to marry the ListView with its cursor and its detail layout. Only this time it will be more involved because of the edit and delete actions provided by the layout.

Create a new class named “WatchlistManagementCursorAdapter” extending CursorAdapter (android.support.v4.widget.CursorAdapter). Let Android Studio create the missing methods and a constructor with “Context context, Cursor c, boolean autoRequery” parameters. The next step is to extend the class like shown below including the private WatchListManagementDetailViewHolder class:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.database.Cursor;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CursorAdapter;
import android.widget.TextView;

import de.dbremes.dbtradealert.DbAccess.DbHelper;
import de.dbremes.dbtradealert.DbAccess.WatchlistContract;

public class WatchListManagementCursorAdapter extends CursorAdapter {
    private Context context;
    DbHelper dbHelper;

    public WatchListManagementCursorAdapter(Context context, Cursor c, boolean autoRequery) {
        super(context, c, autoRequery);
        this.context = context;
        this.dbHelper = new DbHelper(this.context);
    } // ctor()

    @Override
    public void bindView(View view, Context context, Cursor cursor) {
        WatchListManagementDetailViewHolder holder
                = (WatchListManagementDetailViewHolder) view.getTag();
        holder.nameTextView.setText(cursor.getString(cursor
                .getColumnIndex(WatchlistContract.Watchlist.NAME)));
        holder.watchListId = cursor.getLong(cursor
                .getColumnIndex(WatchlistContract.Watchlist.ID));
    } // bindView()

    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent) {
        View view = View.inflate(context, R.layout.layout_watchlists_management_detail, null);
        // could replace WatchListManagementDetailViewHolder in ICS and above with
        // view.setTag(R.id.my_view, myView);
        WatchListManagementDetailViewHolder holder = new WatchListManagementDetailViewHolder();
        //holder.deleteButton = (Button) view.findViewById(R.id.deleteButton);
        holder.deleteButton.setOnClickListener(deleteButtonClickListener);
        holder.editButton = (Button) view.findViewById(R.id.editButton);
        //holder.editButton.setOnClickListener(editButtonClickListener);
        holder.nameTextView = (TextView) view.findViewById(R.id.nameTextView);
        holder.watchListId = cursor.getLong(cursor.getColumnIndex(WatchlistContract.Watchlist.ID));
        view.setTag(holder);
        return view;
    } // newView()


    private class WatchListManagementDetailViewHolder {
        public Button deleteButton;
        public Button editButton;
        public TextView nameTextView;
        public long watchListId;
    } // class WatchListManagementDetailViewHolder
} // class WatchListManagementCursorAdapter

As always the ViewHolder’s job is to save the time required for finding controls with findViewById(). newView() creates a new WatchListManagementDetailViewHolder instance, stores references to the controls in it and saves it to the View’s tag. After that bindView() is called and uses those references to update the control’s data. Because bindView() is called way more often than newView() the time savings from storing references to the views add up. Even more important bindView() is called when the screen updates and that’s where split seconds count.

Finally connect WatchlistManagementCursorAdapter to watchListsListView in WatchlistsManagementActivity.onCreate(). Again, the pattern should be familiar by now.

public class WatchlistsManagementActivity extends AppCompatActivity {
    private Cursor cursor;
    private DbHelper dbHelper;
    private WatchListManagementCursorAdapter watchListManagementCursorAdapter
    // ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlists_management);
        dbHelper = new DbHelper(this);
        this.cursor = dbHelper.readAllWatchlists();
        this.watchListManagementCursorAdapter
                = new WatchListManagementCursorAdapter(this, this.cursor, false);
        ListView watchListsListView = (ListView) findViewById(R.id.watchListsListView);
        TextView emptyTextView = (TextView) findViewById(R.id.emptyTextView);
        watchListsListView.setEmptyView(emptyTextView);
        watchListsListView.setAdapter(watchListManagementCursorAdapter);
    } // onCreate()

    // ...
}
Watchlists Management screen showing watchlists

Watchlists Management screen showing watchlists

Now start the app and test again: its watchlists show up in the Manage Watchlists screen – be aware that tapping the Edit or Delete buttons will crash the app. Again a good time to check in the changes.

A prerequisite for working New and Edit buttons is yet another new activity: one to edit a watchlist’s details. So let’s create that activity.

1.3 Add an Activity to Edit Watchlists

Add a new empty activity named “WatchlistEditActivity” (notice singular). The layout needs 2 Buttons, a ListView, a TextEdit, and 3 TextViews. That ends up being 250 lines of xml so I’ll not post it – just get the code from GitHub or look at the screenshot below.

The first step is to provide code for WatchlistsManagementActivity’s New button:

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    public void onNewButtonClick(View view) {
        Intent intent = new Intent(this, WatchlistEditActivity.class);
        intent.putExtra(WatchlistEditActivity.INTENT_EXTRA_WATCHLIST_ID,
                DbHelper.NEW_ITEM_ID);
        startActivityForResult(intent,
                WatchlistEditActivity.CREATE_WATCHLIST_REQUEST_CODE);
    } // onNewButtonClick()

} // class WatchlistsManagementActivity()

The pattern around startActivityForResult() is as used before. But this time an ID is transferred in the intent’s extras so the target activity knowns which watchlist to work on. As onNewButtonClick() starts the creation of a new watchlist it transfers a special ID that DbTradeAlert uses to signal a new item.

And WatchlistEditActivity’s required extensions:

public class WatchlistEditActivity extends AppCompatActivity {
    private final static String CLASS_NAME = "WatchlistEditActivity";
    public final static int CREATE_WATCHLIST_REQUEST_CODE = 0;
    public final static String INTENT_EXTRA_WATCHLIST_ID = "de.dbremes.dbtradealert.watchlistId";
    private DbHelper dbHelper;
    private long watchlistId = DbHelper.NewItemId;

    public void onCancelButtonClick(View view) {
        setResult(RESULT_CANCELED, new Intent());
        finish();
    } // onCancelButtonClick()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        final String methodName = "onCreate";
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlist_edit);
        this.dbHelper = new DbHelper(this);
        Bundle extras = getIntent().getExtras();
        if (extras != null) {
            this.watchlistId = extras.getLong(INTENT_EXTRA_WATCHLIST_ID);
            EditText nameEditText = (EditText) findViewById(R.id.nameEditText);
            if (this.watchlistId == DbHelper.NewItemId) {
                // Create mode
                nameEditText.setText("");
                setTitle("Create Watchlist");
            } 
        }
        refreshSecuritiesList(this.watchlistId);
    } // onCreate()

    private void refreshSecuritiesList(long watchListId) {
        final String methodName = "showStockList";
        SimpleCursorAdapter adapter;
        // Connect securities list to cursor
        String[] fromColumns = { SecurityContract.Security.SYMBOL };
        int[] toViews = { android.R.id.text1 };
        Cursor securitiesCursor = this.dbHelper.getAllSecuritiesAndMarkIfInWatchlist(watchListId);
        int flags =0;
        adapter = new SimpleCursorAdapter(this,
                android.R.layout.simple_list_item_multiple_choice,
                securitiesCursor, fromColumns, toViews, flags);
        ListView securitiesToIncludeListView
                = (ListView) findViewById(R.id.securitiesToIncludeListView);
        TextView emptyTextView = (TextView) findViewById(R.id.emptyTextView);
        securitiesToIncludeListView.setEmptyView(emptyTextView);
        securitiesToIncludeListView.setAdapter(adapter);
        // Mark securities that are included in this watchlist
        int isInWatchListIncludedPosition = securitiesCursor
                .getColumnIndex(DbHelper.IS_SECURITY_IN_WATCHLIST_ALIAS);
        for (int i = 0; i < securitiesCursor.getCount(); i++) {
            securitiesCursor.moveToPosition(i);
            Log.v(CLASS_NAME, String.format("%s(): securitiesCursor[%d].%s = %d",
                    methodName, i, DbHelper.IS_SECURITY_IN_WATCHLIST_ALIAS,
                    securitiesCursor.getInt(isInWatchListIncludedPosition)));
            if (securitiesCursor.getInt(isInWatchListIncludedPosition) == 1) {
                securitiesToIncludeListView.setItemChecked(i, true);
            }
        }
    } // refreshSecuritiesList()
} // class WatchlistEditActivity

WatchlistEditActivity.onCreate() checks the watchlist ID from the intent’s extras. If it’s the special one for a new watchlist it empties the Name TextView and sets the screen’s title appropriately. Finally it calls refreshSecuritiesList() which updates securitiesListView with a cursor containing all the securities from the database and marks those securities with DbHelper.IS_SECURITY_IN_WATCHLIST_ALIAS == 1 (see below). Of course the cursor cannot be closed as it has been passed over to the SimpleCursorAdapter.

Finally DbHelper’s required extensions:

public class DbHelper extends SQLiteOpenHelper {
    public final static long NEW_ITEM_ID = -1L;
    // Alias for generated columns of getAllSecuritiesAndMarkIfInWatchlist() and readAllWatchlists()
    public final static String IS_SECURITY_IN_WATCHLIST_ALIAS = "isSecurityInWatchlist";
    // ...

    /**
     * Gets a list of all securities with those in the specified watchlist marked,
     * ordered by symbol
     *
     * @param idOfWatchlistToMark
     *            If a stock is included in this watchlist,
     *            is_in_watchlist_included will be 1, otherwise 0
     * @return A list (_id, is_included_in_watchlist, symbol) of all securities
     * with those in the specified watchlist marked, ordered by symbol
     */
    public Cursor getAllSecuritiesAndMarkIfInWatchlist(long idOfWatchlistToMark) {
        final String methodName = "getAllSecuritiesAndMarkIfInWatchlist";
        Cursor cursor = null;
        Log.v(CLASS_NAME, String.format("%s(): idOfWatchlistToMark = %d",
                methodName, idOfWatchlistToMark));
        SQLiteDatabase db = this.getReadableDatabase();
        String sql = "SELECT tmp._id AS "
                + Security.ID
                + ", tmp.symbol AS "
                + Security.SYMBOL
                + ", q.name AS "
                + Quote.NAME
                + ", MAX(tmp.isInWatchList) AS "
                + IS_SECURITY_IN_WATCHLIST_ALIAS
                + "\nFROM ("
                + "\n\tSELECT " + Security.ID + ", " + Security.SYMBOL + ", 1 AS isInWatchList"
                + "\n\tFROM " + Security.TABLE + " s"
                + "\n\t\tLEFT JOIN " + SecuritiesInWatchlists.TABLE + " siwl ON "
                + SecuritiesInWatchlists.SECURITY_ID + " = " + Security.ID
                + "\n\tWHERE siwl." + SecuritiesInWatchlists.WATCHLIST_ID + " = ?"
                + "\n\tUNION ALL"
                + "\n\tSELECT " + Security.ID + ", " + Security.SYMBOL + ", 0 AS isInWatchList"
                + "\n\tFROM " + Security.TABLE + " s"
                + "\n) AS tmp"
                + "\n\tLEFT OUTER JOIN " + Quote.TABLE + " q ON q." + Quote.SECURITY_ID + " = tmp._id"
                + "\nGROUP BY tmp._id, tmp.symbol, " + Quote.NAME
                + "\nORDER BY tmp.symbol ASC";
        String[] selectionArgs = new String[] { String.valueOf(idOfWatchlistToMark) };
        logSql(methodName, sql, selectionArgs);
        cursor = db.rawQuery(sql, selectionArgs);
        Log.v(CLASS_NAME, String.format(
                CURSOR_COUNT_FORMAT, methodName, cursor.getCount()));
        return cursor;
    } // getAllSecuritiesAndMarkIfInWatchlist()

    // ...
}

This is the SQL generated by DbHelper.getAllSecuritiesAndMarkIfInWatchlist():

 SELECT tmp._id AS _id, tmp.symbol AS symbol, q.name AS name, MAX(tmp.isInWatchlist) AS isSecurityInWatchlist
 FROM (
	SELECT _id, symbol, 1 AS isInWatchlist
	FROM security s
		LEFT JOIN securities_in_watchlists siwl ON security_id = _id
	WHERE siwl.watchlist_id = -1
	UNION ALL
	SELECT _id, symbol, 0 AS isInWatchlist
	FROM security s
 ) AS tmp
	LEFT OUTER JOIN quote q ON q.security_id = tmp._id
 GROUP BY tmp._id, tmp.symbol, name
 ORDER BY tmp.symbol ASC

The SQL contains an outer and 2 inner SELECTs. The first inner SELECT lists all the securities that are in the watchlist – none in this case as this is a new watchlist – and sets isInWatchlist to 1 for them. The second inner SELECT lists all the securities no matter if they are included in any watchlist and sets isInWatchlist to 0 for them.

Both lists of securities are then combined into a new temporary list by a UNION. The temporary list will have two items for each security in the watchlist – one with isInWatchlist == 0 and one with isInWatchlist == 1. By selecting only the one with MAX(tmp.isInWatchlist) into the final list it will contain a single item for each security and those in the watchlist will have isInWatchlist == 1.

New Edit Watchlist screen

New Edit Watchlist screen

Run the app again:

  1. Open the Manage Watchlists screen
  2. Tap New:
    1. The new WatchlistEditActivity shows up listing all securities and ready to receive a name
    2. Again no menu but a distinct title
  3. Tap Cancel – tapping OK will crash the app
  4. Optional: commit changes

Note that that I closed the automatically displayed on-screen keyboard so it doesn’t obstruct the activity’s screen.

For now watchlist management is all form but no function. Time to make it operational.

1.4 Add Save Functionality for New Watchlists

“Add Save Functionality” simply means get WatchlistEditActivity’s OK button working:

public class WatchlistEditActivity extends AppCompatActivity {
    // ...

    public void onOkButtonClick(View view) {
        // Get name
        String name = "";
        EditText editText = (EditText) findViewById(R.id.nameEditText);
        if (editText.length() > 0) {
            name = editText.getText().toString();
        }
        // Get securities to include in watchlist
        ListView listView = (ListView) findViewById(R.id.securitiesListView);
        long[] securityIds = listView.getCheckedItemIds();
        // Save edits
        this.dbHelper.updateOrCreateWatchlist(name, securityIds, this.watchlistId);
        setResult(RESULT_OK, getIntent());
        finish();
    } // onOkButtonClick()

    // ...
}

The method extracts the (new) watchlist’s name and the securities to include in it from the controls and passes it to DbHelper.updateOrCreateWatchlist(). After that it sets the activity’s result to RESULT_OK and closes the screen.

public class DbHelper extends SQLiteOpenHelper {
    private final static String DELETE_RESULT_FORMAT = "%s(): result of db.delete() from %s = %d";
    private final static String INSERT_CONTENT_VALUES_FORMAT = "%s(): contentValues for %s: %s";
    // ...

    public void updateOrCreateWatchlist(String name, long[] securityIds,
                                        long watchlistId) {
        final String methodName = "updateOrCreateWatchlist";
        Long insertResult = null;
        String[] whereArgs = new String[] { String.valueOf(watchlistId) };
        SQLiteDatabase db = getWritableDatabase();
        try {
            db.beginTransaction();
            // Save watchlist data
            boolean isExistingWatchlist = (watchlistId != NEW_ITEM_ID);
            ContentValues contentValues = new ContentValues();
            contentValues.put(Watchlist.NAME, name);
            if (isExistingWatchlist) {
                Integer updateResult = db.update(Watchlist.TABLE,
                        contentValues, Watchlist.ID + " = ?", whereArgs);
                Log.v(CLASS_NAME, String.format(UPDATE_RESULT_FORMAT,
                        methodName, Watchlist.TABLE, updateResult));
            } else {
                insertResult = db.insert(Watchlist.TABLE, null, contentValues);
                Log.v(CLASS_NAME, String.format(INSERT_RESULT_FORMAT,
                        methodName, Watchlist.TABLE, insertResult));
                watchlistId = insertResult;
                Log.v(CLASS_NAME, String.format("%s(): new watchlistId = %d",
                        methodName, watchlistId));
            }
            // Delete existing connections to securities
            if (isExistingWatchlist) {
                Integer deleteResult = db.delete(
                        SecuritiesInWatchlists.TABLE,
                        SecuritiesInWatchlists.WATCHLIST_ID + " = ?", whereArgs);
                Log.v(CLASS_NAME, String.format(DELETE_RESULT_FORMAT,
                        methodName, SecuritiesInWatchlists.TABLE,
                        deleteResult));
            } else {
                Log.v(CLASS_NAME, String.format(
                        "%s(): New watchlist; skipping delete in %s",
                        methodName, SecuritiesInWatchlists.TABLE));
            }
            // Create specified connections to securities
            contentValues = new ContentValues();
            for (int i = 0; i < securityIds.length; i++) {
                contentValues.clear();
                contentValues.put(SecuritiesInWatchlists.SECURITY_ID, securityIds[i]);
                contentValues.put(SecuritiesInWatchlists.WATCHLIST_ID, watchlistId);
                Log.v(CLASS_NAME, String.format(INSERT_CONTENT_VALUES_FORMAT,
                        methodName, SecuritiesInWatchlists.TABLE,
                        contentValues));
                insertResult = db.insert(SecuritiesInWatchlists.TABLE, null, contentValues);
                Log.v(CLASS_NAME, String.format(INSERT_RESULT_FORMAT,
                        methodName, SecuritiesInWatchlists.TABLE, insertResult));
            }
            db.setTransactionSuccessful();
            Log.d(CLASS_NAME, methodName + "(): success!");
        } finally {
            db.endTransaction();
        }
    } // updateOrCreateWatchlist()
}

For a new watchlist updateOrCreateWatchlist() first inserts it into the watchlist table and then creates the connection to its securities in the securities_in_watchlists table. All of this is done in a transaction of course. We’ll ignore the code to update existing watchlists for now.

Now WatchlistManagementActivity needs code to deal with tapping OK in WatchlistEditActivity:

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (resultCode == RESULT_OK) {
            refreshWatchlistsListView();
        }
    } // onActivityResult()

    // ...

    private void refreshWatchlistsListView() {
        Cursor cursor = this.dbHelper.readAllWatchlists();
        this.watchlistManagementCursorAdapter.changeCursor(cursor);
    } // refreshWatchlistsListView()
}

Try the new functionality:

  1. Open the Manage Watchlists screen
  2. Tap New to show the Edit Watchlist screen – it says “Create Watchlist”
  3. Enter a name and select one or more securities
  4. Tap OK
  5. Also tap OK in the Manage Watchlists screen
  6. DbTradeAlert shows the new watchlist in the rightmost tab
  7. Optional: commit changes

What’s missing now is only functionality to edit and delete existing watchlists.

1.5 Add Edit Functionality for Existing Watchlists

Implementing functionality to edit watchlists is a bit involved because the button for it (and the one for deleting watchlists) isn’t in an activity like all the previous buttons. Instead, a ListView hosts a list of views and each view contains both a button for deleting and one for editing the watchlist it represents. For that reason the button’s click handler goes into the ListView’s CursorAdapter class which connects it in newView():

public class WatchlistManagementCursorAdapter extends CursorAdapter {
    // ...

    private View.OnClickListener editButtonClickListener = new View.OnClickListener() {

        public void onClick(View v) {
            WatchlistManagementDetailViewHolder holder
                    = (WatchlistManagementDetailViewHolder) ((View) v.getParent()).getTag();
            long watchListId = holder.watchListId;
            Intent intent = new Intent(holder.context, WatchlistEditActivity.class);
            intent.putExtra(WatchlistEditActivity.WATCHLIST_ID_INTENT_EXTRA, watchListId);
            ((Activity) holder.context).startActivityForResult(intent,
                    WatchlistEditActivity.UPDATE_WATCHLIST_REQUEST_CODE);
        }
    }; // editButtonClickListener

    // ...

    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent) {
        View view = View.inflate(context, R.layout.layout_watchlists_management_detail, null);
        // could replace WatchListManagementDetailViewHolder in ICS and above with
        // view.setTag(R.id.my_view, myView);
        WatchListManagementDetailViewHolder holder = new WatchListManagementDetailViewHolder();
        holder.context = context;
        holder.deleteButton = (Button) view.findViewById(R.id.deleteButton);
        //holder.deleteButton.setOnClickListener(deleteButtonClickListener);
        holder.editButton = (Button) view.findViewById(R.id.editButton);
        holder.editButton.setOnClickListener(editButtonClickListener);
        holder.nameTextView = (TextView) view.findViewById(R.id.nameTextView);
        holder.watchListId = cursor.getLong(cursor.getColumnIndex(WatchlistContract.Watchlist.ID));
        view.setTag(holder);
        return view;
    } // newView()


    public class WatchListManagementDetailViewHolder {
        public Context context;
        public Button deleteButton;
        public Button editButton;
        public TextView nameTextView;
        public long watchListId;
    } // class WatchListManagementDetailViewHolder
} // class WatchlistManagementCursorAdapter

The code creates an OnClickListener instance from an anonymous class and provides an onClick() handler for it. The view representing the watchlist is passed as a parameter to onClick() and the WatchlistManagementDetailViewHolder instance connected to that view provides access to the watchlist’s Id and WatchlistsManagementActivity’s Context (see highlighted lines).

A necessary change for that was to add a Context field to WatchlistManagementDetailViewHolder. That makes it possible to access the WatchlistsManagementActivity instance inside editButtonClickListener.

The code then creates an intent to show an WatchlistEditActivity and provides the watchlist’s Id in its extras so WatchlistEditActivity knows which watchlist to load. When starting the activity the code uses WatchlistEditActivity.UPDATE_WATCHLIST_REQUEST_CODE to signal that it’s about editing an existing watchlist.

Actually WatchlistEditActivity.onCreate() only checks if watchlistId isn’t DbHelper.NewItemId to decide if that’s an edit:

public class WatchlistEditActivity extends AppCompatActivity {
    public final static int UPDATE_WATCHLIST_REQUEST_CODE = 1;
    // ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        final String methodName = "onCreate";
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlist_edit);
        this.dbHelper = new DbHelper(this);
        Bundle extras = getIntent().getExtras();
        if (extras != null) {
            this.watchlistId = extras.getLong(WATCHLIST_ID_INTENT_EXTRA);
            EditText nameEditText = (EditText) findViewById(R.id.nameEditText);
            if (this.watchlistId == DbHelper.NewItemId) {
                // Create mode
                nameEditText.setText("");
                setTitle("Create Watchlist");
            } else {
                // Update mode
                Cursor watchlistCursor = this.dbHelper.readWatchlist(this.watchlistId);
                if (watchlistCursor.getCount() == 1) {
                    watchlistCursor.moveToFirst();
                    nameEditText
                            .setText(watchlistCursor.getString(watchlistCursor
                                    .getColumnIndex(WatchlistContract.Watchlist.NAME)));
                } else {
                    Log.e(CLASS_NAME, String.format(
                            "%s(): readWatchlist() found %d watchlists with id = %d; expected 1!",
                            methodName, watchlistCursor.getCount(),
                            this.watchlistId));
                }
            }
        }
        refreshSecuritiesList(this.watchlistId);
    } // onCreate()

    // ...
}

It displays the watchlist’s name which is the only field provided by DbHelper.readWatchlist() and shows its securities.

public class DbHelper extends SQLiteOpenHelper {
    // ...

    private void logSql(String methodName, String[] columns, String orderBy,
                        String selection, String[] selectionArgs, String table) {
        StringBuilder sb = new StringBuilder();
        sb.append("SELECT ");
        if (columns != null) {
            for (int i = 0; i < columns.length; i++) {
                sb.append(columns[i]);
                if (i < columns.length - 1) {
                    sb.append(", ");
                }
            }
        } else {
            sb.append("*");
        }
        sb.append("\nFROM ");
        sb.append(table);
        if (TextUtils.isEmpty(selection) == false) {
            sb.append("\nWHERE ");
            sb.append(insertSelectionArgs(selection, selectionArgs));
        }
        if (TextUtils.isEmpty(orderBy) == false) {
            sb.append("\nORDER BY ");
            sb.append(orderBy);
        }
        Log.v(CLASS_NAME, methodName + "(): " + sb.toString());
    } // logSql()

    // ...

    public Cursor readWatchlist(long watchlistId) {
        final String methodName = "readWatchlist";
        Cursor cursor = null;
        Log.v(CLASS_NAME,
                String.format("%s(): watchlistId = %d", methodName, watchlistId));
        SQLiteDatabase db = getReadableDatabase();
        String selection = Watchlist.ID + " = ?";
        String[] selectionArgs = new String[]{String.valueOf(watchlistId)};
        String table = Watchlist.TABLE;
        logSql(methodName, null, null, selection, selectionArgs, table);
        cursor = db.query(table, null, selection, selectionArgs, null, null,
                null);
        Log.v(CLASS_NAME, String.format(CURSOR_COUNT_FORMAT, methodName, cursor.getCount()));
        if (cursor.getCount() != 1) {
            Log.e(CLASS_NAME, String.format(
                    "%s(): found %d watchlists with id = %d; expected 1!", methodName,
                    cursor.getCount(), watchlistId));
        }
        return cursor;
    } // readWatchlist()

    // ...
}

When the user taps OK the watchlist and its connection to securities are saved like for a new watchlist. And finally WatchlistsManagementActivity.onActivityResult() receives a resultCode of RESULT_OK and refreshes its list of watchlists to show a possibly changed name – again this was already implemented for creating new watchlists.

Give it a try:

  1. Open the Manage Watchlists screen
  2. Tap Edit on one of the watchlists to show the Edit Watchlist screen – it says “Edit Watchlist”
  3. Change the name or the securities to include
  4. Tap OK
  5. Also tap OK in the Manage Watchlists screen
  6. Check if the changes were applied correctly – either in the main screen or by using the Edit Watchlist screen again
  7. Optional: commit changes

1.6 Add Delete Functionality for Watchlists

The functionality to delete watchlists in WatchlistManagementCursorAdapter uses the same pattern as for editing them:

public class WatchlistManagementCursorAdapter extends CursorAdapter {
    public static final String WATCHLIST_DELETED_BROADCAST = "WatchlistDeletedBroadcast";
    // ...

    private View.OnClickListener deleteButtonClickListener = new View.OnClickListener() {

        public void onClick(final View v) {
            WatchListManagementDetailViewHolder holder
                    = (WatchListManagementDetailViewHolder) ((View) v.getParent()).getTag();
            String watchListName = holder.nameTextView.getText().toString();
            new AlertDialog.Builder(holder.context)
                    .setTitle("Delete?")
                    .setMessage(
                            String.format(
                                    "Delete watchlist '%s' and its connections to securities?",
                                    watchListName))
                    .setIcon(android.R.drawable.ic_dialog_alert)
                    .setPositiveButton(android.R.string.yes,
                            new DialogInterface.OnClickListener() {
                                public void onClick(DialogInterface dialog,
                                                    int whichButton) {
                                    WatchListManagementDetailViewHolder holder
                                            = (WatchListManagementDetailViewHolder) ((View) v
                                            .getParent()).getTag();
                                    long watchListId = holder.watchListId;
                                    dbHelper.deleteWatchlist(watchListId);
                                    Intent intent = new Intent(WATCHLIST_DELETED_BROADCAST);
                                    LocalBroadcastManager.getInstance(holder.context)
                                            .sendBroadcast(intent);
                                }
                            })
                    .setNegativeButton("Cancel", null)
                    .show();
        } // onClick()

    }; // deleteButtonClickListener

    // ...
}

If you aren’t used to Java the code for deleteButtonClickListener probably looks somewhat weird due to the anonymous classes and the builder pattern. The code itself is like the code to edit a watchlist but with an added twist: the user needs to tap OK in a confirmation dialog to actually delete a watchlist.

Creating that AlertDialog uses the builder pattern / a fluent API / method chaining. That’s syntactical sugar claiming to make the code more readable. Or maybe it makes it even less readable – you decide.

The setPositiveButton() method’s 2nd parameter is yet another OnClickListener and again it is provided as an anonymous class that only implements an onClick() handler. That handler determines the watchlist’s ID and passes it to DbHelper.deleteWatchlist(). After that it initiates a refresh of the list from which the watchlist was deleted.

WatchlistManagementCursorAdapter avoids referencing WatchlistsManagementActivity because that activity already uses the CursorAdapter. And because deleting a watchlist doesn’t show a new activity it can’t trigger WatchlistsManagementActivity.onActivityResult().

In WatchlistsManagementActivity a receiver for that broadcast has to be implemented, registered, and unregistered. Note that in lifecycle methods like onPause() and onResume() you always call super first.

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    private BroadcastReceiver watchlistDeletedBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (intent.getAction().equals(
                    WatchlistsManagementCursorAdapter.WATCHLIST_DELETED_BROADCAST)) {
                refreshWatchlistsListView();
            }
        }
    }; // watchlistDeletedBroadcastReceiver

    // ...

    @Override
    public void onPause() {
        super.onPause();
        // Unregister broadcast receiver for WATCHLIST_DELETED_BROADCAST
        LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(this);
        broadcastManager.unregisterReceiver(watchlistDeletedBroadcastReceiver);
    } // onPause()

    @Override
    public void onResume() {
        super.onResume();
        // Register broadcast receiver for WATCHLIST_DELETED_BROADCAST
        LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(this);
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(WatchlistsManagementCursorAdapter.WATCHLIST_DELETED_BROADCAST);
        broadcastManager.registerReceiver(watchlistDeletedBroadcastReceiver, intentFilter);
    } // onResume()

    // ...
}

Deleting the data is straightforward – DbHelper.deleteWatchlist() first deletes the records connecting the watchlist to any securities and after that deletes the watchlist itself. Of course it wraps everything in a transaction. The log entries look like this if the watchlist showed 2 securities:
… V/DbHelper: deleteWatchlist(): watchlistId = 4
… V/DbHelper: deleteWatchlist(): result of db.delete() from securities_in_watchlists = 2
… V/DbHelper: deleteWatchlist(): result of db.delete() from watchlist = 1
… D/DbHelper: deleteWatchlist(): success!

Deleting a watchlist

Deleting a watchlist

To try it:

  1. Open the Manage Watchlists screen
  2. Tap Delete on one of the watchlists
  3. Tap Ok in the confirmation dialog – note that it displays the watchlist’s name to avoid any mishaps
  4. Also tap OK in the Manage Watchlists screen
  5. Check if the watchlist is gone
  6. Optional: commit changes

2. Add Securities Management

Securities management works exactly the same way watchlists management does. And adding securities management will follow the exact same steps adding watchlist management used. Therefore I’ll just fast forward through this.

2.1 Add the Manage Securities Screen

You need a new empty activity named “SecuritiesManagementActivity” with 3 Buttons, a ListView, and a TextView similar to activity_watchlists_management.xml.

And you need code for the Cancel and OK buttons and to set the screen’s title in onCreate() like in WatchlistsManagementActivity.java.

And finally you need a new menu item in menu_watchlist_list.xml.

Then you need to extend WatchlistListActivity to start the new activity in onOptionsItemSelected() and retrieve its result in onActivityResult().

Manage Securities screen - list of watchlists empty

Manage Securities screen – list of watchlists empty

Now try out the additions:

  1. Start the app
  2. In its overflow menu tap “Manage Securities”
  3. The “Manage Securities” screen appears
    1. Note the hint about adding a new security when there is none
    2. Note that there is no menu but a distinct title for the screen
  4. In the “Manage Securities” screen tap either OK or Cancel – tapping “New” will crash the app
  5. Optional: commit the changes

2.2 Fill List of Existing Securities

To list the existing securities DbTradeAlert needs a layout to show each security. It’s added like layout_watchlists_management_detail with one more TextView to show the security’s symbol.

Manage Securities screen listing securities

Manage Securities screen listing securities

The next step will be using the adapter to marry the ListView with its cursor and its detail layout.

After that create a new class named “SecuritiesManagementCursorAdapter” extending CursorAdapter like you did for WatchlistsManagementCursorAdapter.

For explanations see filling the list of existing watchlists.

Finally connect SecuritiesManagementCursorAdapter to securitiesListView in WatchlistsManagementActivity.onCreate() – to fill the list of securities DbHelper.getAllSecuritiesAndMarkIfInWatchlist() will be reused.

Test it: the securities show up in the Manage Securities screen. Optionally check in the changes.

2.3 Add an Activity to Edit Securities

Add a new empty activity named “SecurityEditActivity”. It hosts a lot of controls and I’ll just show the resulting layout:

Edit Security layout

Edit Security layout

The first step after creating the layout is to provide a handler for SecuritiesManagementActivity’s New button. The pattern is exactly like in WatchlistsManagementActivity:

  1. Create an intent pointing to SecurityEditActivity.class
  2. Add an Extra named SecurityEditActivity.SECURITY_ID_INTENT_EXTRA with the security ID of DbHelper.NEW_ITEM_ID to the intent
  3. Call startActivityForResult() passing the intent and SecurityEditActivity.CREATE_SECURITY_REQUEST_CODE

And SecurityEditActivity’s required extensions:

  1. It needs definitions for CREATE_SECURITY_REQUEST_CODE and SECURITY_ID_INTENT_EXTRA
  2. onCancelButtonClick():
    1. Sets result to RESULT_CANCELED
    2. Closes the screen
  3. onCreate():
    1. Grabs the ID from the intent’s Extras
    2. Clears the EditTexts for new securities
    3. Sets the screen’s title
    4. Calls refreshSecuritiesListView() passing the security’s ID
  4. refreshSecuritiesListView():
    1. Calls dbHelper.getAllWatchlistsAndMarkIfSecurityIsIncluded() passing the security’s ID
    2. Creates a SimpleCursorAdapter instance to connect cursor and ListView
    3. Sets the ListView’s EmptyView
    4. Marks the watchlists that include the currently shown security

Again this code works exactly like that for editing a watchlist – just with a lot more fields.

Finally DbHelper needs getAllWatchlistsAndMarkIfSecurityIsIncluded()which works like explained for DbHelper.getAllSecuritiesAndMarkIfInWatchlist().

Run the app again:

  1. Open the Manage Securities screen
  2. Tap New: the new Edit Security screen shows up listing all watchlists and ready for input
    1. again no menu but a distinct title of “Add Security”
  3. Tap Cancel – tapping OK will crash the app
  4. Optional: commit changes
Adding a new security

Adding a new security

The screen shows a security’s fields: at the top is the symbol field. Below that you see value and date for base and maximum price, targets, and the notes field. Note that the dates have to be entered according to the device’s locale. While Android provides a DatePickerDialog I found it too much hassle to implement and connect it to the EditTexts.

At the bottom of the screen is a list of all watchlists with those checked that contain this security.

Note that that I closed the automatically displayed on-screen keyboard so it doesn’t obstruct the activity’s screen.

2.4 Add Save Functionality for New Securities

Saving new securities is implemented like its watchlist counterpart with two additions:

  • Make shure date and float values were entered correctly
  • Stop the user from entering a duplicate symbol
public class SecurityEditActivity extends AppCompatActivity {
    // ...

    private Date getDateFromEditText(Integer editTextId) {
        Date result = null;
        EditText editText = (EditText) findViewById(editTextId);
        if (editText.length() > 0) {
            DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
            String text = editText.getText().toString();
            try {
                result = dateFormat.parse(text);
            } catch (ParseException e) {
                Log.e(CLASS_NAME, Utils.EXCEPTION_CAUGHT, e);
                Toast.makeText(
                        this, "Error: '" + text + "' is not a valid date", Toast.LENGTH_SHORT)
                        .show();
            }
        }
        return result;
    } // getDateFromEditText()

    // ...

    public void onOkButtonClick(View view) {
        Float basePrice = getFloatFromEditText(R.id.basePriceEditText);
        Date basePriceDate = getDateFromEditText(R.id.basePriceDateEditText);
        Float lowerTarget = getFloatFromEditText(R.id.lowerTargetEditText);
        Float maxPrice = getFloatFromEditText(R.id.maxPriceEditText);
        Date maxPriceDate = getDateFromEditText(R.id.maxPriceDateEditText);
        String notes = Utils.getStringFromEditText(this, R.id.notesEditText);
        String symbol = Utils.getStringFromEditText(this, R.id.symbolEditText);
        Float upperTarget = getFloatFromEditText(R.id.upperTargetEditText);
        Float trailingTarget = getFloatFromEditText(R.id.trailingTargetEditText);
        long[] watchlistIds = Utils.getSelectedListViewItemIds(this, R.id.watchlistsListView);
        String errorMessage = this.dbHelper.updateOrCreateSecurity(basePrice, basePriceDate,
                lowerTarget, maxPrice, maxPriceDate, notes, this.securityId,
                symbol, trailingTarget, upperTarget, watchlistIds);
        if (TextUtils.isEmpty(errorMessage)) {
            setResult(RESULT_OK, getIntent());
            finish();
        } else {
            Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show();
        }
    } // onOkButtonClick()

    // ...
}

The SQL is similar to that saving watchlists with the only twist of avoiding duplicate symbols. DbHelper.updateOrCreateSecurity() first checks whether securityId equals NEW_ITEM_ID. If that’s the case it looks for an existing security with the same symbol. If it finds one it aborts the transaction and returns an error message.

SecurityEditActivity.onOkButtonClick() checks whether DbHelper.updateOrCreateSecurity() returned an non-empty string. If that’s the case it shows a Toast with that string and stays on screen.

Finally SecurityManagementActivity needs to be extended:

  • onActivityResult() calls refreshSecuritiesListView() if resultCode was RESULT_OK
  • refreshSecuritiesListView() does exactly what its name says

Try the new functionality:

  1. Open the Manage Securities screen
  2. Tap New to show the Edit Security screen – it says “Add Security”
  3. Enter a symbol that’s already in use and tap OK – you’ll get a message that you cannot add another security with that symbol and the Edit Security screen stays
  4. Enter a new symbol and select one or more watchlists
  5. Tap OK – the Edit Security screen closes and the Manage Securities screen shows the new security – without its name because no quotes were downloaded yet
  6. Also tap OK in the Manage Securities screen to close it
  7. Navigate to one of the watchlists that include the new security – there is no report for it because no quotes were downloaded yet
  8. Tap refresh – DbTradeAlert shows a complete report for the new security
  9. Optional: commit changes

2.5 Add Edit Functionality for Existing Securities

Editing a security - symbol read-only

Editing a security – symbol read-only

Editing securities is implemented like editing watchlists and I’ll skip repeating the explanation. One difference is that SecurityEditActivity.onCreate() calls symbolEditText.setEnabled(false) for existing securities. And DbHelper.readSecurity() of course has to join the Quote table for its Name field.

When the user taps OK the security and its connection to watchlists are saved like for a new security. If the user entered a duplicate symbol the error message from DbHelper is shown and the screen stays open.

And finally WatchlistsManagementActivity.onActivityResult() receives a resultCode of RESULT_OK and refreshes is list of securities if this was an addition – as it shows only the symbols and these are immutable there is no need for a refresh after updating a security.

Give it a try:

  1. Open the Manage Securities screen
  2. Tap Edit on one of the securities to show the Edit Security screen – it says “Edit Security”
  3. Change one of the fields or the watchlists that will include the security
  4. Tap OK
  5. Also tap OK in the Manage Securities screen
  6. Check if the changes were applied correctly
  7. Optional: commit changes

2.6 Add Delete Functionality for Securities

The functionality to delete securities uses the same pattern as for editing them – for explanations see deleting watchlists.

Deleting the data is straightforward – DbHelper.deleteSecurity() first deletes the security’s quote, then the records connecting the security to any watchlists and after that deletes the security itself. Of course it wraps everything in a transaction. The log entries look like this if the security was in a single watchlist:
… V/DbHelper: deleteSecurity(): securityId = 6
… V/DbHelper: deleteSecurity(): result of db.delete() from quote = 1
… V/DbHelper: deleteSecurity(): result of db.delete() from securities_in_watchlists = 1
… V/DbHelper: deleteSecurity(): result of db.delete() from security = 1
… D/DbHelper: deleteSecurity(): success!

Deleting a security

Deleting a security

To try it:

  1. Open the Manage Watchlists screen
  2. Tap Delete on one of the watchlists
  3. Tap Ok in the confirmation dialog – note that it displays the watchlist’s name to avoid any mishaps
  4. Also tap OK in the Manage Watchlists screen
  5. Check if the watchlist is gone
  6. Optional: commit changes

Next post: Add Reminders

Additional Resources:

Posted in Uncategorized | Tagged , | Leave a comment

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

Posted in Uncategorized | Tagged , | Leave a comment