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:
- 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.
- 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.
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.
- The entire response is broken up by \ marks. An example would be {\"id"\:\1001\,\"emailAddress"\: ... \}
- The entire JSON string is wrapped in quotation marks - as a quick hack to get around this I simply took a substring.
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:
- 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)
- 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)
<?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.