DbTradeAlert for Android: More Finishing Touches

Update 2011-11-08: Firebase Crash Reporting is out of beta and more data shows up more quickly. Details see Google’s Firebase developer platform gets better analytics, crash reporting and more.


First post in this series: Introduction to DbTradeAlert

Previous post: Integrate Google Play Services


This post is just a mingle-mangle of small enhancements:

  1. Add Firebase Crash Reporting to playStore and withAds flavors
  2. Add Firebase Remote Config to playStore and withAds flavors
  3. Add an about box
  4. Perform Play Store optimization

1. Add Firebase Crash Reporting

Firebase Crash Reporting allows you to keep track of exceptions and error scenarios. It also analyzes exceptions providing information about problem clusters and how many users were affected as well as the severity of the impact. Google only introduced Firebase Crash Reporting at its I/O 2016 and currently it’s still in Beta.

The first step to integrate Firebase Crash Reporting into an app is adding its dependencies to a module’s build.gradle:

// ...

dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:23.3.0'
    compile 'com.android.support:design:23.3.0'
    compile 'com.android.support:support-v4:23.3.0'
    compile 'com.android.support:recyclerview-v7:23.3.0'
    playStoreCompile 'com.google.firebase:firebase-core:9.4.0'
    playStoreCompile 'com.google.firebase:firebase-crash:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-core:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-ads:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-crash:9.4.0'
    compile 'org.jetbrains:annotations-java5:15.0'
}

// ...

In this case two out of three flavors are going to use Firebase Crash Reporting. For that reason the “common” package’s PlayStoreHelper gets four new methods:

// ...

public class PlayStoreHelper {
    // ...

    public static void logDebugMessage(String tag, String message) {
        FirebaseCrash.log(message);
        FirebaseCrash.logcat(Log.DEBUG, tag, message);
    } // logDebugMessage()

    public static void logDebugMessage(Exception e) {
        String message = "";
        String stackTrace = "";
        if (e != null) {
            message = e.getMessage();
            stackTrace = Log.getStackTraceString(e);
            FirebaseCrash.logcat(Log.DEBUG, message, stackTrace);
        }
    } // logDebugMessage()

    public static void logError(String tag, String message) {
        FirebaseCrash.log(message);
        FirebaseCrash.logcat(Log.ERROR, tag, message);
    } // logError()

    public static void logError(Exception e) {
        FirebaseCrash.report(e);
    } // logError()

// ...

So four methods provide all the “crash” reporting and replace most of the Log.e() calls:

  • logDebugMessage(Exception e) reports an exception that should not be treated as an error but as a … debug message
    • Parsing exceptions are logged for now to get an idea what goes wrong and will be replaced by Log.d() calls afterwards
  • logDebugMessage(String tag, String message) reports any condition as a debug message
    • Internet being unavailable is currently logged
  • logError(Exception e) reports an exception that should be treated as an error:
    • Used in only 2 places – that strange UnsupportedEncodingException and a possible IOException when copying the database
  • logError(String tag, String message) reports a non-exceptional condition as an error:
    • SQL queries searching by Id that return not exactly one row
    • Receiving a HTTP status code != 200 when downloading
    • switch statements unintentionally executing their default block

Calling for example reportException() with a test exception will produce a line like this followed by the stack trace in Android Studio’s logcat window:
... E/Test crash: java.lang.Exception: Test crash

The “naked” package’s PlayStoreHelper of course gets the same methods which just call Log.d() and Log.e() respectively.

Data from Firebase Crash Reporting shows up online much faster than general Firebase Analytics data – Google estimates 1 to 2 minutes. To check if the test crash made it home:

  1. Navigate to the Firebase console at https://console.firebase.google.com and log in
  2. Select your project – DbTradeAlert in my case
  3. Click Crash on the navigation bar
  4. If necessary switch to the correct app – “de.dbremes.dbtradealert.withads” in my case – in the top right
Firebase Crash Reporting main screen

Firebase Crash Reporting main screen

The graph shows the number of errors and users impacted as well as whether Firebase was able to identify any clusters. That’s done by comparing the stack traces.

Below the graph is a view of each error cluster and clicking it reveals the full stacktrace. If you are using ProGuard that stacktrace will be obfuscated and you should upload the respective mapping file. Note that each build of an app generates a new mapping file which you’ll need to upload – and store in your VCS if you need to debug errors from previous builds. DbTradeAlert doesn’t use ProGuard because it’s less than 3 MB in size and doesn’t contain any secrets.

Clicking the Show Details button of the full stacktrace will show even more details – the size of free and used memory, whether the device was on WiFi, its manufacturer and orientation, …

Crash report details

Crash report details

Some additional notes:

  • You cannot delete errors in Firebase Crash Reporting; to prevent polluting your app’s report use a different flavor for debugging and testing
  • Firebase Crash Reporting also produces app_exception events in Firebase Analytics but this seems to be a hit-or-miss feature for now
  • Calls to FirebaseCrash.logcat() currently don’t produce anything in the Firebase Crash Reporting console
  • Firebase Crash Reporting creates a separate background process which may cause concurrency issues

2. Add Firebase Remote Config

Firebase Remote Config lets you remotely configure your app. That means you can change how your app works or looks on user’s devices without requiring a new download. You also control whether a configuration change affects all users or only a part of them and when apps check for changes. DbTradeAlert makes use of Firebase Remote Config to switch off logging of parsing and connection errors after I got an idea of what goes wrong for users.

Let’s start by configuring two parameters in Firebase Console:

  1. Navigate to https://console.firebase.google.com/ and log in
  2. Select the project and click Remote Config on the navigation bar to switch to the Parameters pane
  3. In the Parameters pane click Add Your First Parameter
  4. Enter “is_parsing_error_logging_enabled” as Parameter key, “true” as Default value and click Add Parameter
  5. Add another parameter named “is_connection_error_logging_enabled” with the same default value
  6. Finally click Publish Changes above the Parameters pane
  7. In the confirmation box click Publish Changes again to make the parameters immediately available

This setup doesn’t need conditions to make changes only available to parts of the users. If yours does click “Add value for condition” when specifying a parameter.

The next step is to include the depenency on Firebase Remote Config in build.gradle:

// ...

dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:23.3.0'
    compile 'com.android.support:design:23.3.0'
    compile 'com.android.support:support-v4:23.3.0'
    compile 'com.android.support:recyclerview-v7:23.3.0'
    playStoreCompile 'com.google.firebase:firebase-core:9.4.0'
    playStoreCompile 'com.google.firebase:firebase-crash:9.4.0'
    playStoreCompile 'com.google.firebase:firebase-config:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-core:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-ads:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-crash:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-config:9.4.0'
    compile 'org.jetbrains:annotations-java5:15.0'
}

// ...

The dependency on Firebase Remote Config is added to the playStore and withAds flavors like the one on Firebase Crash Reporting.

Now the app needs local configuration parameters matching the ones defined in Firebase Remote Config so it can run without contacting the mothership first. That means creating a new configuration file so those configuration values don’t show up in the Settings screen:

  1. In the res\xml folder’s context menu select New | File
  2. In the New File window enter “remote_config_defaults.xml” as File Name and click OK

Type in the contents of remote_config_defaults.xml:

<?xml version="1.0" encoding="utf-8"?>
<defaultsMap>
    <entry>
        <key>is_connection_error_logging_enabled</key>
        <value>true</value>
    </entry>
    <entry>
        <key>is_parsing_error_logging_enabled</key>
        <value>true</value>
    </entry>
</defaultsMap>

With the preparations finished Firebase Remote Config is ready for action.

As Firebase Remote Config is useful for both the playStore and withAds flavors it will be encapsulated in the PlayStoreHelper class. The first step ist to initialize Firebase Remote Config:

// ...

public class PlayStoreHelper {
    // ...

    private static void fetchRemoteConfigValues(FirebaseRemoteConfig firebaseRemoteConfig) {
        // Parameterless fetch() uses this default value for cacheExpirationSeconds:
        long cacheExpirationSeconds = 43200; // 12 hours

        // Expire the cache immediately if in development mode
        if (firebaseRemoteConfig.getInfo().getConfigSettings().isDeveloperModeEnabled()) {
            cacheExpirationSeconds = 0;
        }

        firebaseRemoteConfig.fetch(cacheExpirationSeconds)
                .addOnCompleteListener(new OnCompleteListener<Void>() {
                    @Override
                    public void onComplete(@NonNull Task<Void> task) {
                        if (task.isSuccessful()) {
                            FirebaseRemoteConfig.getInstance().activateFetched();
                            Log.v(CLASS_NAME,
                                    "fetchRemoteConfigValues(): Firebase Remote Config values have been fetched");
                        } else {
                            PlayStoreHelper.logError(task.getException());
                        }
                    }
                });
    } // fetchRemoteConfigValues()

    public static void initialize(boolean isDeveloperModeEnabled) {
        // Create a FirebaseRemoteConfig instance and initialize it with local default values
        // Note that the initial getInstance() call on app creation reads from a local file.
        // To avoid StrictMode disk read errors, this initial call should not be made on the
        // UI thread.
        FirebaseRemoteConfig firebaseRemoteConfig = FirebaseRemoteConfig.getInstance();
        firebaseRemoteConfig.setDefaults(R.xml.remote_config_defaults);
        FirebaseRemoteConfigSettings frcs = new FirebaseRemoteConfigSettings.Builder()
                // setDeveloperModeEnabled() only controls minimum cacheExpirationSeconds?
                .setDeveloperModeEnabled(isDeveloperModeEnabled)
                .build();
        firebaseRemoteConfig.setConfigSettings(frcs);
        // Only now remote values are fetched - if cached values have expired
        fetchRemoteConfigValues(firebaseRemoteConfig);
    } // initialize()

    // ...
} // class PlayStoreHelper

The initialize() method grabs Firebase Remote Config’s default instance and initializes it with the local values from remote_config_defaults.xml. After that it enables developer mode – or not – which only seems to control the minimum cache expiration one can specify.

Note that the initial getInstance() call on app creation reads from a local file. To avoid StrictMode disk read errors, this initial call should not be made on the UI thread.

Now comes the remote part of Firebase Remote Config: fetchRemoteConfigValues(). This method first determines the cache expiration – either the built-in default of 12 hours or nothing at all if in developer mode. If you can live with the default you can just call the parameterless overload of FirebaseRemoteConfig.fetch().

The method then adds an OnCompleteListener to fetch() that will of course be called asynchronously. When everything went to plan the remote values are activated.

When the app starts it calls the initialize() method:

// ...

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

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ...

        // 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);

        // This is needed to provide local default values until fetching remote config values
        // is complete and to control cache expiration with isDeveloperModeEnabled
        boolean isDeveloperModeEnabled = true;
        PlayStoreHelper.initialize(isDeveloperModeEnabled && BuildConfig.DEBUG);

        // This will be called before fetching remote config values is complete
//        PlayStoreHelper.logConnectionError(CLASS_NAME, "No worries, just a test");
//        PlayStoreHelper.logParsingError(CLASS_NAME, new ParseException("ParseException-test", 0));

        this.dbHelper = new DbHelper(this);

        // ...
    } // onCreate()

    // ...
} // class WatchlistListActivity

With the infrastructure in place let’s configure logging. As only logging of specific error conditions will be shut off it makes sense to add specialized logging methods. And an additional loging method helps to gain some insight into Firebase Remote Config’s state:

// ...

public class PlayStoreHelper {
    // ...

    public static void logConnectionError(String tag, String message) {
        boolean isConnectionErrorLoggingEnabled = FirebaseRemoteConfig.getInstance()
                .getBoolean("is_connection_error_logging_enabled");
        if (isConnectionErrorLoggingEnabled) {
            logError(tag, message);
        } else {
            Log.e(tag, message);
        }
//        logFirebaseRemoteConfig();
    } // logConnectionError()

    // ...

    private static void logFirebaseRemoteConfig() {
        final String methodName = "logFirebaseRemoteConfig";
        FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.getInstance();
        FirebaseRemoteConfigInfo remoteConfigInfo = remoteConfig.getInfo();
        FirebaseRemoteConfigSettings frcs = remoteConfigInfo.getConfigSettings();
        // isDeveloperModeEnabled
        boolean isDeveloperModeEnabled = frcs.isDeveloperModeEnabled();
        Log.v(CLASS_NAME, String.format(
                "%s(): isDeveloperModeEnabled = %b", methodName, isDeveloperModeEnabled));
        // fetchTimeMillis - timestamp in milliseconds of last successful fetch
        long fetchTimeMillis = remoteConfigInfo.getFetchTimeMillis();
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String fetchTimestamp = sdf.format(new Date(fetchTimeMillis));
        Log.v(CLASS_NAME, String.format(
                "%s(): fetchTimeMillis = %d (%s)", methodName, fetchTimeMillis, fetchTimestamp));
        // lastFetchStatus
        int lastFetchStatus = remoteConfigInfo.getLastFetchStatus();
        String lastFetchStatusString = "?";
        switch (lastFetchStatus) {
            case FirebaseRemoteConfig.LAST_FETCH_STATUS_FAILURE:
                lastFetchStatusString = "LAST_FETCH_STATUS_FAILURE";
                break;
            case FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET:
                lastFetchStatusString = "LAST_FETCH_STATUS_NO_FETCH_YET";
                break;
            case FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS:
                lastFetchStatusString = "LAST_FETCH_STATUS_SUCCESS";
                break;
            case FirebaseRemoteConfig.LAST_FETCH_STATUS_THROTTLED:
                lastFetchStatusString = "LAST_FETCH_STATUS_THROTTLED";
                break;
        }
        Log.v(CLASS_NAME, String.format(
                "%s(): lastFetchStatus = %d (%s)",
                methodName, lastFetchStatus, lastFetchStatusString));
        // valueSource
        FirebaseRemoteConfigValue v = remoteConfig.getValue("is_parsing_error_logging_enabled");
        int valueSource = v.getSource();
        String valueSourceString = "?";
        switch (valueSource) {
            case FirebaseRemoteConfig.VALUE_SOURCE_DEFAULT:
                valueSourceString = "VALUE_SOURCE_DEFAULT";
                break;
            case FirebaseRemoteConfig.VALUE_SOURCE_REMOTE:
                valueSourceString = "VALUE_SOURCE_REMOTE";
                break;
            case FirebaseRemoteConfig.VALUE_SOURCE_STATIC:
                valueSourceString = "VALUE_SOURCE_STATIC";
                break;
        }
        Log.v(CLASS_NAME, String.format(
                "%s(): valueSource for is_parsing_error_logging_enabled = %d (%s)",
                methodName, valueSource, valueSourceString));
        Log.v(CLASS_NAME, String.format(
                "%s(): value for is_parsing_error_logging_enabled = %b",
                methodName, v.asBoolean()));
    } // logFirebaseRemoteConfig()

    public static void logParsingError(String tag, Exception e) {
        final String EXCEPTION_CAUGHT = "Exception caught";
        boolean isParsingErrorLoggingEnabled = FirebaseRemoteConfig.getInstance()
                .getBoolean("is_parsing_error_logging_enabled");
        if (isParsingErrorLoggingEnabled) {
            logError(e);
        } else {
            Log.e(tag, EXCEPTION_CAUGHT, e);
        }
//        logFirebaseRemoteConfig();
    } // logParsingError()

    // ...
} // class PlayStoreHelper 

Both logConnectionError() and logParsingError() check whether the respective error should be logged with Firebase Crash Reporting. In that case they call the generic method and otherwise they call Log.e(). Reading the values is again done via FirebaseRemoteConfig’s default instance.

With logFirebaseRemoteConfig() the PlayStoreHelper also provides a method to peek into Firebase Remote Config. You can call it from one of the logging methods for example. Interesting details are

  • FirebaseRemoteConfigInfo.getFetchTimeMillis() which returns the last time the remote values were fetched
  • FirebaseRemoteConfigInfo.getLastFetchStatus() which returns exactly that including LAST_FETCH_STATUS_THROTTLED which means you were too fidgety
  • FirebaseRemoteConfigValue.getSource() which lets you know where each configuration value actually came from – VALUE_SOURCE_STATIC means the configuration value wasn’t found and Firebase Remote Config simply used a default value like an empty string

To make use of the new logging methods I replaced the generic ones at the respective places. To test Firebase Remote Config:

  1. Call PlayStoreHelper.logParsingError() somewhere and inside logParsingError() call logFirebaseRemoteConfig()
  2. Set a breakpoint in the OnCompleteListener and in logParsingError()
  3. Run the app making shure logParsingError() is called – the current configuration will log the Exception with Firebase Crash Reporting

Check that logParsingError() gets the correct value and you’ll find that the OnCompleteListener breakpoint is only hit a while after you started the app. After a few minutes the Exception should show up in Firebase Crash Reporting. Finally check logFirebaseRemoteConfig()’s output (timestamp, value source).

Now change is_parsing_error_logging_enabled in the Firebase Remote Config console to false. Make shure to publish the changes, too!

Running the same test again should now only show the Exception in logcat.

3. Add an About Box

About box with selected text

About box with selected text

Adding an about box is straight forward:

  1. Add a menu item to menu/menu_watchlist_list.xml
  2. Add an empty activity named AboutActivity
  3. Start that activity from WatchlistListActivity.onOptionsItemSelected() like the Help activity

The layout file layout/activity_about.xml looks like this:

<?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="de.dbremes.dbtradealert.AboutActivity">

    <TextView android:id="@+id/editTextView" android:layout_alignParentTop="true" android:layout_centerHorizontal="true" android:layout_height="wrap_content" android:layout_marginTop="34dp" android:layout_width="fill_parent" android:textAppearance="?android:attr/textAppearanceMedium" android:textIsSelectable="true" />

    <Button android:id="@+id/okButton" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:layout_height="wrap_content" android:layout_marginBottom="32dp" android:layout_width="wrap_content" android:onClick="onOkButtonClick" android:text="OK" />
</RelativeLayout>

The TextView has set its textIsSelectable attribute to true so users can select its content – avoids typos.

A look inside AboutActivity.java – nothing special here:

package de.dbremes.dbtradealert;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

public class AboutActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_about);
        int resId = getApplicationContext().getApplicationInfo().labelRes;
        String appName = getApplicationContext().getString(resId);
        String applicationId = BuildConfig.APPLICATION_ID;
        int versionCode = BuildConfig.VERSION_CODE;
        String versionName = BuildConfig.VERSION_NAME;
        setTitle("About " + appName);
        String s = String.format(
                "appName = %s\napplicationId = %s\nversionCode = %s\nversionName = %s",
                appName, applicationId, versionCode, versionName);
        TextView editTextView = (TextView) findViewById(R.id.editTextView);
        editTextView.setText(s);
    } // onCreate()

    public void onOkButtonClick(View view) {
        finish();
    } // onOkButtonClick()
} // class AboutActivity

4. Perform Play Store Optimization

Play Store optimization is search engine optimization for the Play Store. And it’s important because as more than one new app hits the Play Store per minute forget about yours making it solely on its inner virtues.

Disclaimer: my Play Store optimization wasn’t successful so you may want to skip this section all together.

In this post I’ll simply fiddle with keywords. Of course there are more efficient ways to boost your app installs. For example getting reviews and links to your app from the target group’s publications. But that’s way outside the scope of this series.

The first step is to figure out which keywords to use. And the initial idea probably is to use the ones people are most likely to search for when hunting for an app like yours. But there are already very popular apps of the same type which will outrank yours. And making it to #165 versus 400 competitors may be an accomplishment but will get you no downloads.

So the strategy is to find keywords which people are less likely to use but have your app near the top of search results. And of course you want to optimize that relation of a higher search rank vs. less searches.

Here Google’s Keyword Planner tool comes handy. It’s intended for advertisers who bid on keywords to show their adverts when someone searches for those keywords. To help them figure out the most economic keywords the tool shows a suggested bid for each as well as a list of related keywords and their suggested bids.

My idea simply is the more they bid the better the relation of search rank vs. frequency will be for DbTradeAlert. Of course this is not necessarily the same demographic that searches the Play Store but the tool should give me a rough idea about which keywords to use. If you work on a more professional level there are specialized tools and services to buy.

When searching for an app like DbTradeAlert my keywords would be “watchlist” or … “trade alert”. Let’s see what would be better keywords:

  1. Navigate to https://adwords.google.com/ko/KeywordPlanner/Home and log in with your AdWords / AdMob account
  2. Click “Search for new keywords using a phrase, website or category”
  3. In the search box enter “watchlist”, select “Finance” as the product category, and click Get ideas
Google Keyword Planner in action

Google Keyword Planner in action

The results page shows statistics for the original keyword at the top and it turns out that competition for “watchlist” is actually low at 10K – 100K monthly searches.

The lower part shows keywords listed by relevance to the original keyword. One interesting find is that “watch list” has one tenth of “watchlist” average monthly searches with similar competition but its suggested bid is 50 % higher. So using “watch list” instead of “watchlist” looks like a good idea.

Even more interesting is that keywords related to “stock” are the most expensive ones with “tracking stock” making the top with low competition. On the other hand “trading” is worth nearly twice as much as “tracking stock” but has high competition. And “stock quotes” – while probably used by people looking for data on stocks – has only a moderate suggested bid but 100 times the monthly searches of “watchlist”.

Repeating the search for “trade alert” showed prices going through the roof – 100 times the previous keywords’ suggested bids with medium to high competition. But the target group seems to be day and Forex traders – certainly not the people using DbTradeAlert. In addition to that searching for “tradealert” reported 10 – 100 monthly searches and not even a suggested bid. So no new keyword ideas from this.

Searching the Play Store for those old and new keywords basically didn’t find DbTradeAlert at all. That was using the web page which shows about 250 results for a search.

The exceptions were “trade alert” where it scored #106 of about 250 and … “tradealert” at #9 of 15 apps. But as already noted those using the terms will be in the wrong target group. Lesson: do keyword research before selecting an app’s name!

After that I changed the wording of the app’s store listing mostly to include “track stocks” in its title as well as in the short and full description. But letting that sink in for five days showed no changes in the seach results.

Next post: Automated UI Tests – Part 1: Thoughts about Testing

Additional Resources

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

Leave a Reply

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

WordPress.com Logo

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

Twitter picture

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

Facebook photo

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

Google+ photo

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

Connecting to %s