Quote coming soon

27 Jun 2012

Making sense of the C2DM architecture. Providing example code and simple steps every developer needs to get this working in their own apps.
  1. Prerequisites
  2. Creating the Android client
  3. The 3rd party application server
  4. Device Registration
  5. Device Unregistration
  6. Send Message
  7. Getting an Auth token
  8. Sending a message
Prerequisites

IMPORTANT NOTICE

The C2DM service has been deprecated and will no longer be maintained (and eventually will be shut off). Google now has an official push service called Google Cloud Messaging, along with some great docs on how to use it.

Since i only wrote this article because the documentation for C2DM was so sketchy, i see no reason to update it for GCM since their docs are quite useful.



Before any of this will work, there's a few things that you need to do.
Creating the Android client

Create an Android project. Make sure the manifest has all the things listed on the C2DM page. Obviously you'll want to replace com.example.myapp with your own app's package. Also, make sure the package name matches what you entered on the C2DM registration page or you won't be able to use the service.

In addition, since we'll be using the google c2dm code to do the heavy lifting of services / messages / etc.., we'll need a couple of additional manifest items.

<manifest ...>
  ...
  <uses-permission android:name="android.permission.WAKE_LOCK" />
  <application ...>
    <service android:name=".C2DMReceiver" />

    <!-- and change the SEND permission android:name from ".C2DMReceiver" to this: -->
    <receiver android:name="com.google.android.c2dm.C2DMBroadcastReceiver" ... />
    
    ...
  </application>
</manifest>


Add the google C2dm classes (com.google.android.c2dm) into your project.

You'll need some way to store the registration_id for this instance of the app. I used a wrapper class around the Android SharedPreferences object to get/set key values.

Prefs.java

public class Prefs 
{
	public static SharedPreferences get(Context context)
	{
		return context.getSharedPreferences("SH_PUSHY", 0);
	}
	
	public static void addKey(Context context, String key, String val)
	{
		SharedPreferences settings = Prefs.get(context);
		SharedPreferences.Editor editor = settings.edit();
		editor.putString(key, val);
		editor.commit();
	}
	
	public static void removeKey(Context context, String key)
	{
		SharedPreferences settings = Prefs.get(context);
		SharedPreferences.Editor editor = settings.edit();
		editor.remove(key);
		editor.commit();
	}
	
	public static String getKey(Context context, String key)
	{
		SharedPreferences prefs = Prefs.get(context);
		return prefs.getString(key, null);
	}
}

You'll need a class that knows how to communicate with your 3rd party app server.
public class DeviceRegistrar {
	public static final String SENDER_ID = "xxx@gmail.com";
	public static final String KEY_DEVICE_REGISTRATION_ID = "deviceRegistrationID";
	private static final String APP_SERVER_URL = "xxx";
        private static final String REGISTER_URI = "/xxx/register";
        private static final String UNREGISTER_URI = "/xxxx/unregister";

    ...
}


It will need to handle REGISTER requests (where a registration id is received from the C2DM server and needs to be stored in your app server):
	public static void registerWithServer(Context context, String registrationId)
	{
		String deviceId = getDeviceId();
		
		//connect with 3rd party server and register the device
		//TODO: do this on a thread
		try {
			Log.d(TAG, "attempting to register with 3rd party app server...");
			HttpClient client = new DefaultHttpClient();
			HttpGet request = new HttpGet();
			request.setURI(new URI(APP_SERVER_URL + REGISTER_URI + "?deviceId=" + deviceId + "registrationId=" + registrationId));
			HttpResponse response = client.execute(request);
			StatusLine status = response.getStatusLine();
			if (status == null) 
				throw new IllegalStateException("no status from request");
			if (status.getStatusCode() != 200)
				throw new IllegalStateException(status.getReasonPhrase());
		} catch (Exception e) {
			Log.e(TAG, "unable to register: " + e.getMessage());
			//TODO: notify the user
			return;
		}
		
                //store for later
		Prefs.addKey(context, KEY_DEVICE_REGISTRATION_ID, registrationId);
		Log.d(TAG, "successfully registered device with 3rd party app server");
	}

And UNREGISTER requests (where a registration key is removed from the C2DM server and needs to be removed from yoru app server):
	public static void unregisterWithServer(Context context, String registrationId)
	{
		String deviceId = getDeviceId();

		//connect with 3rd party server and unregister the device
		//TODO: do this on a thread
		try {
			Log.d(TAG, "attempting to unregister with 3rd party app server...");
			HttpClient client = new DefaultHttpClient();
			HttpGet request = new HttpGet();
			request.setURI(new URI(APP_SERVER_URL + UNREGISTER_URI + "?deviceId=" + deviceId));
			HttpResponse response = client.execute(request);
			StatusLine status = response.getStatusLine();
			if (status == null) 
				throw new IllegalStateException("no status from request");
			if (status.getStatusCode() != 200)
				throw new IllegalStateException(status.getReasonPhrase());
		} catch (Exception e) {
			Log.e(TAG, "unable to unregister: " + e.getMessage());
			//TODO: notify the user
			return;
		}
		
                //remove local key so app doesn't try to accidentally use it
		Prefs.removeKey(context, KEY_DEVICE_REGISTRATION_ID);
		Log.d(TAG, "succesfully unregistered with 3rd party app server");
	}

In addition, you'll notice that there's a getDeviceId method call. Each device needs to pass it's unique ID to the server so it can be identified. There are many ways to get a unique device id. See this android developer blog article for ways to implement this.

Finally, you'll need to implement the C2DmReceiver class. This class extends the abstract Google class C2DmBaseReceiver. It allows you to receive callbacks for all of the underlying INTENT notifications.

C2DMReceiver

public class C2DMReceiver 
extends C2DMBaseReceiver 
{
	public C2DMReceiver()
	{
		//send the email address you set up earlier
		super(DeviceRegistrar.SENDER_ID);
	}
	
	@Override
	public void onRegistered(Context context, String registrationId) 
	throws IOException 
	{
		Log.d(TAG, "successfully registered with C2DM server; registrationId: " + registrationId);
		DeviceRegistrar.registerWithServer(context, registrationId);
	}
	
	@Override
	public void onUnregistered(Context context) 
	{
		Log.d(TAG, "successfully unregistered with C2DM server");
		String deviceRegistrationID = Prefs.getKey(context, DeviceRegistrar.KEY_DEVICE_REGISTRATION_ID);
		DeviceRegistrar.unregisterWithServer(context, deviceRegistrationID);
	}

	@Override
	public void onError(Context context, String errorId) 
	{
		//notify the user
		Log.e(TAG, "error with C2DM receiver: " + errorId);
		
		if ("ACCOUNT_MISSING".equals(errorId)) {
			//no Google account on the phone; ask the user to open the account manager and add a google account and then try again
			//TODO
			
		} else if ("AUTHENTICATION_FAILED".equals(errorId)) {
			//bad password (ask the user to enter password and try.  Q: what password - their google password or the sender_id password? ...)
			//i _think_ this goes hand in hand with google account; have them re-try their google account on the phone to ensure it's working
			//and then try again
			//TODO
			
		} else if ("TOO_MANY_REGISTRATIONS".equals(errorId)) {
			//user has too many apps registered; ask user to uninstall other apps and try again
			//TODO
			
		} else if ("INVALID_SENDER".equals(errorId)) {
			//this shouldn't happen in a properly configured system
			//TODO: send a message to app publisher?, inform user that service is down
			
		} else if ("PHONE_REGISTRATION_ERROR".equals(errorId)) {
			//the phone doesn't support C2DM; inform the user
			//TODO
			
		} //else: SERVICE_NOT_AVAILABLE is handled by the super class and does exponential backoff retries
		
	}

	@Override
	protected void onMessage(Context context, Intent intent) 
	{
		Bundle extras = intent.getExtras();
		if (extras != null) {
			//parse the message and do something with it.
			//For example, if the server sent the payload as "data.message=xxx", here you would have an extra called "message"
			String message = extras.getString("message");
			Log.i(TAG, "received message: " + message);
			MainActivity.setMessage(message);
		}
	}

}


Ok, so finally for real: you'll need to actually initiate the register and unregister processes at some point to start the ball rolling. Auto register when app starts if the registration key isn't set, a menu item, a button press, whatever. But to do it, just call the relevant C2DM method:
    //to register
    C2DMessaging.register(this /*the application context*/, DeviceRegistrar.SENDER_ID);

    //to unregister
    C2DMessaging.unregister(this /*the application context*/);
The 3rd party application server

The client is useless unless there's a server app that can initiate messages being sent. In this section i'll describe what needs to go into the app server and how it communicates with the google C2DM server. I implemented the backend as a series of java servlets, but you can implement it however you want as long as it can talk HTTPS POST to the Google's C2DM servers.

You'll need 3 pieces.

Device Registration

Each time a device registers (from the app) with the C2DM server, it will also need to let your app server know its registration ID so that the app server can send messages to it later.
Store the deviceId and registrationId in a database (or a text file or a stone tablet. whatever - just as long as you can get it back later).

Device Unregistration

Each time a device unregisters (from the app) with the C2DM server, it will also need to let your app server know about it so that it can be removed from the list and no longer be sent messages.
Find the deviceId and matching registrationId and remove them from the database (or other long term storage mechanism).

Send Message

Whenever the server wants to send a message to all registered devices (or a subset of them), it communicates with the C2DM server. This is a 2 step process.

Step 1 is getting an authorization token which all google services need (be that c2dm, calendar, maps, email, etc..). This auth token doesn't need to be retrieved every time - just initially and then periodically whenever the c2dm server informs the server that the auth key is stale.

Step 2 is actually sending the message.

I'll discuss each in turn below.

Getting an Auth token

This is where the separate email account you setup really comes into play. In order for a server to communicate with any google service, it needs an auth token, which is tied to a google account. You have to send the email address and password in to this request for your server. Fortunately it's over https and on a backend server, so it's fairly secure. Still - probably not a good idea to use your real gmail account.

First, check your database to see if you already have an auth token. If so, just try to use it. If it isn't valid, the server will tell you and you can re-request one.
For all the gory details on this, see the google page on ClientLogin. Do an HTTPS POST to:

https://www.google.com/accounts/ClientLogin
Make sure to set the Content-Length header appropriately, and also set the Content-Type header to:
application/x-www-form-urlencoded
Pass along the following fields: You will either receive a 200 or 403 response.

200

Parse the body of the response and look for the line AUTH=[a really long string]. Store the really long string - that's your auth code.

403

The body of the response will have a Url=[url] line that you can display to the user. It will also have an Error=[error] line which describes the error. Switch on the error and handle it appropriately. See the google doc page for the list of possible errors. It may be something like "BadAuthentication", "AccountDisabled", etc.

Before i show my servlet implementation, here's a little helper class you might need. Since we're using SSL, it seems there's a hsotname issue or key problem or something. In order to prevent that from stopping the java code cold in its tracks, you can fake out the hostname verifier so that it won't complain if the key it's viewing isn't from the domain it thinks it should be from. Use this at your own risk - it introduces a security problem.
    private static class FakeHostnameVerifier 
    implements HostnameVerifier 
    { 
        public boolean verify(String hostname, SSLSession session) 
        { 
            return true; 
        } 
    } 
Here is my servlet code for getting an auth token.
    private String getAuthToken()
    {
        String authToken = _dao.getAuthToken();
        if (authToken != null) {
            _log.info("retrieved auth token from db: " + authToken);
            return authToken;
        }
        
        _log.info("asking C2DM server for auth token...");
        
        StringBuilder buf = new StringBuilder();
        HttpsURLConnection.setDefaultHostnameVerifier(new FakeHostnameVerifier()); 
        HttpsURLConnection request = null;
        OutputStreamWriter post = null;
        try {
            URL url = new URL("https://www.google.com/accounts/ClientLogin");
            request = (HttpsURLConnection) url.openConnection();
            request.setDoOutput(true);
            request.setDoInput(true);

            buf.append("accountType").append("=").append((URLEncoder.encode("GOOGLE", "UTF-8")));
            buf.append("&Email").append("=").append((URLEncoder.encode(SENDER_ID, "UTF-8")));
            buf.append("&Passwd").append("=").append((URLEncoder.encode(SENDER_PW, "UTF-8")));
            buf.append("&service").append("=").append((URLEncoder.encode("ac2dm", "UTF-8")));
            buf.append("&source").append("=").append((URLEncoder.encode("myco-pushapp-1.0", "UTF-8")));
            
            request.setRequestMethod("POST");
            request.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            request.setRequestProperty("Content-Length", buf.toString().getBytes().length+"");
            
            post = new OutputStreamWriter(request.getOutputStream());
            post.write(buf.toString());
            post.flush();
            
            int code = request.getResponseCode();
            _log.info("response code: " + request.getResponseCode());
            _log.info("response message: " + request.getResponseMessage());
            if (code == 200) {
                BufferedReader in = new BufferedReader(new InputStreamReader(request.getInputStream()));
                buf = new StringBuilder();
                String inputLine;
                while ((inputLine = in.readLine()) != null) {
                    if (inputLine.startsWith("Auth=")) {
                        authToken = inputLine.substring(5);
                    }
                    buf.append(inputLine);
                }
                post.close();
                in.close();
                _log.info("response from C2DM server:\n" + buf.toString());
                
            } else if (code == 403) {
                //TODO: handle error conditions
            }
            
            if (authToken != null) {
                _log.info("storing auth token: " + authToken);
                _dao.saveAuthToken(authToken);
            }
            
            return authToken;
            
        } catch (Exception e) {
            _log.error("unable to make https post request to c2dm server", e);
            //TODO: do something about it
            return null;
        }
    }
Sending a message

Once that pesky auth token is secured, you can get down to actually sending a message. Grab a list of devices (along with their registration codes) that you want to send to and do an HTTPS POST to:

https://android.apis.google.com/c2dm/send
Make sure to set the Content-Type and Content-Length headers the same as for the auth token. In addition, you'll need to set another special header which contains the auth token:
Authorization=GoogleLogin auth=[big old auth token string]
Then set the following fields: There are a few additional optional fields you can send. See the doc page for the full list, and also for an explanation of the collapse_key. It's basically a way to skip multiple messages if the phone is off so when the user turns the device on they don't get flooded (i.e. instead of getting 50 email notifications, they just get 1).

The response will be one of the following:

401

The auth token was invalid. Try to get another one. The one you used might be stale.

503

The C2DM server is unavailable; try again, but be sure to use exponential backoff or risk being blacklisted. Also, check the response header for Retry-After and use it if found.

200

It _may_ contain an error. Check for them, and if so, deal with it. See the documentation page for details on possible errors. It can be something like "QuotaExceeded", "MessageTooBig", etc.
Here is my servlet code for sending a message to a registered device (call this in a loop to send to multiple devices). I don't think you can send to many devices at once.
    private void sendMessage(String authToken, String collapseKey, String registrationId, String message)
    throws Exception
    {
        _log.info("sending message...");
        
        HttpsURLConnection.setDefaultHostnameVerifier(new FakeHostnameVerifier()); 
        URL url = new URL("https://android.apis.google.com/c2dm/send");
        HttpsURLConnection request = (HttpsURLConnection) url.openConnection();
        request.setDoOutput(true);
        request.setDoInput(true);

        StringBuilder buf = new StringBuilder();
        buf.append("registration_id").append("=").append((URLEncoder.encode(registrationId, "UTF-8")));
        buf.append("&collapse_key").append("=").append((URLEncoder.encode(collapseKey, "UTF-8")));
        buf.append("&data.message").append("=").append((URLEncoder.encode(message, "UTF-8")));
        
        request.setRequestMethod("POST");
        request.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
        request.setRequestProperty("Content-Length", buf.toString().getBytes().length+"");
        request.setRequestProperty("Authorization", "GoogleLogin auth=" + authToken);
        
        OutputStreamWriter post = new OutputStreamWriter(request.getOutputStream());
        post.write(buf.toString());
        post.flush();
        
        BufferedReader in = new BufferedReader(new InputStreamReader(request.getInputStream()));
        buf = new StringBuilder();
        String inputLine;
        while ((inputLine = in.readLine()) != null) {
            buf.append(inputLine);
        }
        post.close();
        in.close();

        _log.info("response from C2DM server:\n" + buf.toString());
        
        int code = request.getResponseCode();
        _log.info("response code: " + request.getResponseCode());
        _log.info("response message: " + request.getResponseMessage());
        if (code == 200) {
            //TODO: check for an error and if so, handle
            
        } else if (code == 503) {
            //TODO: check for Retry-After header; use exponential backoff and try again
            
        } else if (code == 401) {
            //TODO: get a new auth token
        }
    }