Wednesday, July 18, 2012

A really simple Android Jersey/Gson example


Last weekend I wanted to start playing with REST on android.  I set up a really simple Google App Engine server that basically runs nothing more than a Hello World server - you send it a shared User object with nothing but an emailAddress and it will give it back to you.

To recreate this you're going to need to download the following libraries: (the downloads are at the bottom - the jersey-json was a nightmare to track down the first time)

gson (i used 2.2.2):
jersey-client (i used 1.12):
jersey-core (i used 1.12):
jersey-json (i used 1.12):

The first thing I did was create a simple network handler.

There's 2 points to this that took me a minute to find:
  1. Android doesn't allow you to run Network Code from the main thread.  In order to return anything it would be necessary to have some type of Callback method.
  2. Certain versions of Jersey and Android don't play well together.  See this post: http://stackoverflow.com/questions/9342506/jersey-client-on-android-nullpointerexception.  Basically you will have to provide your own Implementation of Jersey's ServiceIteratorProvider class - Android apparently cannot load the one in the META-INF folder of the Jersey Jar. The code to get around this is copied directly out of this post and works perfectly - it is attached below.
So - the very first thing I decided to do before creating my NetworkHandler class was to create a simple Callback interface as shown below.

package com.exposure101.example.shared;

public interface Callback<T> {

  void callback(T t); 
} 

Next I made 2 classes that extend Android's AsyncTask class to do the actual GET and POST calls.  I made a simple enum to wrap the MIME types for JSON.  The source code is given below

Here's the MIMETypes enum

package com.exposure101.example.shared;

public enum MIMETypes {

  APPLICATION_JSON("application/json");
  
  private final String name;
  
  private MIMETypes(String name) {
    this.name = name;
  }
  
  public String getName() {
    return name;
  }
}

Here's the Get Task

package com.exposure101.example.json;

import android.os.AsyncTask;

import com.exposure101.example.shared.Callback;
import com.exposure101.example.shared.MIMETypes;
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.WebResource;

public class GetTask extends AsyncTask<String, String, String> {

  private final String url;
  private final Callback<String> callback;

  GetTask(String url, Callback<String> callback) {
    this.url = url;
    this.callback = callback;
  }

  @Override
  protected String doInBackground(String... params) {
    final Client client = Client.create();
    final WebResource resource = client.resource(url);
    final ClientResponse response = resource.accept(MIMETypes.APPLICATION_JSON.getName())
        .get(ClientResponse.class);
    return response.getEntity(String.class);
  }

  @Override
  protected void onPostExecute(String result) {
    callback.callback(result);
    super.onPostExecute(result);
  }
}

And here's the Post Task

package com.exposure101.example.json;

import android.os.AsyncTask;

import com.exposure101.example.shared.Callback;
import com.exposure101.example.shared.MIMETypes;
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.WebResource;

public class PostTask extends AsyncTask<String, String, String> {

  private final String url;
  private final String requestBody;
  private final Callback<String> callback;

  PostTask(String url, String requestBody, Callback<String> callback) {
    this.url = url;
    this.requestBody = requestBody;
    this.callback = callback;
  }

  @Override
  protected String doInBackground(String... params) {
    final Client client = Client.create();
    final WebResource resource = client.resource(url);
    final ClientResponse response = resource.type(MIMETypes.APPLICATION_JSON.getName())
        .post(ClientResponse.class, requestBody);
    if (response.getStatus() != 201 && response.getStatus() != 200) {
      throw new RuntimeException("failed: http error code = " + response.getStatus());
    }
    final String responseEntity = response.getEntity(String.class).replaceAll("\\\\", "");
    return responseEntity.substring(1, responseEntity.length() - 1);
  }

  @Override
  protected void onPostExecute(String result) {
    callback.callback(result);
    super.onPostExecute(result);
  }
}

With this I kind of cheated the system using the POST request - honestly I have no idea if you should do it like this.  On the server side, as soon as the POST is received I query the datastore to see if a User with the given email address exists.  If it's not already in the datastore I create one, either way I return it back across the wire as JSON with the google datastore id in it.  There's 2 things that I found happen.
  1. The entire response is broken up by \ marks. An example would be {\"id"\:\1001\,\"emailAddress"\: ... \}
  2. The entire JSON string is wrapped in quotation marks - as a quick hack to get around this I simply took a substring.
Now that I have some simple tasks up and ready I made a really simple NetworkHandler interface to read and write requests. 

package com.exposure101.example.json;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import com.exposure101.example.jersey.AndroidServiceIteratorProvider;
import com.exposure101.example.shared.Callback;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sun.jersey.spi.service.ServiceFinder;

public class NetworkHandler {

  private static NetworkHandler instance;

  public static synchronized NetworkHandler getInstance() {
    if (instance == null) {
      instance = new NetworkHandler();
    }
    return instance;
  }

  @SuppressWarnings("rawtypes")
  private NetworkHandler() {
    ServiceFinder.setIteratorProvider(new AndroidServiceIteratorProvider());
  }

  public <T> void read(final String url, final Class<T> clazz, final Callback<T> callback) {
    new GetTask(url, new Callback<String>() {

      @Override
      public void callback(String result) {
        callback.callback(new GsonBuilder().create().fromJson(result, clazz));
      }
    }).execute();
  }

  public <T> void readList(final String url, final Class<T[]> clazz, final Callback<List<T>> callback) {
    new GetTask(url, new Callback<String>() {

      @Override
      public void callback(String result) {
        final T[] array = new GsonBuilder().create().fromJson(result, clazz);
        callback.callback(new ArrayList<T>(Arrays.asList(array)));
      }
    }).execute();
  }

  public <T> void write(final String url, final Class<T> clazz, final T t, final Callback<T> callback) {
    final Gson gson = new GsonBuilder().create();
    new PostTask(url, gson.toJson(t), new Callback<String>() {

      @Override
      public void callback(String result) {
        callback.callback(gson.fromJson(result, clazz));
      }
    }).execute();
  }
}


That's pretty much it for the Network code.

All that's left is to create an activity to show the user accounts and send it to the server.

Basically there's only 2 things I want to happen here:
  1. Have all the accounts on the phone come up in a ListView on the top part of the screen.  (Note: the accounts on your phone aren't unique - there may be multiple accounts with the same email - we should only show unique email addresses)
  2. Have a button on the bottom to send the selected email address to the server.  If we receive a response we can then launch a new Intent to switch the Activity. (Note: while the server is processing we should indicate that to the user with a loading icon)
The xml layout for this is as follows:

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

    <Button
        android:id="@+id/select_user_account_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:text="@string/login" />

    <ListView
        android:id="@+id/user_accounts_list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_above="@id/select_user_account_button"
        android:layout_alignParentTop="true" />

</RelativeLayout>

and the actual Android Activity code is as follows:


package com.exposure101.example.activities;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.util.Patterns;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.Window;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ListView;

import com.exposure101.example.R;
import com.exposure101.example.json.NetworkHandler;
import com.exposure101.example.json.UserServiceURLs;
import com.exposure101.example.shared.Callback;
import com.exposure101.example.shared.entity.User;

public class UserAccountSelectActivity extends Activity {

  private ListView userAccountsListView;

  private Button selectUserAccountButton;

  private String selectedEmailAddress;

  /**
   * 
   */
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
    setContentView(R.layout.activity_user_account_select);
    userAccountsListView = (ListView) findViewById(R.id.user_accounts_list_view);
    selectUserAccountButton = (Button) findViewById(R.id.select_user_account_button);
    final Account[] accounts = initializeAccounts();
    final String[] emailAddresses = initializeEmailAddresses(accounts);
    initializeView(emailAddresses);
  }

  /**
   * get the accounts from android's account manager
   * 
   * @return
   */
  private Account[] initializeAccounts() {
    final Pattern emailPattern = Patterns.EMAIL_ADDRESS;
    final List<Account> accounts = new ArrayList<Account>(Arrays.asList(AccountManager.get(this)
        .getAccounts()));
    for (final Iterator<Account> it = accounts.iterator(); it.hasNext();) {
      final Account account = it.next();
      if (emailPattern.matcher(account.name).matches() == false) {
        it.remove();
      }
    }
    return accounts.toArray(new Account[accounts.size()]);
  }

  /**
   * get the email addresses from the accounts
   * 
   * @param accounts
   * @return
   */
  private String[] initializeEmailAddresses(Account[] accounts) {
    final List<String> emailAddresses = new ArrayList<String>(accounts.length);
    for (final Account account : accounts) {
      emailAddresses.add(account.name);
    }
    final Set<String> uniqueEmailAddresses = new HashSet<String>(emailAddresses);
    return uniqueEmailAddresses.toArray(new String[uniqueEmailAddresses.size()]);
  }

  /**
   * 
   * @param accounts
   */
  private void initializeView(String[] emailAddresses) {
    selectUserAccountButton.setEnabled(false);
    userAccountsListView.setAdapter(new ArrayAdapter<String>(this,
        android.R.layout.simple_list_item_single_choice, emailAddresses));
    userAccountsListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
    userAccountsListView.setOnItemClickListener(new OnItemClickListener() {

      @Override
      public void onItemClick(AdapterView<?> adapter, View view, int position, long arg3) {
        selectedEmailAddress = (String) userAccountsListView.getItemAtPosition(position);
        selectUserAccountButton.setEnabled(true);
      }
    });

    selectUserAccountButton.setOnClickListener(new OnClickListener() {

      @Override
      public void onClick(View v) {
        login();
      }
    });
  }

  /**
   * 
   * @param emailAddress
   */
  private void login() {
    final NetworkHandler networkHandler = NetworkHandler.getInstance();
    final String url = UserServiceURLs.POST.getURL();
    networkHandler.write(url, User.class, new User(selectedEmailAddress), new Callback<User>() {

      @Override
      public void callback(User user) {
        handleLogin();
      }
    });
  }

  /**
   * your call was successful
   */
  private void handleLogin() {
    setProgressBarIndeterminateVisibility(false);
    selectUserAccountButton.setEnabled(true);
    /*
     * startActivity(new Intent(UserAccountSelectActivity.this, TheNextExampleActivity.class));
     */
  }
}
 

And that's about it. 

Here's all the required libraries:  (The jersey-json one was a little hard to track down)

gson
jersey-client-1.2
jersey-core-1.2
json-jar-1.2

And here's the AndroidServiceIteratorProvider class.

package com.exposure101.example.jersey;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;

import android.util.Log;

import com.sun.jersey.spi.service.ServiceFinder.ServiceIteratorProvider;

public class AndroidServiceIteratorProvider<T> extends ServiceIteratorProvider<T> {

  private static final String TAG = AndroidServiceIteratorProvider.class.getSimpleName();
  private static final String MESSAGE = "Unable to load provider";

  private static final HashMap<String, String[]> SERVICES = new HashMap<String, String[]>();

  private static final String[] com_sun_jersey_spi_HeaderDelegateProvider = {
      "com.sun.jersey.core.impl.provider.header.MediaTypeProvider",
      "com.sun.jersey.core.impl.provider.header.StringProvider" };

  private static final String[] com_sun_jersey_spi_inject_InjectableProvider = {};

  private static final String[] javax_ws_rs_ext_MessageBodyReader = {
      "com.sun.jersey.core.impl.provider.entity.StringProvider",
      "com.sun.jersey.core.impl.provider.entity.ReaderProvider" };

  private static final String[] javax_ws_rs_ext_MessageBodyWriter = {
      "com.sun.jersey.core.impl.provider.entity.StringProvider",
      "com.sun.jersey.core.impl.provider.entity.ReaderProvider" };

  static {
    SERVICES.put("com.sun.jersey.spi.HeaderDelegateProvider",
        com_sun_jersey_spi_HeaderDelegateProvider);
    SERVICES.put("com.sun.jersey.spi.inject.InjectableProvider",
        com_sun_jersey_spi_inject_InjectableProvider);
    SERVICES.put("javax.ws.rs.ext.MessageBodyReader", javax_ws_rs_ext_MessageBodyReader);
    SERVICES.put("javax.ws.rs.ext.MessageBodyWriter", javax_ws_rs_ext_MessageBodyWriter);
    SERVICES.put("jersey-client-components", new String[] {});
    SERVICES.put("com.sun.jersey.client.proxy.ViewProxyProvider", new String[] {});
  }

  @SuppressWarnings("unchecked")
  @Override
  public Iterator<Class<T>> createClassIterator(Class<T> service, String serviceName,
      ClassLoader loader, boolean ignoreOnClassNotFound) {

    String[] classesNames = SERVICES.get(serviceName);
    int length = classesNames.length;
    ArrayList<Class<T>> classes = new ArrayList<Class<T>>(length);
    for (int i = 0; i < length; i++) {
      try {
        classes.add((Class<T>) Class.forName(classesNames[i]));
      } catch (ClassNotFoundException e) {
        Log.v(TAG, MESSAGE, e);
      }
    }
    return classes.iterator();
  }

  @Override
  public Iterator<T> createIterator(Class<T> service, String serviceName, ClassLoader loader,
      boolean ignoreOnClassNotFound) {

    String[] classesNames = SERVICES.get(serviceName);
    int length = classesNames.length;
    ArrayList<T> classes = new ArrayList<T>(length);
    for (int i = 0; i < length; i++) {
      try {
        classes.add(service.cast(Class.forName(classesNames[i]).newInstance()));
      } catch (IllegalAccessException e) {
        Log.v(TAG, MESSAGE, e);
      } catch (InstantiationException e) {
        Log.v(TAG, MESSAGE, e);
      } catch (ClassNotFoundException e) {
        Log.v(TAG, MESSAGE, e);
      }
    }

    return classes.iterator();
  }
}



Hope this helps.

2 comments: