Grails based survey system, the android app

Some time back I wrote an article describing the roosearch system I developed using grails. This is the second part, the android client, please checkout the previous article otherwise this might not make much sense!

After completing the grails component, I had a RESTful API available to me, and I just needed to build an app that could consume those services.

Customer lookup and QR codes

The app needs to be simple and quick to use, one of the things I remember from a UX discussion at DroidCon UK is “Don’t annoy your users, they control your app ratings and your income!”. In order to lookup the surveys quickly, I’ve added the ability to scan QR codes. Actually I didn’t have to do a great deal as there is already an app called ZXing by Google that scans QR codes, so I just needed to make Roosearch delegate to ZXing and handle the result.

Of course, we don’t want to exclude users that don’t have ZXing, or even a camera on their device, so I’ve also provided a text field where they can enter the customer Id manually if required.

When the user clicks on the “scan barcode” button, I first check if ZXing is installed using the following

    public void scanBarCode(View v) {
        final boolean scanAvailable = isIntentAvailable(this,
                "com.google.zxing.client.android.SCAN");
        if (!scanAvailable){
            Toast.makeText(this, "You need to install the ZXing barcode app to use this feature", Toast.LENGTH_SHORT).show();
            return;
        }

        Intent intent = new Intent("com.google.zxing.client.android.SCAN");
        intent.putExtra("SCAN_MODE", "QR_CODE_MODE");
        startActivityForResult(intent, 0);
    }

If the user does have ZXing installed on their device, and choose to use it, we can get the result back from the bar code scan using:

public void onActivityResult(int requestCode, int resultCode, Intent intent) {
        if (requestCode == 0) {
            if (resultCode == RESULT_OK) {
                String contents = intent.getStringExtra("SCAN_RESULT");
                performRooLookup(contents);
            } else if (resultCode == RESULT_CANCELED) {
                // Handle cancel
            }
        }
    }

    private void performRooLookup(String rooId) {
        if (StringUtils.isBlank(rooId)) {
            Toast.makeText(this, "Please enter a valid customer id", Toast.LENGTH_SHORT).show();
            return;
        }

        Integer customerId;
        try {
            customerId = Integer.parseInt(rooId);
        } catch (NumberFormatException e) {
            Toast.makeText(this, "Customer id needs to be numeric", Toast.LENGTH_SHORT).show();
            return;
        }
        new FindRooTask(this, new FindRooTaskCompleteListener()).execute(customerId);
    }

I then have the following buried in a service call, invoked by an AsyncTask, which handles finding Customer details:

    public Customer getCustomerDetails(int customerId) {

        try {
            final String url = "http://roosearchdev.jameselsey.cloudbees.net/api/customer/{query}";

            HttpHeaders requestHeaders = new HttpHeaders();

            // Create a new RestTemplate instance
            RestTemplate restTemplate = new RestTemplate();
            restTemplate.getMessageConverters().add(new MappingJacksonHttpMessageConverter());

            // Perform the HTTP GET request
            ResponseEntity<Customer> response = restTemplate.exchange(url, HttpMethod.GET,
                    new HttpEntity<Object>(requestHeaders), Customer.class, customerId);

            return response.getBody();
        } catch (Exception e) {
            System.out.println("Oops, got an error retrieving from server.. + e");
        }
         return null;
    }

A Customer looks like this:

public class Customer implements Parcelable {

    @JsonProperty("company_name")
    private String companyName;
    private String twitter;
    private String facebook;
    private List<SurveySummary> surveys = new ArrayList<SurveySummary>();
    //Accessors omitted
}

The SurveySummary just has a title and Id. The reason for just returning summaries is because a customer may have many surveys, and there is no need to obtain them all, we just obtain the title to display to the user, if selected, we’ll retrieve the survey by its id.

To recap, here are 2 screenshots that show the above; the landing screen, and then the customer display screen

Landing screen for Roosearch, where the user can enter a customer Id or scan a QR code

Landing screen for Roosearch, where the user can enter a customer Id or scan a QR code

Customer screen, display social media links, name, photo, and list of surveys that the customer has

Customer screen, display social media links, name, photo, and list of surveys that the customer has

 

The survey engine

This is where the magic happens. I have a single activity and single view that handles presenting the survey to the user. As the surveys can change number of questions, and number of responses, I needed a way of dynamically traversing the survey object and allowing user to move between the questions whilst retaining state of what they have selected so far.

I’ve created the following method that will redraw the layout for a given question id:

    public void drawQuestionOnScreen(int id) {
        TextView question = (TextView) findViewById(R.id.question);
        question.setText(s.getQuestion(id - 1).getText());   // subtract 1 as lists are indexed from 0

        LinearLayout linLay = (LinearLayout) findViewById(R.id.answers);
        linLay.removeAllViews();
        RadioGroup rg = new RadioGroup(this);
        rg.setId(1);
        for (int aIndex = 0; aIndex < s.getQuestion(id - 1).getResponses().size(); aIndex++) {
            Answer a = s.getQuestion(id - 1).getAvailableOption(aIndex);
            RadioButton button = new RadioButton(this);
            button.setText(a.getText());
            button.setTextColor(R.color.dark_text_color);
            button.setId(aIndex);
            rg.addView(button);
        }
        linLay.addView(rg);

        TextView status = (TextView) findViewById(R.id.status);
        status.setText(format("%d of %d", id, s.getQuestionCount()));
    }

As you can see, it will retrieve the question by Id, then iterate over the responses and generate RadioButtons. Moving to the next question is reasonably easy, firstly I work out if an option has been selected, and prevent moving on if not. After that, I mark the selected response in the survey object, and then work out if there is another question in the sequence to display, if not we can progress to the finish.

One of the questions in the given survey

One of the questions in the given survey

    public void next(View v) {
        RadioGroup rg = (RadioGroup) findViewById(1);

        int selectedRadioId = rg.getCheckedRadioButtonId();
        if(selectedRadioId == -1){
            Toast.makeText(this, "Please select a response", Toast.LENGTH_SHORT).show();
            return;
        }

        s.getQuestion(questionIndex - 1).getResponses().get(selectedRadioId).setSelected(true);
        // work out if there is another question, then move to it
        if (s.getQuestionCount() > 1 && questionIndex < s.getQuestionCount()) {
            questionIndex++;
            drawQuestionOnScreen(questionIndex);
        } else {
            // if there are no other questions, show dialog saying submit or not
            Toast.makeText(this, "Reached the end of the survey", Toast.LENGTH_SHORT).show();
            // HERE we should process the entire survey, crunch data and post off (maybe async)

            Intent i = new Intent(this, SurveyComplete.class);
            i.putExtra("com.roosearch.domain.Survey", s);
            startActivity(i);
        }
    }

A similar approach is needed for moving back to previous questions, determine if there is a previous question to move to then redraw the screen, like so:

    public void previous(View v) {
        // work out if there is a previous question, and if so move to it
        if (s.getQuestionCount() > 1 && questionIndex > 1) {
            questionIndex--;
            drawQuestionOnScreen(questionIndex);
        } else {
            //if there are no other questions, move back to home screen, finish() this and scrap any progress
            finish();
        }
    }

Once the user completes all questions, the SurveyComplete activity is invoked.

Completing a survey

When the user has completed all questions, the survey object is passed into the SurveyComplete activity, which handles sending the responses back to the grails web application.

@Override
    protected void onResume()
    {
        super.onResume();
        TextView tv = (TextView) findViewById(R.id.completeMessage);
        tv.setText("Thank you for taking the time to complete the survey");
        tv.setTextColor(R.color.dark_text_color);

        Survey s = getIntent().getExtras().getParcelable("com.roosearch.domain.Survey");

        if (s != null)
        {
            StringBuffer sb = new StringBuffer();
            sb.append("\n" + s.getTitle() + "\n");
            for (Question q : s.getQuestions())
            {
                sb.append("\nQ: " + q.getText());
                sb.append("\nA: " + q.getSelectedAnswer() + "\n");
            }
            tv.append("\n\n" + sb.toString());
        }

        new SurveyUploadTask(this, new SurveyUploadTaskCompleteListener()).execute(s);
    }

    public class SurveyUploadTaskCompleteListener implements AsyncTaskCompleteListener<Void> {
        @Override
        public void onTaskComplete(Void voidz) {
            Toast.makeText(SurveyComplete.this, "Survey uploaded", Toast.LENGTH_SHORT).show();
        }
    }

The activity uses an AsyncTask to post the data back to the grails API controller, and displays a toast when successful.

Survey completed, results uploaded, and summary presented to user

Survey completed, results uploaded, and summary presented to user

 

Wrapping it up

Overall quite a simple app, I spent probably around 2 or 3 weekends putting together, most of that time was spent getting to grips with some automated testing for android. The code is admittedly a little rough around the edges, but I was aiming for an MVP (most viable product) to get working, feel free to contribute or suggest improvements!

I chose to use maven, but would use gradle if I were to pick this up again. Be sure to check out the code on github and try running it against Roosearch web, it does work!

Click here for the source code on Github

Deploying the android libraries into your maven repository

Maven is a fantastic build tool, and a great addition to anyone developing on the android platform, however one of the first hurdles that people often stumble upon, is when their project involves one of the SDK libraries, such as Google Maps.

You’ll most likely see something like this when you attempt to first compile the project :

[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6.597s
[INFO] Finished at: Thu Jul 19 10:28:12 BST 2012
[INFO] Final Memory: 7M/81M
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal on project WifiSpotter: Could not resolve dependencies for project com.jameselsey.android.apps.wifispotter:WifiSpotter:apk:0.1-SNAPSHOT: Failed to collect dependencies for [com.google.android:android:jar:2.1.2 (provided), com.google.android:android-test:jar:2.2.1 (provided), com.pivotallabs:robolectric:jar:1.0 (test), junit:junit:jar:4.8.2 (test), com.google.android.maps:maps:jar:8_r1 (provided)]: Failed to read artifact descriptor for com.google.android.maps:maps:jar:8_r1: Could not transfer artifact com.google.android.maps:maps:pom:8_r1 from/to cloudbees-private-release-repository (https://repository-
[ERROR] jameselsey
[ERROR] .forge.cloudbees.com/release): IllegalArgumentException: Illegal character in authority at index 8: https://repository-
[ERROR] jameselsey
[ERROR] .forge.cloudbees.com/release/com/google/android/maps/maps/8_r1/maps-8_r1.pom

As we can see, maven is unable to find the maps artefact, and rightly so. This is because the Google jars are not available (at least not at the time of writing this) on the maven central repositories. Fortunately for us, its relatively easy to resolve this, we just need to obtain the artefacts from our android home area, and then install them to our maven repositories so our projects have access to them.

You could manually install all of them, but this would be quite a lengthy task, and relatively unnecessary with the help of the maven-android-sdk-deployer, this nifty little tool will extract the libraries from your local android SDK installation, and install them as maven dependencies, to your local repository. From there, you can just depend on them as any other maven artefact, such as :

<dependency>
  <groupId>android</groupId>
  <artifactId>android</artifactId>
  <version>4.1_r2</version>
  <scope>provided</scope>
</dependency>

There is no need for me to cover how to use this tool, since it is pretty well documented, however I would like to refer you to a previous post of mine regarding deploying maven artefacts to a CloudBees repository, combined with this post, you can quite easily and reliably maven-ise the android dependencies, and deploy them into the cloud. Then you can build your android projects using maven from anywhere, and also take advantage of the free Jenkins service they provide.

Remember to drop in the repository into the pom.xml, and then you can simply run :

mvn clean install deploy:deploy

Hope this helps, if anything is unclear, please let me know!

Android; how to display a map the easy way..

I’m seeing countless questions, literally on a daily basis on StackOverflow regarding using maps on Android. To be honest I’ve never come across these problems but it seems many people have trouble using the maps in their application, so I’ll provide a clear and easy set of instructions on how to do this.

Common issues

  1. API key is incorrect
  2. You are using the standard Android emulator and not the Google APIs.
  3. You have extended Activity instead of MapActivity

The Tutorial…

The first thing you need, is an application to put your map in. For this tutorial I’m suggesting that you just create a blank android project, to get familiar with how maps work, then you can shift it into your existing application. Using IntelliJ (or Eclipse, if you must), create a new android project. I’ve called mine “MapMe”.

This will give you just one activity, such as the following :

package com.jameselsey.mapme;

import android.app.Activity;
import android.os.Bundle;

public class MapMe extends Activity
{
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
}

I’d also suggest that you run this auto generated project to make sure your emulator starts up OK and you generally have a stable platform to build upon.

Next, we need to alter the emulator. The standard emulator that you get is the “android emulator”, which doesn’t allow you to use maps (at least not easily). This means that you need to create a slightly different emulator which does allow you to use maps, its quite easy.

Open up the android AVD manager (where you can create emulators / Android Virtual Devices). Click on the “available packages” option and download something called “Google APIs”, that should take a few minutes. When that is done, create a new AVD, be sure to check the “target” and make sure it is set to Google APIs. Once you’ve setup this emulator, make sure that your project is actually set to use it, by checking your run configurations and the target emulator.

Once thats done, I’d suggest you re-run the base application we’ve just created to make sure its all still OK.

OK so before we can finally jump into some code, you’ll need to go off and get an API key. This key allows you to use the Google maps API, I won’t explain how to do that here since theres already a rather good writeup from Google themselves, so go off and do that now.

Right lets hop into some code again, first thing you need to do is to add INTERNET permissions and uses-library into your AndroidManifest.xml file, mine looks as follows :

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.jameselsey.mapme"
      android:versionCode="1"
      android:versionName="1.0">
    <application android:label="MapMe" android:icon="@drawable/icon">
        <activity android:name="MapMe"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <uses-library android:name="com.google.android.maps" />
    </application>
    <uses-permission android:name="android.permission.INTERNET" />
</manifest> 

Next, go into main.xml under res/layout and add the following :

<com.google.android.maps.MapView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/mapview"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:clickable="true"
    android:apiKey="YOUR_API_KEY_HERE"
/>

Now we can start doctoring our activity.

Firstly, add the following import

import com.google.android.maps.MapActivity;

And make sure that you implement the method you inherit from MapActivity :

@Override
    protected boolean isRouteDisplayed()
    {
        return false;
    }

That is pretty much it, all you need to do now is to run the application, you should have the following end result :

Complete Source

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.jameselsey.mapme"
      android:versionCode="1"
      android:versionName="1.0">
    <application android:label="MapMe" android:icon="@drawable/icon">
        <activity android:name="MapMe"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <uses-library android:name="com.google.android.maps" />
    </application>
    <uses-permission android:name="android.permission.INTERNET" />
</manifest> 

res/layout/main.xml

<com.google.android.maps.MapView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/mapview"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:clickable="true"
    android:apiKey="YOUR_API_KEY_HERE"
/>

MapMe Activity

package com.jameselsey.mapme;

import android.os.Bundle;
import com.google.android.maps.MapActivity;

public class MapMe extends MapActivity
{
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    @Override
    protected boolean isRouteDisplayed()
    {
        return false;
    }
}

Common Issues

Problems with RuntimeException : stub

java.lang.RuntimeException: stub
   at com.google.android.maps.MapView.<init> (Unknown Source)

You need to make sure that

<uses-library android:name="com.google.android.maps" />

is a child of the application tag.

Problems with importing of the com.google.* package

You may come across the following error :

java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation

If there are problems with that import (com.google.*), chances are you don’t have the maps.jar on your class path. Go back into your AVD manager, check available packages, under 3rd party libraries you should find some libraries from Google Inc., download those. If that still doesn’t solve your issue, then double check that your android “facet” is correctly using the Google APIs target.

Also, make sure that you don’t have 2 copies of the maps.jar on your class path, if you already have the target specified to Google APIs, then you don’t need to implicitly specify maps.jar on the classpath.

Maps are displaying, but all I see is grey squares…

This is most likely because you have the wrong API key, please go and re-generate that again to make sure its OK.

Further Reading

  1. Official Google Documentation
  2. MobiForge Tutorial