DbTradeAlert for Android: Automated UI Tests – Part 3: Testing with WireMock and Firebase Test Lab for Android

Update 2016-11-24: the bug in handling URL encoded parameters in WireMock is fixed. But the current version throws a “IllegalArgumentException: resource assets not found” before even starting a test. For now DbTradeAlert stays with Wiremock version 2.2.1.

Update 2016-11-08: the free Spark plan now includes 5 tests per day on physical devices in Firebase Test Lab for Android – no credit card required. Details see Firebase Dev Summit.


First post in this series: Introduction to DbTradeAlert

Previous post: Automated UI Tests – Part 2: Testing with Espresso

This post jumps right into the middle of adding a scrolling experience test that was identified as being necessary for DbTradeAlert. Read the previous post to get an idea of the test and the one before it to find out how that scrolling experience test came about.


5.5 Mock Internet Access with WireMock

In its current state the test downloads quotes from the Internet when the recorded action to tap the Refresh button is executed. And to avoid starting the swipes before the list is filled with reports from those quotes the test uses a Thread.sleep(). This approach has various problems:

  • There may simply be no Internet access – for example on a build server
  • Waiting slows down the tests – especially a problem on Android as tests have to be repeated on various devices, Android versions, regional settings, …
  • The quote data is unpredictable – quotes downloaded monday morning will be 3 days old, values may be missing, downloads may result in an error code, …

The solution to all this is to avoid accessing the Internet altogether and just provide canned data. As replacing external components during tests is a common requirement it has a common fulfillment: mocking.

This test will use WireMock which serves as a proxy and responds with a canned .csv file to DbTradeAlert’s rerouted request. WireMock can do much more like return arbitrary HTTP response codes or take any time to answer at all. This will come in handy for later tests.

5.5.1 Download Sample Quotes

While WireMock can be used to record responses it’s easier to just download a .csv file directly. The URL can be determined from:

  • the base URL in QuoteRefresherService.onHandleIntent()
  • the format string specifying the data fields and their order in DbHelper.QuoteDownloadFormatParameter
  • the initial symbols and those from ch_securities.csv

The URL will look like this:
http://download.finance.yahoo.com/d/quotes.csv?f=aa2bc4d1ghl1nopp2st1vx&s=ABBN.VX+CFR.VX+NESN.VX+NOVN.VX+ROG.VX+SYNN.VX+UBSG.VX+ZURN.VX

Just enter it into a browser and save the downloaded quotes.csv file to “…\app\src\androidTest\assets”. To be shure check the asset shows up in Android Studio’s Project view as “quotes.csv (androidTest)”. The file is exactly what the app would have downloaded itself.

5.5.2 Integrate WireMock into the Project

The first step to integrate WireMock is to specify the dependencies in “build.gradle (Module: app)”:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        applicationId "de.dbremes.dbtradealert"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 2
        versionName "1.1"
        archivesBaseName = "${parent.name}-${android.defaultConfig.versionName}"
        // Ad unit Id for sample adverts from
        // https://firebase.google.com/docs/admob/android/google-services.json
        buildConfigField "String", "AD_UNIT_ID", "\"ca-app-pub-3940256099942544/6300978111\""
        testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
    }
    buildTypes {
        debug {
            // Multidex only needed for WireMock
            multiDexEnabled true
        }
        release {
            minifyEnabled false
            // ProGuard is off
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            // Keep actual ad unit Id secret by loading it from file outside of Git repo
            Properties props = new Properties()
            props.load(new FileInputStream("$project.rootDir/../../DbTradeAlert/project.properties"))
            buildConfigField "String", "AD_UNIT_ID", "\"${props.getProperty("ad_unit_id")}\""
        }
    }
    productFlavors {
        naked
        playStore {
            applicationId = "${android.defaultConfig.applicationId}.playStore"
        }
        withAds {
            applicationId = "${android.defaultConfig.applicationId}.withAds"
        }
    }
    sourceSets {
        playStore.java.srcDirs = ['src/common/java', 'src/playStore/java']
        withAds.java.srcDirs = ['src/common/java', 'src/withAds/java']
    }
}

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'
    androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    }
    androidTestCompile 'com.android.support.test.espresso:espresso-contrib:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude group: 'com.android.support', module: 'design'
        exclude group: 'com.android.support', module: 'recyclerview-v7'
    }
    androidTestCompile 'com.github.tomakehurst:wiremock:2.2.1', {
        // Allows us to use the Android version of Apache httpclient instead
        exclude group: 'org.apache.httpcomponents', module: 'httpclient'
        // Resolves the Duplicate Class Exception
        // duplicate entry: org/objectweb/asm/AnnotationVisitor.class
        exclude group: 'org.ow2.asm', module: 'asm'
        // Fixes Warning conflict with Android's version of org.json
        // org.json:json:20090211 is ignored for debugAndroidTest as it may be conflicting
        // with the internal version provided by Android.
        exclude group: 'org.json', module: 'json'
    }
    // Android compatible version of Apache httpclient.
    androidTestCompile 'org.apache.httpcomponents:httpclient-android:4.3.5.1'
    androidTestCompile 'com.android.support:multidex:1.0.0'
}
// Remove apply plugin: 'com.google.gms.google-services' when building naked variants.
// Those have no google-services.json file and applying the plugin would produce an error:
// "File google-services.json is missing. The Google Services Plugin cannot function without it."
// Building the R class would fail too and produce "cannot resolve symbol R" errors.
apply plugin: 'com.google.gms.google-services'
// Non-naked variants on the other hand will throw an exception when started after being build
// without this line: "FirebaseApp with name [DEFAULT] doesn't exist"

Because WireMock will only be used in instrumented tests references to it are noted with “androidTestCompile” – “testCompile” would apply to unit tests and “compile” affects the app itself.

Adding WireMock requires the use of multidexing – see lines 22 and 86. That’s because Android apps have an upper limit of 64K (65,536) methods and WireMock with its dependencies adds more than 65,000 methods alone. The limit actually affects the app’s DEX (Dalvik EXecutable) file and the solution is to use more than one DEX file. Fortunately Gradle will take care of that when requested. Also WireMock will only be added to the test apk and not to the app itself and Gradle is smart enough to use multidexing only when necessary.

Note that deviating from Handstandsam’s (Sam Edwards) reference build.gradle file – see lines 73 to 83 – I had to change an exclude from
exclude group: 'asm', module: 'asm' to
exclude group: 'org.ow2.asm', module: 'asm' to get rid of the error
ZipException: duplicate entry: org/objectweb/asm/AnnotationVisitor.class
Now how does one find the culprit?

The first step is to list all the app’s references, and their references, and their references, … While you can view them in Android Studio I found the command line output easier to digest. In Android Studio’s Terminal window enter:
gradlew app:dependencies --configuration androidTestCompile

Gradle or actually its wrapper gradlew defaults to the current project’s directory and you’ll get an output like this:

C:\Users\Admin\Documents\AndroidStudioProjects\DbTradeAlert>gradlew app:dependencies --configuration mockCompile
:app:dependencies                                                                         

------------------------------------------------------------
Project :app
------------------------------------------------------------

mockCompile - Classpath for compiling the mock sources.
+--- com.android.support.test.espresso:espresso-core:2.2.2
|    +--- com.squareup:javawriter:2.1.1
|    +--- com.android.support.test:rules:0.5
|    |    \--- com.android.support.test:runner:0.5
|    |         +--- junit:junit:4.12
|    |         |    \--- org.hamcrest:hamcrest-core:1.3
|    |         \--- com.android.support.test:exposed-instrumentation-api-publish:0.5
|    +--- com.android.support.test:runner:0.5 (*)
|    +--- javax.inject:javax.inject:1
|    +--- org.hamcrest:hamcrest-library:1.3
|    |    \--- org.hamcrest:hamcrest-core:1.3
|    +--- com.android.support.test.espresso:espresso-idling-resource:2.2.2
|    +--- org.hamcrest:hamcrest-integration:1.3
|    |    \--- org.hamcrest:hamcrest-library:1.3 (*)
|    +--- com.google.code.findbugs:jsr305:2.0.1
|    \--- javax.annotation:javax.annotation-api:1.2
+--- com.android.support.test.espresso:espresso-contrib:2.2.2
|    +--- com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:2.0
|    |    \--- org.hamcrest:hamcrest-core:1.3
|    \--- com.android.support.test.espresso:espresso-core:2.2.2 (*)
+--- com.github.tomakehurst:wiremock:2.2.1
|    +--- org.eclipse.jetty:jetty-server:9.2.13.v20150730
|    |    +--- javax.servlet:javax.servlet-api:3.1.0
|    |    +--- org.eclipse.jetty:jetty-http:9.2.13.v20150730
|    |    |    \--- org.eclipse.jetty:jetty-util:9.2.13.v20150730
|    |    \--- org.eclipse.jetty:jetty-io:9.2.13.v20150730
|    |         \--- org.eclipse.jetty:jetty-util:9.2.13.v20150730
|    +--- org.eclipse.jetty:jetty-servlet:9.2.13.v20150730
|    |    \--- org.eclipse.jetty:jetty-security:9.2.13.v20150730
|    |         \--- org.eclipse.jetty:jetty-server:9.2.13.v20150730 (*)
|    +--- org.eclipse.jetty:jetty-servlets:9.2.13.v20150730
|    |    +--- org.eclipse.jetty:jetty-continuation:9.2.13.v20150730
|    |    +--- org.eclipse.jetty:jetty-http:9.2.13.v20150730 (*)
|    |    +--- org.eclipse.jetty:jetty-util:9.2.13.v20150730
|    |    \--- org.eclipse.jetty:jetty-io:9.2.13.v20150730 (*)
|    +--- org.eclipse.jetty:jetty-webapp:9.2.13.v20150730
|    |    +--- org.eclipse.jetty:jetty-xml:9.2.13.v20150730
|    |    |    \--- org.eclipse.jetty:jetty-util:9.2.13.v20150730
|    |    \--- org.eclipse.jetty:jetty-servlet:9.2.13.v20150730 (*)
|    +--- com.google.guava:guava:18.0
|    +--- com.fasterxml.jackson.core:jackson-core:2.6.1
|    +--- com.fasterxml.jackson.core:jackson-annotations:2.6.1
|    +--- com.fasterxml.jackson.core:jackson-databind:2.6.1
|    |    +--- com.fasterxml.jackson.core:jackson-annotations:2.6.0 -> 2.6.1
|    |    \--- com.fasterxml.jackson.core:jackson-core:2.6.1
|    +--- org.xmlunit:xmlunit-core:2.1.1
|    +--- org.xmlunit:xmlunit-legacy:2.1.1
|    |    +--- org.xmlunit:xmlunit-core:2.1.1
|    |    \--- junit:junit:3.8.1 -> 4.12 (*)
|    +--- com.jayway.jsonpath:json-path:2.2.0
|    |    +--- net.minidev:json-smart:2.2.1
|    |    |    \--- net.minidev:accessors-smart:1.1
|    |    |         \--- org.ow2.asm:asm:5.0.3
|    |    \--- org.slf4j:slf4j-api:1.7.16
|    +--- org.slf4j:slf4j-api:1.7.12 -> 1.7.16
|    +--- net.sf.jopt-simple:jopt-simple:4.9
|    +--- junit:junit:4.12 (*)
|    +--- org.apache.commons:commons-lang3:3.4
|    \--- com.flipkart.zjsonpatch:zjsonpatch:0.2.1
|         +--- com.fasterxml.jackson.core:jackson-databind:2.3.2 -> 2.6.1 (*)
|         +--- com.fasterxml.jackson.core:jackson-core:2.3.2 -> 2.6.1
|         +--- com.google.guava:guava:18.0
|         \--- org.apache.commons:commons-collections4:4.0
+--- org.apache.httpcomponents:httpclient-android:4.3.5.1
\--- com.android.support:multidex:1.0.0

(*) - dependencies omitted (listed previously)

BUILD SUCCESSFUL

Of most interest is of course everything under “com.github.tomakehurst:wiremock” as the error wasn’t there before adding it. Neither the class name “AnnotationVisitor” nor the namespace “objectweb” show up but “org.ow2.asm” looks interesting.

The next step is to change “exclude group: ‘asm’, module: ‘asm'” to “exclude group: ‘org.ow2.asm’, module: ‘asm'” and list the dependencies again – “org.ow2.asm:asm:5.0.3” was gone. And so was the ZipException when compiling the app again.

Solving that problem makes way for the next one:

com.android.build.api.transform.TransformException: com.android.builder.packaging.DuplicateFileException: Duplicate files copied in APK META-INF/LICENSE
File1: C:\Users\Admin\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.6.1\892d15011456ea3563319b27bdd612dbc89bb776\jackson-core-2.6.1.jar
File2: C:\Users\Admin\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient-android\4.3.5.1\eecbb0b998e77629862a13d957d552b3be58fc4e\httpclient-android-4.3.5.1.jar
File3: C:\Users\Admin\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.6.1\45c37a03be19f3e0db825fd7814d0bbec40b9e0\jackson-databind-2.6.1.jar
File4: C:\Users\Admin\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.6.1\f9661ddd2456d523b9428651c61e34b4ebf79f4e\jackson-annotations-2.6.1.jar

As the Exception details show the problem is that four LICENSE files are being copied to the same place but of course only one can make it. Luckily checking the project’s licenses showed that all four are Apache 2.0 licenses so adding only one of them is legal:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        // ...
    }
    buildTypes {
        // ...
    }
    productFlavors {
        // ...
    }
    sourceSets {
        // ...
    }
    packagingOptions {
        // Keep only 1 license from 3* com.fasterxml.jackson.core + 1* org.apache.httpcomponents
        // All 4 are Apache 2.0 licenses
        pickFirst  'META-INF/LICENSE'
    }
}

dependencies {
    // ...
}
// ...

Finally the app builds with WireMock included.

5.5.3 Integrate WireMock into the Test

For the scrolling experience test the app needs to redirect its quotes request to WireMock and preferably without the redirect affecting other tests or the app itself. In other words it needs a specific build variant applying that mock.

But once you define a mock build type and switch to it the ScrollingExperienceTest class won’t compile anymore. That’s because Gradle evaluates the testCompile and androidTestCompile dependencies only for the debug build type. While a ‘testBuildType “mock”‘ fixes that only one build type can be specified which will shift the problem around. That means to have debug and mock builds working you’d have to add a mockCompile for each testCompile and androidTestCompile.

In this case the test only runs on the mock build so all androidTestCompile specific dependencies for Espresso and WireMock related libraries can be replaced with mockCompile dependencies.

Changes to build.gradle:

  • Lines 23 to 32 define the mock build
  • Line 24 shows how to initialize a build type
  • Line 68 replaces the debug with the mock build type as the default build type for tests
  • The new build type is applied for example in line 86
apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        applicationId "de.dbremes.dbtradealert"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 2
        versionName "1.1"
        archivesBaseName = "${parent.name}-${android.defaultConfig.versionName}"
        // Ad unit Id for sample adverts from
        // https://firebase.google.com/docs/admob/android/google-services.json
        buildConfigField "String", "AD_UNIT_ID", "\"ca-app-pub-3940256099942544/6300978111\""
        // Prepare rerouting of quote download requests to WireMock for tests
        buildConfigField "String", "HOST", "download.finance.yahoo.com"
        buildConfigField "String", "PORT", "\"80\""
        testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
    }
    buildTypes {
        mock {
            initWith(buildTypes.debug)
            // Without enhanced google-services.json his leads to the error
            // "No matching client found for package name 'de.dbremes.dbtradealert.playStore.mock'":
            //applicationIdSuffix ".mock"
            buildConfigField "String", "HOST", "\"127.0.0.1\""
            buildConfigField "String", "PORT", "\"8080\""
            // Multidex only needed for WireMock
            multiDexEnabled true
        }
        release {
            minifyEnabled false
            // ProGuard is off
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            // Keep actual ad unit Id secret by loading it from file outside of Git repo
            Properties props = new Properties()
            props.load(new FileInputStream("$project.rootDir/../../DbTradeAlert/project.properties"))
            buildConfigField "String", "AD_UNIT_ID", "\"${props.getProperty("ad_unit_id")}\""
        }
    }
    productFlavors {
        naked
        playStore {
            applicationId = "${android.defaultConfig.applicationId}.playStore"
        }
        withAds {
            applicationId = "${android.defaultConfig.applicationId}.withAds"
        }
    }
    sourceSets {
        playStore.java.srcDirs = ['src/common/java', 'src/playStore/java']
        withAds.java.srcDirs = ['src/common/java', 'src/withAds/java']
    }
    variantFilter { variant ->
        def names = variant.flavors*.name
        // Only ...-playStore-debug.apk needed of the mocks
        if (variant.buildType.name.equals("mock") && names.contains("playStore") == false) {
            variant.ignore = true
        }
    }
    packagingOptions {
        // Keep only 1 license from 3* com.fasterxml.jackson.core + 1* org.apache.httpcomponents
        // All 4 are Apache 2.0 licenses
        pickFirst  'META-INF/LICENSE'
    }
    testBuildType "mock"
}

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'
    mockCompile 'com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    }
    mockCompile 'com.android.support.test.espresso:espresso-contrib:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude group: 'com.android.support', module: 'design'
        exclude group: 'com.android.support', module: 'recyclerview-v7'
    }
    mockCompile 'com.github.tomakehurst:wiremock:2.2.1', {
        // Allows us to use the Android version of Apache httpclient instead
        exclude group: 'org.apache.httpcomponents', module: 'httpclient'
        // Resolves the Duplicate Class Exception
        // duplicate entry: org/objectweb/asm/AnnotationVisitor.class
        exclude group: 'org.ow2.asm', module: 'asm'
        // Fixes Warning conflict with Android's version of org.json
        // org.json:json:20090211 is ignored for debugAndroidTest as it may be conflicting
        // with the internal version provided by Android.
        exclude group: 'org.json', module: 'json'
    }
    // Android compatible version of Apache httpclient.
    mockCompile 'org.apache.httpcomponents:httpclient-android:4.3.5.1'
    mockCompile 'com.android.support:multidex:1.0.0'
}
// Remove apply plugin: 'com.google.gms.google-services' when building naked variants.
// Those have no google-services.json file and applying the plugin would produce an error:
// "File google-services.json is missing. The Google Services Plugin cannot function without it."
// Building the R class would fail too and produce "cannot resolve symbol R" errors.
apply plugin: 'com.google.gms.google-services'
// Non-naked variants on the other hand will throw an exception when started after being build
// without this line: "FirebaseApp with name [DEFAULT] doesn't exist"

A side effect of introducing a new build type is the explosion of variants from 6 to 9. As only the new playStore-mock variant is needed a variantFilter eliminates the others – see lines 56 to 62.

When adding a build type or flavor it’s a good idea to check all uses of the BuildConfig class to make shure they still work. All was good in this case and finally it’s time to work on the test itself.

The actual redirect is achieved by assigning a host and port number depending on the build type – see lines 17 to 19 and 28 to 29 in build.gradle above.

Note how all buildConfigField values are explicitly enclosed in quotation marks despite being declared as strings. Without that weird errors like “error: package download.finance does not exist” pop up because the values end up unquoted in BuildConfig.java.

The base URL specified in QuoteRefresherService.onHandleIntent() is then replaced with
String baseUrl = "http://" + BuildConfig.HOST + ":" + BuildConfig.PORT + "/d/quotes.csv";

For mock builds BuildConfig.HOST will be 127.0.0.1 and BuildConfig.PORT will be 8080 while any other build still uses the real world values.

The ScrollingExperienceTest class will look like this after integrating WireMock:

// ...
@LargeTest
@RunWith(AndroidJUnit4.class)
public class ScrollingExperienceTest {
    private static final String CLASS_NAME = "ScrollingExperienceTest";
    private String symbolParameterValue;

    @Rule
    public ActivityTestRule<WatchlistListActivity> mActivityTestRule
            = new ActivityTestRule<>(WatchlistListActivity.class);

    @Rule
    public WireMockRule wireMockRule = new WireMockRule(Integer.valueOf(BuildConfig.PORT));

    private void createTestData() {
        Log.v(CLASS_NAME, "createTestData(): start");
        String testDataString = readFileFromTestAssets("ch_securities.csv");
        Context targetContext = InstrumentationRegistry.getTargetContext();
        DbHelper dbHelper = new DbHelper(targetContext);
        // SQLite Ids start with 1; 1 == CH watchlist
        long watchlistId = 1;
        dbHelper.importTestSecurities(testDataString, watchlistId);
    } // createTestData()

    @After
    public void deleteTestData() {
        Log.v(CLASS_NAME, "@After - deleteTestData(): start");
        Context targetContext = InstrumentationRegistry.getTargetContext();
        DbHelper dbHelper = new DbHelper(targetContext);
        dbHelper.deleteTestSecurities();
        logRequests();
    } // deleteTestData()

    private void logRequests() {
        final String METHOD_NAME = "logRequests";
        // allServeEvents
        List<ServeEvent> allServeEvents = getAllServeEvents();
        Log.v(CLASS_NAME, METHOD_NAME + "(): allServeEvents.size() = " + allServeEvents.size());
        for (int i = 0; i < allServeEvents.size(); i++) {
            Log.v(CLASS_NAME, String.format("%s(): allServeEvents[%d].Url = %s",
                    METHOD_NAME, i, allServeEvents.get(i).getRequest().getUrl()));
        }
        // unmatchedRequests
        List<LoggedRequest> unmatchedRequests = findUnmatchedRequests();
        Log.v(CLASS_NAME, METHOD_NAME + "(): unmatchedRequests.size() = " + unmatchedRequests.size());
        for (int i = 0; i < unmatchedRequests.size(); i++) {
            Log.v(CLASS_NAME, String.format("%s(): unmatchedRequests[%d] = %s",
                    METHOD_NAME, i, unmatchedRequests.get(i).toString()));
        }
        // nearMisses
        // Currently WireMock reports expected requests as received, too
        // See https://github.com/tomakehurst/wiremock/issues/484
        List<NearMiss> nearMisses = findNearMissesForAllUnmatched();
        Log.v(CLASS_NAME, METHOD_NAME + "(): nearMisses.size() = " + nearMisses.size());
        for (int i = 0; i < nearMisses.size(); i++) { Log.v(CLASS_NAME, String.format("%s(): nearMisses[%d].Diff = %s", METHOD_NAME, i, nearMisses.get(i).getDiff())); } } // logRequests() @Before public void prepareTest() { Log.v(CLASS_NAME, "@Before - prepareTest(): start"); try { // Must match imported symbols from ch_securities.csv + sample symbols from install: symbolParameterValue = URLEncoder.encode( "ABBN.VX+BAYN.DE+CFR.VX+NESN.VX+NOVN.VX+ROG.VX+SIE.DE+SYNN.VX+UBSG.VX+ZURN.VX", "utf-8"); } catch (UnsupportedEncodingException e) { PlayStoreHelper.logError(e); } createTestData(); setupWireMock(); } // prepareTest() private String readFileFromTestAssets(String fileName) { String fileContent = ""; Context testContext = InstrumentationRegistry.getContext(); try { InputStream testDataStream = testContext.getResources().getAssets().open(fileName); byte[] testData = new byte[testDataStream.available()]; int bytesRead = testDataStream.read(testData); fileContent = new String(testData); } catch (IOException e) { PlayStoreHelper.logError(e); } return fileContent; } // readFileFromTestAssets() private void setupWireMock() { Log.v(CLASS_NAME, "setupWireMock(): start"); String quotesCsv = readFileFromTestAssets("quotes.csv"); wireMockRule.stubFor(get(urlPathMatching(".*")) .withQueryParam("f", equalTo(DbHelper.QuoteDownloadFormatParameter)) // Currently WireMock fails to match URL encoded parameters // See https://github.com/tomakehurst/wiremock/issues/515 //.withQueryParam("s", equalTo(symbolParameterValue)) .willReturn(aResponse() .withStatus(HttpURLConnection.HTTP_OK) .withBody(quotesCsv))); } // setupWireMock() @Test public void scrollingExperienceTest() throws VerificationException { final String METHOD_NAME = "scrollingExperienceTest"; Log.v(CLASS_NAME, METHOD_NAME + "(): start"); // Make added securities show up in watchlist: Log.v(CLASS_NAME, METHOD_NAME + "(): tapping Refresh"); ViewInteraction actionMenuItemView = onView( allOf(withId(R.id.action_refresh), withContentDescription("Refresh"), isDisplayed())); actionMenuItemView.perform(click()); // Checking request was correct helps to track down reasons for failed tests verify(1, getRequestedFor(urlPathMatching(".*")) .withQueryParam("f", equalTo(DbHelper.QuoteDownloadFormatParameter)) // Currently WireMock fails to match URL encoded parameters // See https://github.com/tomakehurst/wiremock/issues/515 //.withQueryParam("s", equalTo(symbolParameterValue)) ); Log.v(CLASS_NAME, METHOD_NAME + "(): verified correctness of request"); // Scroll items to trigger creation of new items // Visually check if scrolling stutters -> test failed
        ViewInteraction recyclerView = onView(
                allOf(withId(R.id.list),
                        withParent(allOf(withId(R.id.container),
                                withParent(withId(R.id.main_content)))),
                        isDisplayed()));
        for (int i = 0; i < 8; i++) {
            Log.v(CLASS_NAME, METHOD_NAME + "(): swipeUp" + i);
            recyclerView.perform(actionOnItemAtPosition(i, swipeUp()));
        }
        Log.v(CLASS_NAME, METHOD_NAME + "(): the End");
    } // scrollingExperienceTest()
} // class ScrollingExperienceTest

The first step is to create an instance of the WireMockRule class which takes care of starting and stopping the server as well as resetting it between tests. In this case the creation order of the Rule instances doesnt matter but if there was a dependency you’d add a RuleChain.

In addition to load more symbols before starting the test now we have to tell WireMock what result to serve for which request. Therefore a new prepareTest() method gets the @Before annotation. Before calling createTestData() this method creates the symbolParameterValue which WireMock will have to expect. In this case it’s important to URL encode this value as the “+” characters would represent spaces.

Finally prepareTest() calls setupWireMock(). Like createTestData() this method needs to read a file from the test APK’s assets – the previously added quotes.csv. For that reason readFileFromTestAssets() was factored out of createTestData().

The next step is to define a stub – what happens when. In this case we don’t care about the URL but for the two parameters because they define what columns and rows to return. If the parameters match WireMock should return the contents of quotes.csv and a response code of HttpURLConnection.HTTP_OK because QuoteRefresherService.downloadQuotes() will check that response code. Because of a bug WireMock currently fails to verify URL encoded parameters so only the format parameter is checked.

Finally everything is ready to fix the actual test. In scrollingExperienceTest() the call to Thread.sleep() is gone and a verify() has taken its place. That method basically checks if the stub defined in setupWireMock() got a single exact request – again accounting for the current bug with URL encoded parameters.

In addition to that logRequests() gives you an idea what requests WireMock received and how it triaged them. Please note that due to another bug in WireMock currently NearMiss.getDiff() will report the expected request also as the received request.

After running the test the data it created is deleted like before.

A side note for those with multiple tests in a test class. Like rules the tests are executed in no specific order. That’s because both are identified by reflection which doesn’t make any promises about order. For tests one might actually call that a feature because it gets in the way of tests that depend on each other which is usually a bad idea. But if you need it JUnit from version 4.11 on provides a @FixMethodOrder to run tests for example ordered by name.

5.6 Run the Test in Firebase Test Lab for Android

Basically everything up to now has been a preparation for this step. Firebase Test Lab for Android provides those low end devices on which scrolling may stutter. And yes, those are physical devices.

As using Firebase Test Lab for Android isn’t free you’ll have to give up the free Spark plan. I chose the pay-as-you-go Blaze plan.

To upgrade to the Blaze plan:

  1. Navigate to the Firebase console at https://console.firebase.google.com and log in
  2. Select your project with the respective app
  3. In the navigation pane select Test Lab
  4. On the next page click Upgrade
  5. On the new overlay click Select Plan in the Blaze column
  6. Yet another overlay informs you about upgrading the project – click Continue
  7. Accept or change your country which determines the currency
  8. It seems you cannot use the private account created in the beginning but have to set up a business account. Fill in the missing information like the business’ name, phone number, and credit card details and click Confirm Purchase.
  9. On the confirmation overlay click Got It
  10. The page now shows a Run Your First Test button

Google advises to add its Test Lab screenshot library to the app’s test project. That enables you to initiate screenshots from test code. As the scrolling experience test doesn’t benefit from screenshots and I skipped that step.

A very important part of setting up the test is to select the devices and Android versions to test on. Usually you want to identify those most popular with your target audience and some extremes. The scrolling experience test just needs some low end devices and can use whatever Andoid versions they support.

To start the first test:

  1. Navigate to your project in the Test Lab as before
  2. Click the Run Your First Test button
  3. On the Choose Test Type page select Instrumentation Test and click Continue
  4. In the Select App step provide the app and the test APK. These are independent from any APKs you uploaded already. I provided DbTradeAlert-1.1-playStore-mock.apk and DbTradeAlert-1.1-playStore-mock-androidTest.apk from …\DbTradeAlert\app\build\outputs\apk. Click Continue.
  5. In the Select Dimensions step you specify the devices, API levels, orientations, and locales on which to run the tests. The button at the bottom right shows number of permutations this creates – 8 with the default options.
  6. Physical devices: I choose LG Nexus 4 and Motorola Moto G (2nd gen) as they have a low Basemark OS II score
  7. API levels: the selected devices only support API levels 19 and 22 (only Nexus 4)
  8. Orientation: I left both checked but there shouldn’t be much of a difference
  9. Locales: only testing on en_US is OK as the app isn’t localized
  10. Advanced option: timeout of 5 minutes is OK
  11. Finally click Start Tests

All tests on API level 22 (Lollipop) passed while those on API level 19 (Kitkat) failed with “java.lang.NoClassDefFoundError: com.google.firebase.FirebaseOptions”. Stackoverflow has dozends of threads about this starting in May 2016 when Google renamed a lot of stuff to “Firebase”. They said on Stackoverflow it was a bug on their side but that they have fixed it since.

As I don’t own a pre-Lollipop device and don’t think the scrolling experience depends on the API level anyway I didn’t dig any further.

In my case it took about 5 minutes to complete the 6 tests. Surprisingly each test claims that it took about 2.5 minutes while they complete in seconds on my phone. As Google charges USD 5 per hour for physical devices running the tests cost about USD 1.25. Still a bargain compared to maintaining those devices yourself but definitely more than expected.

Each test result links to details which for example include a video. The videos are about 10 to 15 seconds long with about 5 seconds showing the actual test – like on my physical device. As Google claims not to bill for the setup time I have no idea how those 2.5 minutes come about.

Unfortunately the video stuttered itself – no wonder with only 11 frames per second. To be sure I ran the test again on their Nexus 5 – same stutter. As one cannot configure devices to show screen update problems – or anything else from developer options – Firebase Test Lab for Android isn’t suited for this kind of test.

Another interesting outcome was the test failing on API level 23:
android.support.test.espresso.NoActivityResumedException: No activities in stage RESUMED. Did you forget to launch the activity. (test.getActivity() or similar)?

Watching the video showed that the app was waiting for input on the “Ignore battery optimizations?” dialog. Easily fixed by skipping the call to WatchlistListActivity.ensureExemptionFromBatteryOptimizations() if BuildConfig.BUILD_TYPE.equals(“mock”).

That concludes the first automated test deemed interesting. On to the next …

6. Running a Firebase Test Lab for Android Robo Test

The Firebase Test Lab for Android can do more than just run tests – it can come up with its own. Those are called Robo tests and not to be confused with the Monkey tests provided by the SDK. The difference is that Robo tests use a more systematic approach and even create a map of the app.

To start a Robo test:

  1. Navigate to your project in the Test Lab as before
  2. Click the Run a Test button
  3. Select Run a Robo test
  4. Provide an APK, select test dimensions, and start the test as before

After the tests finish you’ll find details about their outcome as with instrumented tests. But now you get additional screenshots and an activity map.

Excerpt from the activity map

Excerpt from the activity map

That activity map shows the paths through the app as taken by a Robo test. And while that’s a neat feature it also shows the limits of Robo tests – they just tap a button but never enter or change text. Robo tests can still be helpful at early stages of an app where you just want to make shure it doesn’t explode when simply showing a screen on some devices or Android versions. Note that the test was listed as taking 8 minutes and another run took 11 minutes – that’s USD 1.60 spent.

7. Test Connection Fault Handling

Until now the app hasn’t really been tested with faulty networks simply because fault simulation wasn’t available. Fortunately WireMock has this capability. It can return HTTP error codes, return HTTP OK but garbage or an empty response, or take forever to answer at all. As the test involves a tap of the Refresh buttton it will be another instrumented test:

// ...

@LargeTest
@RunWith(AndroidJUnit4.class)
public class NetworkFault1Test {
    private static final String CLASS_NAME = "NetworkFault1Test";

    @Rule
    public ActivityTestRule<WatchlistListActivity> mActivityTestRule
            = new ActivityTestRule<>(WatchlistListActivity.class);

    @Rule
    public WireMockRule wireMockRule = new WireMockRule(Integer.valueOf(BuildConfig.PORT));

    @Test
    public void networkFaultTest() throws VerificationException {
        final String METHOD_NAME = "networkFaultTest";
        Log.v(CLASS_NAME, METHOD_NAME + "(): start");
        ViewInteraction actionMenuItemView = onView(
                allOf(withId(R.id.action_refresh), withContentDescription("Refresh"), isDisplayed()));
        Log.v(CLASS_NAME, METHOD_NAME + "(): Refresh -> HttpURLConnection.HTTP_INTERNAL_ERROR");
        actionMenuItemView.perform(click());
        // In theory one can verify user friendly text + error code in toast. In practice it's
        // nearly impossible to get the timing right - toasts show with a delay and fade away soon.
//        String toastText
//                = QuoteRefresherService.QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA
//                + "download failed (response code " + HttpURLConnection.HTTP_INTERNAL_ERROR + ")!";
//        onView(withText(toastText))
//                .inRoot(
//                        withDecorView(
//                                not(mActivityTestRule.getActivity().getWindow().getDecorView())
//                        )
//                )
//                .check(matches(isDisplayed()));

        Log.v(CLASS_NAME, METHOD_NAME + "(): the End");
    } // networkFaultTest()

    @Before
    public void setupWireMock() {
        Log.v(CLASS_NAME, "@Before - setupWireMock(): start");
        wireMockRule.stubFor(get(urlPathMatching(".*"))
                .willReturn(aResponse()
                        .withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
                )
        );
    } // setupWireMock()

} // class NetworkFault1Test

The code is very similar to the ScrollingExperienceTest class. But this time WireMock will simply return HttpURLConnection.HTTP_INTERNAL_ERROR – 500. So no need for adding sample data here.

The resulting toast will show “Error: download failed (response code 500)!” and could theoretically be verified with Espresso. But getting the timing right will be a problem as the toast shows with a slight delay and fades away soon.

Therefore I opted for a manual approach. Remember that the point of this test is to ensure the app handles network faults logically. Whether a toast informs the user of those faults is a separate decision.

In this case I put a break point in QuoteRefresherService.downloadQuotes() and one on the last line of NetworkFault1Test.networkFaultTest(). The second breakpoint is important because the app will be terminated as soon as the test finishes and that will be before you had any chance to single step through QuoteRefresherService.downloadQuotes().

The second test is very similar but WireMock will take 20 seconds to answer – longer than the timeouts in QuoteRefresherService.downloadQuotes().

// ...

@LargeTest
@RunWith(AndroidJUnit4.class)
public class NetworkFault2Test {
    // ...

    @Before
    public void setupWireMock() {
        Log.v(CLASS_NAME, "@Before - setupWireMock(): start");
        wireMockRule.stubFor(get(urlPathMatching(".*"))
                .willReturn(aResponse()
                        .withStatus(HttpURLConnection.HTTP_OK)
                        // Must be longer than timeouts in QuoteRefresherService.downloadQuotes()
                        .withFixedDelay(20000)
                )
        );
    } // setupWireMock()

    // ...
}

This will result in another toast with a slightly different message of “Error: connection timed out!”.

And yes, NetworkFault2Test is a copy of NetworkFault1Test. That comes from the necessity to have separate WireMock instances as the non-invasive approach to testing gives no chance to add URL parameters or anything else to control which stub gets used.

A more elegant approach would be to use WireMock’s stateful behavior. While I scrapped that idea because the “second breakpoint” mentioned above got overrun it’s an interesting solution for similar problems:

// ...

@LargeTest
@RunWith(AndroidJUnit4.class)
public class NetworkFaultTest {
    // ...

    @Before
    public void setupWireMock() {
        Log.v(CLASS_NAME, "@Before - setupWireMock(): start");
        // Internal server error scenario
        wireMockRule.stubFor(get(urlPathMatching(".*"))
                .inScenario(CLASS_NAME)
                .whenScenarioStateIs(STARTED)
                .willReturn(aResponse()
                        .withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
                )
                .willSetStateTo("Timeout scenario")
        );
        // Timeout scenario
        wireMockRule.stubFor(get(urlPathMatching(".*"))
                .inScenario(CLASS_NAME)
                .whenScenarioStateIs("Timeout scenario")
                .willReturn(aResponse()
                        .withStatus(HttpURLConnection.HTTP_OK)
                        // Must be longer than timeouts in QuoteRefresherService.downloadQuotes()
                        .withFixedDelay(20000)
                )
                .willSetStateTo("Garbage data scenario")
        );
        // Garbage data scenario
        wireMockRule.stubFor(get(urlPathMatching(".*"))
                .inScenario(CLASS_NAME)
                .whenScenarioStateIs("Garbage data scenario")
                .willReturn(aResponse()
                        //.withStatus(HttpURLConnection.HTTP_OK)
                        // Returns HttpURLConnection.HTTP_OK + garbage data
                        .withFault(Fault.MALFORMED_RESPONSE_CHUNK)
                )
        );
    } // setupWireMock()

    // ...
}

WireMock offers stateful behavior which you are free to configure with scenarios and their states. Each scenario has an initial state of STARTED and defines the next state by calling willSetStateTo(). Subsequent states set their name with whenScenarioStateIs(). This way setupWireMock() creates three consecutive states allowing to control which stub gets used without changing the URL. While it didn’t work out here this would be handy for example to test the calculation behind trailing targets (trailing stop loss) as that would require a specific order of quotes.

One could also use an abstract base class but I found documenting how the test works and getting at the stub configuration for meaningful output more cumbersome than living with copied code.

The last test uses one of WireMock’s special faults: return HTTP OK but garbage data.

// ...

@LargeTest
@RunWith(AndroidJUnit4.class)
public class NetworkFault3Test {
    // ...

    @Before
    public void setupWireMock() {
        Log.v(CLASS_NAME, "@Before - setupWireMock(): start");
        wireMockRule.stubFor(get(urlPathMatching(".*"))
                .willReturn(aResponse()
                        // Returns HttpURLConnection.HTTP_OK + garbage data
                        .withFault(Fault.MALFORMED_RESPONSE_CHUNK)
                )
        );
    } // setupWireMock()

    // ...
}

This test actually surfaced a bug and now QuoteRefresherService.downloadQuotes() handles IOExceptions:

// ...

public class QuoteRefresherService extends IntentService {
    // ...

    private String downloadQuotes(String urlString) throws IOException {
        String result = "";
        InputStream inputStream = null;
        try {
            URL url = new URL(urlString);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setReadTimeout(10000 /* milliseconds */);
            conn.setConnectTimeout(15000 /* milliseconds */);
            conn.setRequestMethod("GET");
            conn.setDoInput(true);
            int responseCode = -1;
            try {
                // Starts the query
                conn.connect();
                responseCode = conn.getResponseCode();
            } catch (SocketTimeoutException e) {
                sendLocalBroadcast(
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "connection timed out!");
                Log.d(CLASS_NAME,
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "connection timed out!");
            }
            if (responseCode == HttpURLConnection.HTTP_OK) {
                inputStream = conn.getInputStream();
                try {
                    result = getStringFromStream(inputStream);
                } catch (IOException x) {
                    sendLocalBroadcast(QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA
                            + "could not read response!");
                    PlayStoreHelper.logError(CLASS_NAME,
                            QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA
                                    + "could not read response!");
                }
                Log.d(CLASS_NAME, "downloadQuotes(): got " + result.length() + " characters");
            } else {
                sendLocalBroadcast(QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA
                        + "download failed (response code " + responseCode + ")!");
                PlayStoreHelper.logError(CLASS_NAME,
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA
                                + "download failed (response code " + responseCode + ")!");
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
        }
        return result;
    } // downloadQuotes()

    // ...
}

That was the third planned test and now it’s time for the last …

8. Run a Monkey Test

A Monkey test exercises your app like a monkey – it does things at random. Fortunately Android SDK includes a pretty smart monkey:

  • It can restrict its input to specified APKs
  • It can favor specified actions
  • It logs what it does
  • It will stop when the app crashes or produces an ANR dialog
  • It remembers exactly what it did

Android’s monkey will not only send input to the app but also to the system. For example it can turn off WiFi or change the volume. But while that kind of testing is valuable I don’t want it to happen on my phone but on an emulator.

This line in Android Studio’s Terminal window lets the monkey loose:
C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell monkey -p de.dbremes.dbtradealert -v 5000 > %TMP%\monkey_test.txt

In this case the monkey is ordered to send 5000 inputs to package de.dbremes.dbtradealert and it’s actually smart enough to start the app. The resulting log will be in C:\Users\<AccountName>\AppData\Local\Temp\monkey_test.txt and looks like this:

:Monkey: seed=1478409080850 count=5000
:AllowPackage: de.dbremes.dbtradealert
:IncludeCategory: android.intent.category.LAUNCHER
:IncludeCategory: android.intent.category.MONKEY
// Event percentages:
//   0: 15.0%
//   1: 10.0%
//   2: 2.0%
//   3: 15.0%
//   4: -0.0%
//   5: -0.0%
//   6: 25.0%
//   7: 15.0%
//   8: 2.0%
//   9: 2.0%
//   10: 1.0%
//   11: 13.0%

:Switch: #Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;launchFlags=0x10200000;component=de.dbremes.dbtradealert/.WatchlistListActivity;end

    // Allowing start of Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=de.dbremes.dbtradealert/.WatchlistListActivity } in package de.dbremes.dbtradealert

:Sending Trackball (ACTION_MOVE): 0:(-2.0,-2.0)
:Sending Flip keyboardOpen=false
:Sending Touch (ACTION_DOWN): 0:(145.0,560.0)
:Sending Touch (ACTION_UP): 0:(145.06223,548.3796)
:Sending Trackball (ACTION_MOVE): 0:(0.0,-2.0)
:Sending Touch (ACTION_DOWN): 0:(699.0,577.0)
:Sending Touch (ACTION_UP): 0:(701.53815,585.20026)

    // Rejecting start of Intent { act=android.intent.action.MAIN cat=[android.intent.category.HOME] cmp=com.android.launcher3/.Launcher } in package com.android.launcher3

:Sending Trackball (ACTION_MOVE): 0:(-3.0,0.0)

...

Line 1 shows the seed value used. That’s important to reproduce the exact same sequence of inputs – just add “-s <SeedValue>” to the command. Of course the app needs to be in the same starting state too.

Lines 2 to 4 list the packages and categories from which the monkey is allowed to access activities. That’s controlled by the -c and -p command line switches.

Lines 6 to 17 show how the input is composed which is controlled by the –pct – that’s 2 hyphens – command line switches.

Lines 19 to 21 show the successful switch to an activity.

Lines 23 to 29 show various input events that the monkey generated. ACTION_DOWN and ACTION_UP usually happen at diffferent coordinates so that’s actually a swipe.

Line 31 shows a switch to an activity that was rejected because that activity didn’t match the filter created by the -c and -p command line switches.

Surprisingly the monkey has no direct means of typing text. That can still happen when it manages to hit the on screen keyboard.

If you want more control than the monkey provides you can use the monkeyrunner tool. Despite its name it has nothing to do with the test monkey but is an API for Python programs to control devices and emulators as well as the apps on them.

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Automated UI Tests – Part 2: Testing with Espresso

First post in this series: Introduction to DbTradeAlert

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

This post has two additional requirements in regards to the things set-up so far:

  • Android Studio 2.2 or later is needed for the Espresso test recorder
  • Firebase Blaze plan is needed for access to Firebase Test Lab for Android – the post describes the upgrade process

Also note that this Espresso test will be somewhat special as it requires a human to decide whether the scrolling was smooth. Espresso is capable of recording a fully automated UI test by just clicking on a screen element to add an assert about its contents.


5. Automated Scrolling Experience Test

The scrolling experience with larger watchlists on low end devices was identified to be a concern due to the lack of physical devices to test on. Firebase Test Lab for Android provides those to run for example an Espresso test.

5.1 Record an Espresso Test

Recording an Espresso test is not as straight forward as one would think because the test recorder is still in beta. I choose to list every error message thrown at me in the process because those problems will be fairly common.

Let’s record a swipe test first:

  1. In Android Studio select Run | Record Espresso Test
  2. In the Select Deploymet Target window select your device or emulator and click OK
  3. Wait until the Record Your Test window shows up and the app is ready
  4. Swipe up – see comments if that doesn’t get recorded
  5. In the Record Your Test window click Complete Recording
  6. In the following dialog enter a class name for the test – “ScrollingExperienceTest” in my case – and click Save
  7. Accept the offer to add missing dependencies

That should leave you with an automated UI test ready to replay. But as Espresso test recorder is still beta it leaves some manual work for you.

First, make the ScrollingExperienceTest class compile:

  • Fix the R class access – replace “de.dbremes.dbtradealert.withAds.R.” with “R.” in my case
  • Let Andoid Studio add a static import for matchers like withId()

More importantly the swipe wasn’t recorded because while Espresso supports swipes its test recorder doesn’t as of version 2.2.2. To have it at least build the infrastructure I long-tapped a report which got recorded properly. Then I just coded the swipe manually and added the necessary static imports:

package de.dbremes.dbtradealert;

import android.support.test.espresso.ViewInteraction;
import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;
import android.test.suitebuilder.annotation.LargeTest;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import static android.support.test.espresso.Espresso.onView;
import static android.support.test.espresso.action.ViewActions.click;
import static android.support.test.espresso.action.ViewActions.swipeLeft;
import static android.support.test.espresso.action.ViewActions.swipeUp;
import static android.support.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition;
import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
import static android.support.test.espresso.matcher.ViewMatchers.withId;
import static android.support.test.espresso.matcher.ViewMatchers.withParent;
import static android.support.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;

@LargeTest
@RunWith(AndroidJUnit4.class)
public class ScrollingExperienceTest {

    @Rule
    public ActivityTestRule<WatchlistListActivity> mActivityTestRule = new ActivityTestRule<>(WatchlistListActivity.class);

    @Test
    public void scrollingExperienceTest() {
        ViewInteraction recyclerView = onView(
                allOf(withId(R.id.list),
                        withParent(allOf(withId(R.id.container),
                                withParent(withId(R.id.main_content)))),
                        isDisplayed()));

        recyclerView.perform(actionOnItemAtPosition(0, swipeUp()));

        // Freezing the app to see the swipe
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

To run the test:

  1. In the test method’s context menu select Run scrollingExperienceTest
  2. In the Select Deploymet Target window select your device or emulator and click OK

The test will probably fail with this error message:
PerformException: Error performing 'fast swipe' on view. Animations or transitions are enabled on the target device.

That’s because Espresso can only detect whether the app itself is busy but not when animations or transitions are running. That may lead to tests failing for the wrong reason and Espresso prevents that. Disable animations on the device for Espresso to work:

  1. Go to Settings | Developer options
  2. Disable 3 settings:
    1. Window animation scale
    2. Transition animation scale
    3. Animator duration scale

Running the test again produced this error on the withAds flavor:
java.lang.RuntimeException: Action will not be performed because the target view does not match one or more of the following constraints:
at least 90 percent of the view's area is displayed to the user.
Target view: "RecyclerView ...

That’s because the advert covers part of the RecyclerView. So let’s ty the playStore flavor.

Running the test on the playStore flavor produced this error – buried deep in the stack trace:
android.support.test.espresso.InjectEventSecurityException: java.lang.SecurityException: Injecting to another application requires INJECT_EVENTS permission

Hmm, head-scratching after this one. But a simple solution after finally checking the originally recorded long-tap – perform the action on an item and not on the RecyclerView itself. Just replace
recyclerView.perform(swipeUp()) with
recyclerView.perform(actionOnItemAtPosition(0, swipeUp()))

Adding a Thread.sleep(1000) permits to see if the swipeUp() actually moves the watchlist.

5.2 Generate Test Data

The app starts with two watchlists each containing two securities. As the idea is to force the creation of new reports while scrolling the app needs more securities. Logging will be added to make shure the reports weren’t created in advance.

To add the required securities to the watchlist the test will read them from a .csv file. The DbHelper class has already code to parse .csv data and the infrastructure will come in handy later when using WireMock to provide canned quotes.

5.2.1 Add a CSV File to the Test

The .csv file is only used for testing and therefore shouldn’t be included in the app itself. Instrumented tests like Espresso tests actually get compiled into a second apk that runs in the same process as the app’s APK. You’ll see that in Andoid Studio’s test window:
$ adb shell pm install -r "/data/local/tmp/de.dbremes.dbtradealert.playStore"
...
$ adb shell pm install -r "/data/local/tmp/de.dbremes.dbtradealert.playStore.test"

The first step is to create an assets folder under “<ProjectPath>\app\src\androidTest” with Explorer as Android Studio doesn’t support that.

After that use Android Studio to create the .csv file:

  1. In the “app” context menu select New | File
  2. In the Choose Destination Directory window select “…\app\src\androidTest\assets” and click OK
  3. In the New File window enter “ch_securities.csv” and click OK
  4. In the Register New File Type window accept the default of opening Text files in Android Studio and click OK

The contents can be added in any text editor:

"ABBN.VX"
"CFR.VX"
"ROG.VX"
"SYNN.VX"
"UBSG.VX"
"ZURN.VX"

5.2.2 Import CSV Data

Importing CSV data with DbHelper is straight forward:

// ...
public class DbHelper extends SQLiteOpenHelper {
    // ...

    private String[][] convertCsvToStringArrays(String csvData) {
        String[][] result = null;
        String[] csvRows = csvData.split("\r?\n|\r");
        int rowCount = csvRows.length;
        int fieldCount = csvRows[0].split(",").length;
        result = new String[rowCount][fieldCount];
        String csvRow = null;
        for (int rowIndex = 0; rowIndex < csvRows.length; rowIndex++) {
            csvRow = csvRows[rowIndex];
            result[rowIndex] = csvRow.split(",");
            // Delete any surrounding quotes
            for (int fieldIndex = 0; fieldIndex < result[rowIndex].length; fieldIndex++) {
                result[rowIndex][fieldIndex] = result[rowIndex][fieldIndex].replace("\"", "");
            }
        }
        return result;
    } // convertCsvToStringArrays()

    // ...

    public void importTestSecurities(String csvData, long watchlistId) {
        final String METHOD_NAME = "importTestSecurities";
        final String addSecurityToWatchlistSql = "INSERT INTO " + SecuritiesInWatchlists.TABLE
                + "(" + SecuritiesInWatchlists.SECURITY_ID
                + "," + SecuritiesInWatchlists.WATCHLIST_ID + ") VALUES(?,?)";
        final String insertSecuritySql = "INSERT INTO " + Security.TABLE
                + "(" + Security.SYMBOL + ") VALUES(?)";
        String[][] csvArrays = convertCsvToStringArrays(csvData);
        SQLiteDatabase db = this.getWritableDatabase();
        try {
            db.beginTransaction();
            SQLiteStatement addSecurityToWatchlistStatement
                    = db.compileStatement(addSecurityToWatchlistSql);
            SQLiteStatement insertSecurityStatement = db.compileStatement(insertSecuritySql);
            for (int rowIndex = 0; rowIndex < csvArrays.length; rowIndex++) {
                // 1-based index for bindString()!
                insertSecurityStatement.bindString(1, csvArrays[rowIndex][0]);
                long securityId = insertSecurityStatement.executeInsert();
                addSecurityToWatchlistStatement.bindLong(1, securityId);
                addSecurityToWatchlistStatement.bindLong(2, watchlistId);
                addSecurityToWatchlistStatement.executeInsert();
            }
            db.setTransactionSuccessful();
            Log.d(CLASS_NAME, METHOD_NAME + "(): success!");
        } finally {
            db.endTransaction();
        }
    } // importTestSecurities()

    // ...
}

The new importTestSecurities() method has two duties: parse the csv data and insert the resulting values into the securites table which includes adding them to a watchlist.

Parsing the csv format was already implemented in updateOrCreateQuotes() and got factored out into convertCsvToStringArrays() which returns a two dimensional string array.

Inserting the values happens a bit more performance-conscious here than in updateOrCreateQuotes(). As before the main booster is to wrap the inserts in a transaction – that cuts 99% off non-transactional execution time easily because it saves SQLite from needing to wrap each in its own transaction.

What’s new is the call to SQLiteDatabase.compileStatement(). This way preparation steps like analyzing the SQL are only executed once which can cut the remaining execution time in half. An SQLiteStatement absorbs parameter values with type specific methods like SQLiteStatement.bindString() – note the 1-based index and of course you have to clear a parameter value if you don’t provide a new one. The execution method to call – SQLiteStatement.executeInsert() in this case – is specific to the type of SQL statement. Unlike other databases SQLite provides no way to unprepare a statement to free its resources.

To be honest I wouldn’t use prepared statements for the few records this test inserts but just wanted to try them with SQLite. If on the other hand you have to insert tens of thousands of records it may be worth doing that in chunks to prevent memory issues.

SQlite actually supports bulk imports – loading data directly from a file – with “.import <FILE> <TABLE>” but only at the command line interface. And finally starting with version 3.7.11 it provides this insert syntax:
INSERT INTO 'tablename' ('column1', 'column2') VALUES
('data1', 'data2'),
('data3', 'data4'),
('data5', 'data6'),
('data7', 'data8');

Note that this is only syntactical sugar without performance benefits and certainly non-standard SQL.

5.2.3 Start Data Import Before the Test

To start the data import ScrollingExperienceTest gets a new method:

// ...
@LargeTest
@RunWith(AndroidJUnit4.class)
public class ScrollingExperienceTest {
    // ...

    @Before
    public void createTestData() {
        String testDataString = "";
        Context testContext = InstrumentationRegistry.getContext();
        try {
            InputStream testDataStream
                    = testContext.getResources().getAssets().open("ch_securities.csv");
            byte[] testData = new byte[testDataStream.available()];
            testDataStream.read(testData);
            testDataString = new String(testData);
        } catch (IOException e) {
            PlayStoreHelper.logError(e);
        }
        Context targetContext = InstrumentationRegistry.getTargetContext();
        DbHelper dbHelper = new DbHelper(targetContext);
        // SQLite Ids start with 1
        long watchlistId = 1;
        Log.d(CLASS_NAME, "createTestData(): calling importTestSecurities()");
        dbHelper.importTestSecurities(testDataString, watchlistId);
    } // createTestData()

    // ...
}

The @Before annotation tells JUnit to execute createTestData() before running any test. The method reads the .csv file from the test package’s resources – large files should be handled in chunks. Also note that createTestData() uses two different contexts:

  • InstrumentationRegistry.getContext() provides the test app context to access the file in the respective package
  • InstrumentationRegistry.getTargetContext() provides the app context to create a DbHelper instance

Another thing to note is that SQLite’s automatic identifiers are 1-based.

5.3 Delete Test Data After the Test

Every test should leave the app in the same state it was before. That’s the only way to ensure a defined starting state when running multiple tests.

The easiest way is to wrap the whole test beginning with test data creation in a transaction and roll it back after test completion. Of course this can lock surprisingly large parts of the database and should be used with caution.

SQLite doesn’t support nested transactions out of the box but uses savepoint and release commands. The SQLite wrapper for Android maps that to nested transactions though.

But that still doesn’t work for DbTradeAlert’s stateless DbHelper class. The problem is that nested transactions in SQLite have to use the same SQLiteDatabase instance. As DbHelper’s methods create a new SQLiteDatabase instance whenever they need one tests cannot use a transaction without changing DbHelper’s API. Trying it with SQLiteDatabase.beginTransaction() will result in a SQLiteDatabaseLockedException and if you use SQLiteDatabase.beginTransactionNonExclusive() you’ll get a IllegalStateException later – “no transaction pending”.

So DbHelper gets a new method instead:

// ...
public class DbHelper extends SQLiteOpenHelper {
    // ...

    /**
     * Caution: deleteTestSecurities() deletes any securities with Id > 4!
     * Logic as deleteSecurity()
     */
    public void deleteTestSecurities() {
        final String methodName = "deleteTestSecurities";
        Log.v(CLASS_NAME, String.format("%s(): securityId > 4", methodName));
        String[] whereArgs = new String[]{String.valueOf(4)};
        int deleteResult = 0;
        SQLiteDatabase db = getWritableDatabase();
        try {
            db.beginTransaction();
            // Delete security's quotes
            deleteResult = db.delete(Quote.TABLE, Quote.SECURITY_ID + " > ?", whereArgs);
            Log.v(CLASS_NAME, String.format(DELETE_RESULT_FORMAT, methodName,
                    Quote.TABLE, deleteResult));
            // Delete security's existing connections to watchlists
            deleteResult = db.delete(SecuritiesInWatchlists.TABLE,
                    SecuritiesInWatchlists.SECURITY_ID + " > ?", whereArgs);
            Log.v(CLASS_NAME, String.format(DELETE_RESULT_FORMAT, methodName,
                    SecuritiesInWatchlists.TABLE, deleteResult));
            // Delete security
            deleteResult = db.delete(Security.TABLE, Security.ID + " > ?", whereArgs);
            Log.v(CLASS_NAME, String.format(DELETE_RESULT_FORMAT, methodName,
                    Security.TABLE, deleteResult));
            db.setTransactionSuccessful();
            Log.d(CLASS_NAME, methodName + "(): success!");
        } finally {
            db.endTransaction();
        }
    } // deleteTestSecurities()
 
   // ...
}

This new method called deleteTestSecurities() implements the same logic as deleteSecurity() but does so for all securities with Id > 4. This way you can repeat the test without causing a UNIQUE constraint violation when adding the same symbols again. As the tables were created with INTEGER PRIMARY KEY columns and not as AUTOINCREMENT the Ids get recycled.

This method is called from ScrollingExperienceTest:

// ...
@LargeTest
@RunWith(AndroidJUnit4.class)
public class ScrollingExperienceTest {
    // ...

     @After
    public void deleteTestData() {
        Context targetContext = InstrumentationRegistry.getTargetContext();
        DbHelper dbHelper = new DbHelper(targetContext);
        dbHelper.deleteTestSecurities();
    } // deleteTestData()

    // ...
}

As the method is annotated with @After it gets called automatically after the tests have completed.

5.4 Run the Test

Actual test methods like scrollingExperienceTest() are marked with the @Test annotation. In this case the generated method needs an overhaul before it can do anything useful:

// ...
@LargeTest
@RunWith(AndroidJUnit4.class)
public class ScrollingExperienceTest {
    // ...

    @Test
    public void scrollingExperienceTest() {
        Log.d(CLASS_NAME, "scrollingExperienceTest(): start");
        // Make added securities show up in watchlist:
        Log.d(CLASS_NAME, "scrollingExperienceTest(): tapping Refresh");
        ViewInteraction actionMenuItemView = onView(
                allOf(withId(R.id.action_refresh), withContentDescription("Refresh"), isDisplayed()));
        actionMenuItemView.perform(click());

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        ViewInteraction recyclerView = onView(
                allOf(withId(R.id.list),
                        withParent(allOf(withId(R.id.container),
                                withParent(withId(R.id.main_content)))),
                        isDisplayed()));
        for (int i = 0; i < 8; i++) {
            Log.d(CLASS_NAME, "scrollingExperienceTest(): swipeUp" + i);
            recyclerView.perform(actionOnItemAtPosition(i, swipeUp()));
        }
        Log.d(CLASS_NAME, "scrollingExperienceTest(): the End");
    } // scrollingExperienceTest()

    // ...
}

The first necessary change is to tap the Refresh button because the new securities will show up only after they got their quotes. Currently the quotes are actually loaded from the internet and a Thread.sleep(3000) makes shure they arrived before continuing the test – this will be corrected soon.

After that the test issues some calls to swipeUp(). Note that actionOnItemAtPosition() will scroll the respective item into view so don’t just always pass 0 like I did initially.

A temporary addition to ensure the swipes actually force the creation of additional list items is needed:

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

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int reportPosition) {
        // ...
        // Symbol
        viewHolder.Symbol = this.cursor.getString(
                this.cursor.getColumnIndex(SecurityContract.Security.SYMBOL));
        Log.d(CLASS_NAME, "onBindViewHolder(): symbol = " + viewHolder.Symbol);
        // SymbolTextView
        viewHolder.SymbolTextView.setText(this.cursor.getString(
                this.cursor.getColumnIndex(SecurityContract.Security.SYMBOL)));
        // ...
    }

    // ...
}

That needs to be commented out after verifying the test works as expected – onBindViewHolder() is the last spot you want to slow down.

Start the test now by clicking Run ‘scrollingExperienceTest()’ in the method’s context menu. You should be able to watch a tap on Refresh followed by the additional reports showing up and swipes to the end of them. Android Studio’s logcat window should show something like this:

...
... D/QuoteRefresherService: onHandleIntent(): quotes updated - initiating screen refresh
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = BAYN.DE
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = SIE.DE
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = ABBN.VX
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = NESN.VX
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = NOVN.VX
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = CFR.VX
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp0
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 0' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = ROG.VX
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp1
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 1' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = SYNN.VX
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp2
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 2' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = UBSG.VX
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp3
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 3' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/WatchlistRec.ViewAd.: onBindViewHolder(): symbol = ZURN.VX
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp4
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 4' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp5
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 5' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp6
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 6' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/ScrollingExperienceTest: scrollingExperienceTest(): swipeUp7
... I/ViewInteraction: Performing 'actionOnItemAtPosition performing ViewAction: fast swipe on item at position: 7' action on view (with id: de.dbremes.dbtradealert.playStore:id/list and has parent matching: (with id: de.dbremes.dbtradealert.playStore:id/container and has parent matching: with id: de.dbremes.dbtradealert.playStore:id/main_content) and is displayed on the screen to the user)
... D/ScrollingExperienceTest: scrollingExperienceTest(): the End
... D/DbHelper: deleteTestSecurities(): success!

So as required the swipes cause RecyclerView items to be created.

Next post: Automated UI Tests – Part 3: Testing with WireMock and Firebase Test Lab for Android

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Automated UI Tests – Part 1: Thoughts about Testing

First post in this series: Introduction to DbTradeAlert

Previous post: More Finishing Touches


Ever wondered why everybody thinks testing is a good idea but nobody does it? Then read on. If you are more of a hands-on person you’ll have to wait for the next post though.

1. Why Test at All?

If a blog post about testing asks “why test at all?” you’d expect that to be a rhetorical question. But the goal you pursue with testing determines what you test, how you test it, and what you don’t test.

To answer the question “why test?” one can simply ask “why create that app?” and in most cases the answer is “to earn money”. That means tests are simply an investment that has to pay off. Think of them as investing in an insurance to avoid the cost of errors.

Obviously the next question is: how much do errors cost your customer / employer? Do bugs in the software kill people? Or can users just postpone its use for a week if it stopped working altogether? Costs may also come from contractual or legal bindings and brand damage. And finally costs come from having to resolve a problem quickly at the most unsuitable time. That said simply developing the wrong product probably is the most epensive error.

Of course the answer will be just guesswork but the important point is to get everybody in the team on the same page. That means to consider costs and benefits instead of just wanting to use the framework du jour or to meet their peer’s expectations.

Figuring out the cost of errors gave you an idea about how much you can spend on mitigation. If tests catch all errors before they make it to the user that’s perfect. But also unrealistic especially considering the limited resources you can spend on tests which includes gaining and keeping the expertise to use the respective tools and frameworks.

So in reality you’ll have to spend some of the mitigation resources on monitoring to deal with errors that made it to the user. And monitoring will not only alert you to those errors sometimes even before users notice them but it also helps to reproduce those errors.

Another benefit is that monitoring can uncover unexpected usage scenarios and it may be the only way to mitigate costs of errors induced by external systems. If the customer has a monitoring system already in place that will reduce the cost of mitigation on your part. While monitoring doesn’t reduce the Mean Time Between Failure (MTBF) it reduces the Mean Time To Recovery (MTTR) which is often more valuable. That’s why DbTradeAlert uses Firebase Analytics and Firebase Crash Reporting.

Another way to mitigate costs of errors is automated deployment as it also reduces MTTR. A lot of projects have automated deployment in place as part of continuous integration. That will again reduce the cost on your part because those resources were spent to tighten the feedback loop a.k.a. being agile.

2. What and How to Test

The previous section showed that the resources you can spend on tests are very limited so you need to invest them wisely. Let’s have a look at four types of tests:

  • Unit tests
  • Integration tests
  • End-to-end tests
  • Automated UI Tests

The first three types of tests differ by what they test and make up the popular test triangle. The fourth type is the 3rd’s automated cousin – the first two types are almost always automated.

Besides representing the test triangle’s types those test types also have counterparts in Android Studio:

  • Local unit tests located at module-name/src/test/java/: no access to Android dependencies
  • Instrumented tests located at module-name/src/androidTest/java/: access to Android environment and the app
  • Automated UI Tests with Espresso: same as instrumented tests but with a test recorder

There is no rigid separation between unit and integration tests though as the following sections will show.

2.1 Unit tests

Unit tests are written by programmers and exercise small units of code like a method with the intent to isolate it from external behaviour. They became popular with Extreme Programming in the late nineties. Unit tests should be run whenever a change has been made to spot errors before they have costly consequences.

By now each programming language and software platform has specialized frameworks to execute unit tests and to streamline their creation. For Android and Java in general that’s JUnit 4.

Unit tests can also serve as documentation of how a piece of software is expected to work. And they are invaluable for the dreaded two screen long method of business logic. Without proper unit tests everybody would be afraid to touch that beast – for good reasons. They are also the only sure-fire way to ensure the method handles every case and does it correctly.

Going a step further leads to test driven design (TDD) where you write unit tests before the actual code. The most important benefit of that is making you look at your code from a client’s point of view. That will lead to a much better API design.

But while writing a unit test up-front is certainly more fun than debugging an error afterwards those unit tests don’t come for free. Especially the cost of maintaining unit tests can go through the roof if some change requires a substantial rewrite of them.

That in turn can lead to people foregoing necessary refactorings to avoid reworking the respective unit tests. In some situations time constraints lead to deactivating unit tests instead of adapting them. And in that case the whole investment is often lost as it’s likely that later project stages bring even more time constraints and those unit tests never get reactivated.

Short form: unit tests are most effective at verifying business logic.

2.2 Integration Tests

Programmers write integration tests to ensure a program’s components – especially its external components – work together as expected. So in a sense they are the opposite of a unit test. This is the kind of test that catches Ninja changes to APIs and botched configurations. Integration tests mostly use the platform’s unit test framework – JUnit in this case – and run in integration or nightly builds so execution time isn’t much of a concern.

Assuming the component’s interfaces are stable integration tests only require the investment to initially write them. But they don’t benefit from natural boundaries as much as unit tests do. Therefore they tend to invite a mocking framework to shield them from disturbances like unstable network conditions or to circumvent laborious setups. Mocking frameworks in turn tend to invite a dependency injection framework so the mock can be switched in and out. The result not only necessitates structural code changes but an additional set of expertise which is not that prevalent. And as the framework of the day tends to change like fashion finding the required expertise will be even harder – read “more expensive” – in a few years.

2.3 End-to-End Tests

End-to-end tests are the only ones that actually prove a program works as expected. They are also the oldest and most common type and require no additional investment in infrastructure if done manually and ignoring hardware requirements to parallelize testing.

Doing them manually also reveals their biggest drawback – they just eat time. This is somewhat alleviated by the fact that designing and executing manual end-to-end tests should be left to professional testers anyway and therefore doesn’t use development resources.

There are multiple reasons why you want professional testers for this kind of test:

  • Designing useful end-to-end tests requires a broad view of the product – programmers in contrast have a deep understanding on specific parts
  • Designing and manually executing end-to-end tests provides an opportunity to evaluate the user experience (UX) – programmers are notorious for ignoring UX
  • Testing requires professional sceptics – programmers are born optimists (as each of their estimates shows)
  • Testing ones own software will happen in the same mindset that created the software – that’s exactly how unit tests miss bugs

The other drawback of end-to-end tests is that they often leave you in the dark about the exact error condition if things go wrong. Digging that out can be very costly and therefore you’ll still have to invest in unit or integration tests as well as in monitoring.

Android apps add an additional burden to manual tests: a lot of them will have to be repeated on various devices. Those devices may differ in screen or memory size, CPU speed, or Android version or they may be specific to a certain error scenario. Of course that will drive up costs for any manual test and not only end-to-end tests.

Which is a nice transition to the next section …

3. Automated UI Tests

Historically automated UI tests had the odor of being too expensive and too brittle. That’s because in their most simple and ancient form they compared the result screen with a screenshot – it can’t get more brittle than that. In addition to that the recordings couldn’t be adapted easily which means they had to been thrown away as soon as the program changed significantly. Add to that the variation of network and timing issues and you know why automated UI tests had a bad name.

But the industry has evolved and recognizes the value of automated UI tests which results in various automation frameworks. Google’s latest addition is a UI testing framework named Espresso to which Android Studio 2.2 supplies a test recorder which as of now (2016-09-26) is still in beta though.

Espresso addresses most problems of the past:

  • Android provides hooks for Espresso tests – no architecting of the software for testing requirements is needed which saves a lot of time and money
  • Espresso deals with the asynchronous nature of Android apps’ UI – no flaky tests due to timing issues and shorter test runs because no Thread.sleep() is needed
  • Espresso tests use the unit test infrastructure – no need to learn yet another tool
  • By using a test recorder the testers can create the tests – that frees developer resources
  • The test recorder creates readable Java code – the test script can easily be adapted to changing software

Espresso isn’t perfect though:

  • It can only access the app under test – UI elements like notifications and toasts are out of reach because Android owns them
  • It cannot test WebViews or ImageViews

There are ways around those limitations like combining Espresso with Selendroid to test WebViews or using the OpenCV library to test ImageViews. But as usual detours come at a cost – you need to build up the expertise to apply those solutions.

4. Automated Tests for DbTradeAlert

The previous sections determined the goal of testing and the types of automated tests Android provides with their pros and cons. So what automated tests do I choose for DbTradeAlert after learning about Android’s options?

Let’s go through the test types:

  • Unit tests: the app has barely any business logic so no gain from unit tests
  • Integration tests: there are various areas where I would have felt more comfortable with integration tests:
    • Test how the app deals with network errors
    • Test scheduling without having to wait for the actual alarm
    • Test the behavior in Doze and App Stand by mode
  • End-to-end tests: I did those manually like testing CRUD operations for securities. Not shure if automating them would have saved much time.

That only leaves automated integration tests as an option to save time. As the app doesn’t have an API those tests can only be automated by UI interactions.

On the other hand creating automated tests for the app’s scheduling and how it deals with Doze and App Stand by mode would be challenging and won’t happen now.

Now there are two more areas for which I would like more tests when the app faces a broader audience:

  • The scrolling experience with larger watchlists on low end devices
  • The app’s reaction to unintended usage

So here’s the plan:

  • Scrolling experience test:
    • Add more securities than fit the screen
    • Record some swipe tests with Espresso
    • Run those tests in Firebase Test Lab for Android on physical low end devices
  • Monkey test 1:
    • Submit the app to abuse by the test monkey that comes with the SDK
    • Provide canned quotes for both the initial securities and any symbol the monkey may enter
  • Monkey test 2:
    • Submit the app to abuse by a Firebase Test Lab for Android Robo Test
    • Provide canned quotes for both the initial securities and any symbol the monkey may enter
  • Connection test:
    • Provide a configurable connection that can time out, return HTTP error codes and garbage data

Next post: Scrolling experience test – Testing with Espresso

Additional Resources

 

Posted in Uncategorized | Tagged , | Leave a comment

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

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Google AdMob

First post in this series: Introduction to DbTradeAlert

Previous post: Integrate Google Play Services


Google AdMob consists of two programs:

  • AdWords is Google’s main source of income and provides pay-per-click advertising to its users
  • AdSense is AdWords’ counterpart and provides website and app publishers with an opportunity to earn money by showing ads

This post requires an existing app in the Play Store. It will add AdSense to DbTradeAlert and the first step is to create an AdMob account.

1. Create an AdMob Account

To create an AdMob account follow these steps and be aware that some of the information cannot be changed later:

  1. Navigate to the Firebase console at console.firebase.google.com
  2. Select your project
  3. On the navigation bar click AdMob
  4. In the center click Register for AdMob
  5. In the next step select a Google account you want to connect to AdMob
  6. In the next step enter information for your AdSense account:
    1. Country will for example determine the payment currency
    2. Individual / business account determines whether payments will be made to the account holder or a company
    3. Full address, email and phone of payee
  7. In the next step enter the timezone and billing currency information for your AdWords account
  8. In the next step agree to the fine print and click Create AdMob Account

The next step is to register an app with AdMob.

2. Register an App with AdMob

You normally register an app with AdMob by searching the Play Store. That search will only return indexed apps and indexing can take a day or more after initially publishing an app. This is not ideal as it makes publishing an app a two-step process with possibly a multi-day delay.

It seems Google created a new option because of this where you add an app by typing in its Id. But for me that created an new Play Store listing with the same app Id (de.dbremes.dbtradealert.withads) and a lot of confusion. Also note that you cannot delete an AdMob registration. So here is the safe way of selecting an app by searching the Play Store:

  1. Navigate to https://apps.admob.com and log in – the top right shows your Publisher-ID by the way
  2. Click the Monetize tab
  3. The first step is to select the app and the “Search for Your App” tab should be open
    1. Type in a part of the app’s name – “dbtradealert” in my case
    2. The list below the search box should now show the app – click its Select button
    3. Back in the overview step click Add App
  4. The second step is to choose an ad format and name the ad unit
    1. Select an ad format – banner in my case – and fill in the format specific options – I went with the defaults and named the ad unit after the activity that will show it
    2. Click Save – the list of Ad units shows a new entry and most importantly its Ad unit Id which you’ll need later
  5. The third step is to connect AdMob to Firebase Analytics which is optional
    1. Click Connect to Firebase
    2. Confirm that you are allowed to connect this AdMob account to Firebase Analytics
    3. Confirm the package name
    4. Select a Firebase project – or create a new one
  6. In the next step click the link to download the configuration file
    1. You are transferred to the Firebase console where you can download yet another google-services.json file
  7. In the AdMob console click Finish

3. Create an App Flavor for AdSense Integration

Similar to the playStore flavor DbTradeAlert now gains a withAds flavor. This will incorporate Google’s AdSense for monetization.

The intended state of the app’s flavors is:

  • naked flavor: just for reference, without Google Play Services, and not available in the Play Store
  • playStore flavor: with Firebase Analytics for tracking and available in the Play Store
  • withAds flavor: like the playStore flavor and with AdSense for banner adverts added

To have the playStore flavor’s functionality available in the withAds flavor:

  • playStore flavor’s functionality moves to a common package
  • both flavors will point to that package
  • the withAds flavor will get an additional AdHelper class providing AdSense specific functionality
  • the playStore and naked flavors will get the same AddHelper class but with empty methods

For now the withAds flavor could simply point to the playStore flavor’s package. But with each getting its own AdHelper implementation that approach would be a dead end.

4. Move playStore Flavor’s Functionality to a Common Package

The first step is to create the “common” package where playStore flavor’s code will go. I didn’t find a way to have Android Studio do this with flavors added so I did it manually:

  1. Create a new flavor “commmon”
  2. Add a Java directory to “common”
  3. Switch to build variant “commonDebug”
  4. Add a new package to “..\app\src\java\common” using the general package name – “de.dbremes.dbtradealert”

After that move PlayStoreHelper.java to its new package. Same reason as above for doing this manually:

  1. In Explorer move PlayStoreHelper.java from the “playStore” package to the new one
  2. Switch back to build variant playStoreDebug
  3. Open build.gradle (Module: app) and change it like this:
apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    // ...

    productFlavors {
        naked
        playStore {
            applicationId = "${android.defaultConfig.applicationId}.playStore"
        }
    }
    sourceSets {
        playStore.java.srcDirs = ['src/common/java']
    }
}

// ...

The “common” flavor is gone – it just served to let Android Studio create the folder hierarchy. And the new sourceSets closure makes the playStore flavor use the files in the “commmon” package. After this the playStore flavor should build like before.

5. Create the withAds Flavor

The first step is again to create the new flavor in build.gradle:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    // ...

    productFlavors {
        naked
        playStore {
            applicationId = "${android.defaultConfig.applicationId}.playStore"
        }
        withAds {
            applicationId = "${android.defaultConfig.applicationId}.withAds"
        }
    }
    sourceSets {
        playStore.java.srcDirs = ['src/common/java']
        withAds.java.srcDirs = ['src/common/java', 'src/withAds/java']
    }
}

dependencies {
    // ...
    playStoreCompile 'com.google.firebase:firebase-core:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-core:9.4.0'
    withAdsCompile 'com.google.firebase:firebase-ads:9.4.0'
    compile 'org.jetbrains:annotations-java5:15.0'
}

// ...

Like the playStore flavor this one sets the applicationId, adds Firebase core as a dependency, and uses the common package’s java sources. In addition to that it adds Firebase Ads as a dependency and specifies an additional package for its source set.

You probably remember from creating the previous flavor that the new one won’t build yet – the Google Services plugin for Gradle will search in vain for the google-services.json file and this file also doesn’t contain the flavor’s applicationId yet.

So generate a google-services.json file like before:

  1. Navigate to https://console.firebase.google.com and log in
  2. Click Add App
  3. In the popup window click Add Firebase to my Android app
  4. In the “Enter app details” step add the applicationId – “de.dbremes.dbtradealert.withAds” in my case
  5. After clicking Add App a file named google-services.json is downloaded

When you compare this file to the previous one you’ll find that it has the same information plus data for a new client – de.dbremes.dbtradealert.withAds.

This file will be used by both the playStore and the withAds flavor and therefore needs to be in the app directory above them. And of course the playStore flavor’s google-services.json needs to go.

The withAds flavor should now compile providing the same functionality as the playStore flavor. To test that generate signed APKs for both flavors – the naked flavor won’t build with the Google Services plugin applied of course.

After that create a strings.xml file for the withAds flavor, too:

  1. Let Android Studio create a new resource directory for the withAds source set
  2. Let Android Studio create a new resource file named strings.xml in that directory
  3. Copy the app_name element from one of the existing strings.xml files and change its value to “DbTradeAlert A”

6. Add Flavor Specific Functionality

The withAds flavor’s only addition to the playStore flavor is a banner ad on the main screen which involves three tasks:

  1. Connnect an AdView control to AdMob
  2. Inject that control into the main screen
  3. Make the other flavors work without tying them to AdMob

6.1 Connnect an AdView Control to AdMob

To connnect an AdView control to AdMob you’ll have to specify an ad unit Id which looks like this: “ca-app-pub-3940256099942544/6300978111”. An ad unit was obtained in the previous sections and lets Google know whom to pay. It also allows you to track the source of your income if you have multiple adverts in an app.

But while I didn’t find any best practices it seems to be worthwhile to keep your ad unit Id secret because it contains your publisher Id – that’s the part before the “/”. Off the top of my head I can think of two misuses and I’m not even a security guy:

  • Someone could use your publisher Id in frivolous actions and get your account banned
  • With some social engineering they could also take over your account

For this reason my ad unit Id is not in the Git repository but loaded from an external file. Another twist regarding the ad unit Id is that you should only use test ads during development because tapping a real ad for test reasons can get your AdMob account suspended. Gradle takes care of both:

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}"
        // Ad unit Id for sample ads from
        // https://firebase.google.com/docs/admob/android/google-services.json
        buildConfigField "String", "AD_UNIT_ID", "\"ca-app-pub-3940256099942544/6300978111\""
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            Properties props = new Properties()
            props.load(new FileInputStream("$project.rootDir/../../DbTradeAlert/project.properties"))
            buildConfigField "String", "AD_UNIT_ID", "\"${props.getProperty("ad_unit_id")}\""
        }
    }
    // ...
}

// ...

The defaultConfig defines a buildConfigField named “AD_UNIT_ID” providing an ad unit Id that was copied from one of Google’s samples and will only deliver test ads. Note that you have to generate quotes around its value because Gradle would otherwise read it as an integer type which results in an “integer number too large” error.

The release build type creates the same field which overrides the default one. The actual value is read from a file named “project.properties” which has just one line – with a proper ad unit Id of course:
ad_unit_id=ca-app-pub-1234567891234567/1234567891

Of course the publisher Id can still be extracted from the APK. But why make a crook’s life easier …

Now comes a second preparation step – create the AdHelper class. And make shure to put it in the right package …

Android Studio’s Project view will show two packages named “de.dbremes.dbtradealert (withAds)”. That’s because the previous section specified two source directories in build.gradle. The new AdHelper class needs to go in the empty package in the withAds directory.

package de.dbremes.dbtradealert;

import android.content.Context;
import android.view.View;

import com.google.android.gms.ads.AdRequest;
import com.google.android.gms.ads.AdSize;
import com.google.android.gms.ads.AdView;
import com.google.android.gms.ads.MobileAds;

public class AdHelper {

    public static View getAdView(Context context) {
        // Create AdView
        AdView adView = new AdView(context);
        adView.setAdSize(AdSize.BANNER);
        adView.setAdUnitId(BuildConfig.AD_UNIT_ID);
        // Load ad
        AdRequest adRequest = new AdRequest.Builder().build();
        adView.loadAd(adRequest);
        return adView;
    } // getAdView()

    public static void initialize(Context context) {
        MobileAds.initialize(context, BuildConfig.AD_UNIT_ID);
    } // initialize()
} // class AdHelper

The interesting method is getAdView() which prepares an AdView control by setting its size and ad unit Id which can now conveniently be accessed as BuildConfig.AD_UNIT_ID.

The AdView then loads an AdRequest and by calling Builder.addTestDevice() you can specify that you want to receive only test ads even if you use a real ad unit ID – we’ll come back to that later.

6.2 Inject the AdView Control into the Main Screen

To avoid adding AdMob dependencies to the main screen the AdView control is injected at runtime:

// ...

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

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

        // Show banner ad in withAds flavor
        AdHelper.initialize(getApplicationContext());
        View adView = AdHelper.getAdView(getApplicationContext());
        if (adView != null) {
            LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
                    LinearLayout.LayoutParams.MATCH_PARENT,
                    LinearLayout.LayoutParams.WRAP_CONTENT);
            lp.gravity = Gravity.BOTTOM | Gravity.CENTER;
            adView.setLayoutParams(lp);
            LinearLayout ll = (LinearLayout) findViewById(R.id.main_linear_layout);
            ll.addView(adView);
        }
    } // onCreate()

    // ...
} // class WatchlistListActivity

After performing the existing initialization WatchlistListActivity.onCreate() now calls AdHelper.initialize() and AdHelper.getAdView(). In this case AdHelper.initialize() probably doesn’t help much to speed up showing the first ad because that is requested only split seconds afterwards.

The AdView control receives some layout information so it shows up at the bottom of the screen and finally it gets added to its parent view. I had to add this parent view to prevent the AdView from blocking the actual content.

That parent view is a LinearLayout with its orientation attribute set to vertical and it wraps the CoordinatorLayout which was originally the outermost control. The CoordinatorLayout also lost its layout_height which was set to match_parent and made it grab the whole screen. As a replacement it got a layout_weight attribute with a value of 1. That makes it still grab the screen as long as it’s alone but it will leave room to other controls when necessary.

Time to enjoy ads flying in your face. To test vertical scroll behavior it’s a good idea to add two more securities – I added CSGN.VX (Credit Suisse) and UBSG.VX (UBS Group) to the CH watchlist.

After bulding and deploying the app lines like those should show up in Android Studio’s logcat window:
... I/Ads: Starting ad request.
... I/Ads: Use AdRequest.Builder.addTestDevice("xyz") to get test ads on this device.
... E/Ads: JS: Uncaught ReferenceError: AFMA_ReceiveMessage is not defined (:1)
... I/Ads: Scheduling ad refresh 60000 milliseconds from now.
... I/Ads: Ad finished loading.
... I/Ads: Starting ad request.
... ...

The line with addTestDevice(“xyz”) reports a hash code for your device. Use that to request test ads if you use a real ad unit Id. Google confirmed the Uncaught ReferenceError is a bug and they are workng on it.

The test ad should show up at the bottom of the screen:

AdMob in action

AdMob in action

Note that I scrolled all the way up to see if the last report is still accessible. Title and toolbar look a bit messed up but that is actually a feature as that area moves out of the way to give more space to the actual content.

If the ad is somehow hidden you’ll see lines like this in Android Studio’s logcat window:
... de.dbremes.dbtradealert.withAds I/Ads: Ad is not visible. Not refreshing ad.
... de.dbremes.dbtradealert.withAds I/Ads: Scheduling ad refresh 60000 milliseconds from now.
... ...

6.3 Make the Other Flavors Work Without Tying them to AdMob

Like previously seen with the playStore flavor the existing non-withAds flavors need their own empty implementation of an AdHelper class. And ideally an interface or abstract base class would make shure those AdHelpers are implemented correctly.

But neither of them work with static methods. As I don’t like to change code for technical reasons I’ll leave it to the build server to point out diverging AdHelper implementations and just create two identical copies.

Let’s start with an AdHelper class for the playStore flavor:

  1. Add “src/playStore/java” to playStore.java.srcDirs in build.gradle and let Gradle sync
  2. Switch to the playStoreDebug variant
  3. In the app context menu select New | Folder | Java Folder
  4. In the Configure Component window select playStore as the Target Source Set and click Finish
  5. In the app context menu select New | Java Package
  6. In the Choose Destination Directory window select “..\app\src\playStore\java” and click OK
  7. In the New Package window enter “de.dbremes.dbtradealert” and click OK
  8. In the new package’s context menu select New | Java Class
  9. In the Create New Class window enter “AdHelper” and click OK

After that copy the contents of the existing AdHelper class into the new one and:

  1. Remove all “com.google.android.gms.ads” imports
  2. Remove the code in getAdView() and return null
  3. Remove the code in initialize()

Finally build the project to see if it likes the new AdHelper class.

Fixing the naked flavor is a lot easier. Just copy the new AdHelper.java to the respective directory, switch to the nakedDebug variant and Android Studio should pick up the file. Just remember to comment the line applying the Google services plugin out in build.gradle before starting the build.

Next post: more finishing touches (add Firebase Crash reporting, Firebase Remote Config, an About box, and perform Play Store optimization)

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Integrate Google Play Services

First post in this series: Introduction to DbTradeAlert

Previous post: Enter the Play Store


Google offers various services one might want to add to an app. For example:

  • Firebase Analytics provides insights about an app’s usage
  • Firebase Crash Reporting provides insights about app stability
  • Firebase Remote Config allows to change an app’s behavior and look without performing an update; it also allows for example a/b testing
  • Google AdMob allows to monetize an app

Firebase Analytics replaces Google Analytics for mobile apps on both Android and iOS. This was formally declared at Google I/O 2016 just 3 months ago and many examples still show the old way of doing things. At least for now Google Analytics is still used for web apps which adds to the confusion.

Users can opt out of tracking

Users can opt out of tracking

This post lays the foundation for integrating Google Play Services and adds tracking of user behaviour through Firebase Analytics. A later post will add Google AdMob and Firebase Crash Reporting to the app.

In this case the first step is to add app flavors because that enables providing multiple versions of an app – like a paid and a free version. Regarding that “flavors” terminology:

  • Flavors of an app differ by functionality like a paid and a free flavor
  • Build types control the packaging of an app like a debug and a release build
  • Each combination of a flavor and a build type is a variant

As you can have multiple flavor dimensions – like paid vs. free and normal vs. mock – things can get complicated. And if you don’t define flavors you get a default one with no name.

1. Create App Flavors

DbTradeAlert will have three flavors:

  1. The current flavor – called “naked” from here on because Gradle doesn’t like “default”
  2. A playStore flavor which adds tracking
  3. A withAds flavor which builds on the playStore flavor  and adds advertisements – to be added in a later post

The naked flavor serves as a reference to debug flavor specific problems and will not be available in the Play Store. The playStore flavor will fulfill my curiosity about the feedback one gets from the Play Store. In addition to that I’m interested in usage scenarios and add tracking for them.

App flavors are controlled by Gradle. One reason is that Java has no preprocessor and therefore no conditional compiles. The general steps to create a flavor are:

  1. Add the flavors to build.gradle – only specify what is flavor specific to override defaultConfig settings
  2. Keep flavor specific functionality in separate classes in a flavor specific directory structure
  3. Handle flavor specific resources similar to functionality

A common misconception is that flavors override or overwrite methods or classes of the main directory structure. They don’t but instead are added to the code in main creating so-called source sets. So while classes in a flavor can extend those in main you cannot have two casses with the same name.

In contrast to that resources are merged. That means for example a flavor specific resource will replace a general resource.

I read “somewhere” that Gradle has a problem if flavor names start with a capital letter. As Gradle seems to be a moving target with bugs and functionality coming and going anyway I’m playing it safe and use only lowercase flavor names.

1.1. Add the playStore Flavor to build.gradle

The first step is to add the productFlavors closure to app/build.gradle (Module: app):

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'
        }
    }
    productFlavors {
        naked 
        playStore {
            applicationId = "${android.defaultConfig.applicationId}.playStore"
        }
    }
}
// ...

Before adding the productFlavors closure there was an automatic default flavor. Now each flavor has to be explicitly specified.

Note that each flavor has its own applicationId. That’s required if multiple APKs will be uploaded to the Play Store.

Build both flavors to test if the changes work as expected:

  1. In Android Studio click Build | Generate Signed APK
  2. In the Generate Signed APK window, step 1:
    1. The Key store path should be set
    2. Enter the store’s and the key’s passwords and click Next
  3. In the Generate Signed APK window, step 2:
    1. Build type should default to release
    2. Flavors list should contain naked and playStore – select both
    3. Click Finish

The output path should now contain a file named “DbTradeAlert-1.0-naked-release.apk” and one named “DbTradeAlert-1.0-playStore-release.apk”. Both will be identical of course.

1.2 Create the playStore Specific Directory Structure

Implementing the tracking starts with creating the flavor specific directory structure. You can either do that manually or enlist Android Studio:

  1. In the Project pane open the app’s context menu and select New | Folder | Java Folder
  2. In the Configure Component window select “playStore” as the Target Source Set and click Finish – this will create “…\app\src\playStore\java”
  3. In the Build Variants pane switch to the playStoreDebug variant
  4. Open the app context menu again and select New | Java Class
  5. In the Choose Destination Directory window select “..\app\src\playStore\java” and click OK
  6. In the Create New Class window enter “PlayStoreHelper” as the name and click OK
  7. Add “package de.dbremes.dbtradealert” at the top of the new java class – this will result in a red squiggly line under it
  8. Select the “Move to package ‘de.dbremes.dbtradealert'” quick fix Android Studio offers
  9. If necessary choose the right destination directory – “..\app\src\playStore\java\de\dbremes\dbtradealert” – in the Choose Destination Directory window and click OK

The Project view should now list a new package named “de.dbremes.dbtradealert (playStore)” with PlayStoreHelper.java in it.

To make the app’s various versions easier to discern let’s also add flavor specific labels for the app and its main screen.

1.3 Create playStore Specific Resources

The idea is to have different names for the app’s flavors:

  • “DbTradeAlert” for the playStore flavor
  • “DbTradeAlert N” for the naked flavor
  • “DbTradeAlert A” for the withAds flavor

These names will be used for both the app and its main activity. The app’s name shows up for example in the Settings screen’s app list while the activity’s name shows up in the launcher. That’s because activities are meant to be used to start an app – I found that only later and should have used fragments instead of activities.

Both names come from “app_name” in values/strings.xml – to see it in AndroidManifest.xml click on the respective value. The flavor specific names will make use of reource merging – the default value in strings.xml will automatically be replaced by the value in the flavor specific strings.xml.

Currently the playStoreDebug variant should be selected – otherwise select it. After that:

  1. In the app context menu select New | Android resource directory
  2. In the New Resource Directory window select the Source set “playStore” and click OK

This created the directory “… DbTradeAlert\app\src\playStore\res\values”.

Now add the new resource file:

  1. In the app context menu select New | Android resource file
  2. In the New Resource File window
    1. Enter “strings.xml” as File name
    2. Select Source “set playStore”
    3. Click OK

This created a new file which shows up as “strings.xml (playStore)” in Android Studio’s Project view.

And finally create flavor specific names:

  1. Copy the element defining app_name from the old strings.xml to the new one
  2. In the old strings.xml change app_name to “DbTradeAlert N”

Whenever you build the APK from now on both its titles will depend on the selected app variant. As the other value is missing from the new strings.xml the general one will be used irrespective of the flavor being built.

To try it out build a naked variant and install it – it should show up as “DbTradeAlert N”. The playStore variants still show as “DbTradeAlert”.

2. Integrate Google Play Services

Google Play Services provide the base for many Google and Firebase services. If an app uses Google Play services it needs:

  • The google-services.json configuration file in place
  • The dependencies specified in project and app Gradle files

The playStore flavor of DbTradeAlert will use Firebase Analytics which is part of Google Play services.

First make shure your device or emulator came with Google Play store / Google APIs. It seems to be possible to get around that requirement for Firebase Analytics but I didn’t try.

After that download the .json configuration file. That requires the Google developer account created in the sections before.

To download google-services.json and place it into your project:

  1. Navigate to https://console.firebase.google.com and log in
  2. Click Add App
  3. In the popup window click Add Firebase to my Android app
  4. In the “Enter app details” step add the applicationId for the flavor you plan to upload – “de.dbremes.dbtradealert.playStore” in my case – copy it from build.gradle to avoid typos
  5. After clicking Add App a file named google-services.json is downloaded
  6. Depending on your needs copy the file either into the project’s main folder or into the flavor specific root folder – “…\app\src\playStore” in my case

Now add one line to build.gradle (Project: DbTradeAlert):

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.1.2'
        classpath 'com.google.gms:google-services:3.0.0'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

Then add two lines to app/build.gradle (Module: app):

apply plugin: 'com.android.application'

android {
    // ...
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    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.2.1'
}

// Remove apply plugin: 'com.google.gms.google-services' when building naked variants.
// Those have no google-services.json file and applying the plugin would produce an error:
// "File google-services.json is missing. The Google Services Plugin cannot function without it."
// Building the R class would fail too and produce "cannot resolve symbol R" errors.
apply plugin: 'com.google.gms.google-services'

“apply plugin: ‘com.google.gms.google-services'” enables Gradle to create Google Services related resources – we’ll come back to that later for the naked flavor. It’s important to add it at the bottom of build.gradle and not at the top. Also note that Firebase Analytics which are in firebase-core are only linked to the playStore flavor.

Now let Gradle sync its files and fix any errors it reports. It will complain if google-services.json isn’t in the right place and even point out newer versions of the libraries. Should Gradle report an error like “No matching client found for package name …” when building the variant the applicationId in build.gradle doesn’t match package_name in google-services.json.

Finally add google-services.json to your project’s .gitignore file:

*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
google-services.json

This ignores google-services.json files in any directory. Like the passwords you want the keys in google-services.json to stay private.

3. Implement Tracking

Finally it’s time to actually implement the flavor specific functionality. In this case that’s informing Firebase Analytics every time the user taps the Refresh button or opens a screen. The main idea is that they shouldn’t feel the need to refresh quotes manually and if they do there’s something wrong with them the app.

To confine functionality from Firebase Analytics to the playStore flavor code calling the respective APIs needs to reside in files that only the playStore flavor contains. So create a class named PlayStoreHelper in the respective package – “de.dbremes.dbtradealert (playStore)” in my case:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.os.Bundle;

import com.google.firebase.analytics.FirebaseAnalytics;

public class PlayStoreHelper {

    private PlayStoreHelper() {
        // Hide default construcor
    }

    public static void reportAction(Context context, String actionTitle, int actionId) {
        Bundle bundle = new Bundle();
        bundle.putString(FirebaseAnalytics.Param.ITEM_ID, String.valueOf(actionId));
        bundle.putString(FirebaseAnalytics.Param.CONTENT_TYPE, actionTitle);
        FirebaseAnalytics.getInstance(context).logEvent(FirebaseAnalytics.Event.SELECT_CONTENT, bundle);
    } // reportAction()
} // class PlayStoreHelper

And this is the call to PlayStoreHelper.reportAction() in WatchlistListActivity.onListFragmentInteraction() and onOptionsItemSelected():

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

    @Override
    public void onListFragmentInteraction(String symbol) {
        int actionId = 0;
        PlayStoreHelper.reportAction(getApplicationContext(), "Long tap security", actionId);
        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()

    @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();
        PlayStoreHelper.reportAction(
                getApplicationContext(), item.getTitle().toString(), id);
        switch (id) {
            // ...
        }
    }
    // ...
} // class WatchlistListActivity 

DbTradeAlert logs each user interaction as a Firebase SELECT_CONTENT event specifying CONTENT_TYPE and ITEM_ID. You are free to come up with your own events and parameters but the reports in Firebase console currently won’t show them. They actually only report CONTENT_TYPE for the SELECT_CONTENT event but that’s OK for now.

In a real-world scenario the marketing people would want to slice and dice the raw data anyway either in BigQuery or in a spread sheet after downloading them. Note that you cannot download raw data from Firebase directly but have to go through BigQuery which requires the pay-as-you-go Blaze plan.

Another important fact is that data will take several hours to show up in the Firebase website. For that reason it’s advised to use logcat for a first check. Enable local Firebase logging with:
adb shell setprop log.tag.FA VERBOSE
adb shell setprop log.tag.FA-SVC VERBOSE

Before running the new variant it’s a good idea to delete the old app from the device to avoid confusion. After that run the app in debug mode and tap the Refresh button. The logcat output from Firebase should look like this – some identifiers were replaced with “nnnnn” to protect the innocent:

... de.dbremes.dbtradealert.playStore I/FA: App measurement is starting up, version: 9256
...
... de.dbremes.dbtradealert.playStore V/FA: Collection enabled
... de.dbremes.dbtradealert.playStore V/FA: App package, google app id: de.dbremes.dbtradealert.playStore, 1:nnnnn:android:nnnnn
... de.dbremes.dbtradealert.playStore V/FA: Registered activity lifecycle callback
... de.dbremes.dbtradealert.playStore I/FirebaseInitProvider: FirebaseApp initialization successful
... de.dbremes.dbtradealert.playStore V/FA: State of service unknown
... de.dbremes.dbtradealert.playStore V/FA: Checking service availability
... de.dbremes.dbtradealert.playStore V/FA: Setting useService: true
... de.dbremes.dbtradealert.playStore V/FA: Using measurement service
... de.dbremes.dbtradealert.playStore V/FA: Connecting to remote service
... de.dbremes.dbtradealert.playStore V/FA: onActivityCreated
...
... de.dbremes.dbtradealert.playStore D/FA: Logging event (FE): _s, Bundle[{_o=auto}]
... de.dbremes.dbtradealert.playStore V/FA: Using measurement service
... de.dbremes.dbtradealert.playStore V/FA: Connecting to remote service
... de.dbremes.dbtradealert.playStore D/FA: Connected to remote service
... de.dbremes.dbtradealert.playStore V/FA: Processing queued up service tasks: 1
... de.dbremes.dbtradealert.playStore D/FA: Logging event (FE): select_content, Bundle[{_o=app, content_type=Refresh, item_id=2131624116}]
... de.dbremes.dbtradealert.playStore V/FA: Inactivity, disconnecting from AppMeasurementService

The next day go to https://console.firebase.google.com, select your app, and click Analytics. That will show the Dashboard with an overview of Firebase reporting.

To drill down click the Events tab and select “select_content” from the list of event types. The content graph shows the user interactions.

select_content statistics

select_content statistics

Back to Android Studio. When you switch to the nakedDebug variant it will report errors because PlayStoreHelper is missing from this source set. To fix that:

  1. Create a flavor specific directory structure similar to the previous one
  2. Add a new Java class named PlayStoreHelper to the respective package – “de.dbremes.dbtradealert (naked)” in my case
  3. Hide the default constructor and implement an empty static method reportAction()

Now Android Studio will still complain about a missing google-services.json file and an unresolved R symbol. To remedy that comment the line applying the google-services plugin in build.gradle out – I haven’t found a way to have Gradle apply plugins flavor specific.

After that let Gradle resync, remove the currently installed variant from the device, and let Android Studio install the new one. When you tap the Refresh button there should be no logcat activity pointing to Firebase Analytics.

4. Let Users Opt out of Tracking

Now that Firebase Analytics was added successfully it’s time to deactivate it – well, actually to allow users to opt out like the screenshot at the post’s beginning showed. This is actually a bit develish as the option’s setting of course has to be tracked. Implementing this consists of:

  1. Add a Firebase user property to track the option’s value for each user
  2. Add settings to control DbTradeAlert’s tracking
  3. Add code to enable / disable Firebase Analytics = control Google’s tracking
  4. Add code to set the Firebase user property
  5. Add code to check the setting before tracking anything

All code will be specific to the playStore flavor with only a placeholder implementation in naked’s PlayStoreHelper.

4.1 Add a Firebase User Property

To add a Firebase user property

  1. Go to https://console.firebase.google.com and log in
  2. Navigate to your app
  3. Navigate to its Analytics pane
  4. Navigate to the User Properties tab
  5. Add a new user property specifying a name (up to 25 characters) and a description (up to 150 characters)

Note that the name is case sensitive and it will take a few hours for user property data to show up. And maybe even more important: you cannot rename or delete a user property and they are limited to 25 per app.

Please note also that you need at least 10 users for data from user properties showing up in Firebase Analytics. That makes testing a bit harder as filters and audiences won’t be functional without that data.

4.2 Add Settings to Control DbTradeAlert’s Tracking

There are no built-in options to add a flavor specific setting to preferences.xml – unfortunately preference files don’t provide merging options or access to flavor settings like AndroidManifest.xml. In any case having an almost identical preference file for each flavor is usually the worst idea.

DbTradeAlert gets lucky here because it only needs to remove a preference at runtime. If an app has to add a preference back getting the order right can be tricky.

The isTrackingEnabled preference gets removed in SettingsFragment.onCreate():

package de.dbremes.dbtradealert;

import android.os.Bundle;
import android.preference.CheckBoxPreference;
import android.preference.PreferenceFragment;
import android.preference.PreferenceScreen;
import android.util.Log;

public class SettingsFragment extends PreferenceFragment {
    private static final String CLASS_NAME = "SettingsFragment";

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preferences);
        if (BuildConfig.FLAVOR.equals("naked")) {
            CheckBoxPreference trackingPreference
                    = (CheckBoxPreference) findPreference("tracking_preference");
            if (trackingPreference != null) {
                PreferenceScreen ps = getPreferenceScreen();
                ps.removePreference(trackingPreference);
                Log.d(CLASS_NAME, "onCreate(): tracking_preference removed");
            } else {
                Log.v(CLASS_NAME, "onCreate(): tracking_preference already removed");
            }
        }
    } // onCreate()
} // class SettingsFragment

This code uses the automatically generated BuildConfig class to determine the app’s flavor. Note that getPreferenceScreen() is not available in SettingsActivity and has been deprecated there in API level 11.

4.3 Add Code to Control Google’s Tracking and to Set the Firebase User Property

Flavor specific code can now use the infrastructure built in the previous sections. The first step is to extend PlayStoreHelper for the playStore flavor:

// ...

public class PlayStoreHelper {
    public final static String IS_TRACKING_ENABLED_USERPROPERY = "isTrackingEnabled";

    private PlayStoreHelper() {
        // Hide default construcor
    }

    public static void reportAction(
            @NotNull Context context, @NotNull String actionTitle, int actionId) {
        SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
        boolean isTrackingEnabled = sp.getBoolean(SettingsActivity.TRACKING_PREFERENCE_KEY, true);
        if (isTrackingEnabled) {
            Bundle bundle = new Bundle();
            bundle.putString(FirebaseAnalytics.Param.ITEM_ID, String.valueOf(actionId));
            bundle.putString(FirebaseAnalytics.Param.CONTENT_TYPE, actionTitle);
            FirebaseAnalytics.getInstance(context).logEvent(
                    FirebaseAnalytics.Event.SELECT_CONTENT, bundle);
        }
    } // reportAction()

    public static void setBooleanUserProperty(
            @NotNull Context context, @NotNull String propertyName, boolean propertyValue) {
        FirebaseAnalytics.getInstance(context).setUserProperty(
                propertyName, String.valueOf(propertyValue));
        if (propertyName.equals(IS_TRACKING_ENABLED_USERPROPERY)) {
            FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(propertyValue);
        }
    } // setBooleanUserProperty()
} // class PlayStoreHelper

That code is used by SettingsActivity.onSharedPreferenceChanged() – nothing special here:

// ...

public class SettingsActivity extends AppCompatActivity
        implements SharedPreferences.OnSharedPreferenceChangeListener {
    public static final String TRACKING_PREFERENCE_KEY = "tracking_preference";
    // ...

    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
        final String METHOD_NAME = "onSharedPreferenceChanged";
        Log.d(CLASS_NAME, METHOD_NAME + "(): key = " + key);
        switch (key) {
            case "auto_refresh_preference": {
                Intent intent = new Intent(this, QuoteRefreshScheduler.class);
                sendBroadcast(intent);
                break;
            }
            case BUSINESS_DAYS_PREFERENCE_KEY:
            case BUSINESS_HOURS_PREFERENCE_KEY:
                setBusinessTimesPreferenceSummary(key);
                break;
            case TRACKING_PREFERENCE_KEY: {
                boolean isEnabled = sharedPreferences.getBoolean(key, true);
                Context context = getApplicationContext();
                PlayStoreHelper.setBooleanUserProperty(
                        context, PlayStoreHelper.IS_TRACKING_ENABLED_USERPROPERY, isEnabled);
                break;
            }
            default:
                Log.e(CLASS_NAME, METHOD_NAME + "(): Unexpected key = " + key);
                break;
        }
    } // onSharedPreferenceChanged()

} // class SettingsActivity

A quick test:

  1. Run the playStoreDebug variant
  2. Tapping the Refresh button should yield the same log messages as before
  3. Open the Settings screen and opt out of tracking – logcat shows:
    ... de.dbremes.dbtradealert.playStore D/FA: Setting user property (FE): isTrackingEnabled, false
    ... de.dbremes.dbtradealert.playStore D/FA: Setting app measurement enabled (FE): false
  4. Open any screen – still local FA messages but nothing is tracked

The final step is to complete the naked flavor’s PlayStoreHelper, test if this flavor still works, and that the tracking option is missing from it’s Settings screen.

4.4 Track additional values

I also added user properties to track the number of reminders, securities, and watchlists people use. DbHelper got a new method for that:

// ...

public class DbHelper extends SQLiteOpenHelper {
    // ...

    public long getRecordCount(String tableName) {
        long result = -1;
        SQLiteDatabase db = this.getReadableDatabase();
        result = DatabaseUtils.queryNumEntries(db, tableName);
        Log.v(CLASS_NAME, tableName + " table has " + result + " records");
        return result;
    } // getRecordCount()

    // ...
} // class DbHelper 

This method is called from PlayStoreHelper which gets a new method too and defines the names of those new user properties:

// ...

public class PlayStoreHelper {
    private static final String CLASS_NAME = "PlayStoreHelper";
    public final static String IS_TRACKING_ENABLED_USERPROPERTY = "isTrackingEnabled";
    public final static String REMINDER_COUNT_USERPROPERTY = "reminderCount";
    public final static String SECURITY_COUNT_USERPROPERTY = "securityCount";
    public final static String WATCHLIST_COUNT_USERPROPERTY = "watchlistCount";

    // ...

    public static void setLongUserProperty(
            @NotNull Context context, @NotNull String propertyName, long propertyValue) {
        FirebaseAnalytics.getInstance(context).setUserProperty(
                propertyName, String.valueOf(propertyValue));
        Log.v(CLASS_NAME, String.format("%s(): %s=%s; %s=%d", "setLongUserProperty",
                "propertyName", propertyName, "propertyValue", propertyValue));
    } // setLongUserProperty()
} // class PlayStoreHelper

The PlayStoreHelper method is called from WatchlistListActivity.onActivityResult() when any of the management screens returns:

// ...

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

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        final String methodName = "onActivityResult";
        Context context = getApplicationContext();
        long recordCount;
        switch (requestCode) {
            case REMINDERS_MANAGEMENT_REQUEST:
                recordCount = this.dbHelper.getRecordCount(ReminderContract.Reminder.TABLE);
                PlayStoreHelper.setLongUserProperty(
                        context, PlayStoreHelper.REMINDER_COUNT_USERPROPERTY, recordCount);
                // Nothing else 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();
                recordCount = this.dbHelper.getRecordCount(WatchlistContract.Watchlist.TABLE);
                PlayStoreHelper.setLongUserProperty(
                        context, PlayStoreHelper.WATCHLIST_COUNT_USERPROPERTY, recordCount);
                break;
            case SECURITIES_MANAGEMENT_REQUEST:
                recordCount = this.dbHelper.getRecordCount(SecurityContract.Security.TABLE);
                PlayStoreHelper.setLongUserProperty(
                        context, PlayStoreHelper.SECURITY_COUNT_USERPROPERTY, recordCount);
            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()

    // ...
} // class WatchlistListActivity

The last step is to add the new user properties in the Firebase console like the previous sections did.

To test the new feature:

  1. Run the playStoreDebug variant
  2. Enable tracking in the Settings screen again
  3. Open the 3 management screens – each should produce a log entry showing that the respective Firebase user property was updated

Final note: if Firebase cannot connect to the mothership for some reason it will silently fail and retry after a few seconds. This is what you’ll see in logcat:

... de.dbremes.dbtradealert.playStore D/FirebaseInstanceId: background sync failed: TIMEOUT, retry in 10s
... de.dbremes.dbtradealert.playStore D/FirebaseInstanceId: background sync failed: SERVICE_NOT_AVAILABLE, retry in 20s

Next post: Add Google AdMob

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

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