Cdacians

Cdacians
Cdacians

Tuesday 7 August 2012

C2DM service


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.
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.
  • Create a new Gmail account that you will use expressly for your push-enabled application. Write down the email / password. You'll need them later.
  • Decide on your Android application's package name. You'll need this for the next step.
  • Go to the C2DM registration page and ask for permission to use their service. Here you'll need to provide your app's package name and the email address you just set up (this is the "Role account email").
  • Go to this page. Bookmark it. You can skim through it if you like, but it won't make much sense until after you're almost done coding everything. However, in hindsight, it makes perfect sense.
  • At the bottom of the page, there is a link for the Chrome to Phone sample app. Click it, grab the source (you'll have to check it out from svn). You'll need the code from the com.google.android.c2dmpackage later on.
Creating the Android clientCreate 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 serverThe 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.
  • Registration
  • Unregistration
  • Send Message
Device RegistrationEach 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 UnregistrationEach 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 MessageWhenever 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 tokenThis 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:
  • accountType=GOOGLE
  • Email=[your gmail account name. ex: foo@gmail.com]
  • Passwd=[your gmail password]
  • service=ac2dm
  • source=[company-appname-version; used for logging; ex: myco-pushapp-1.0]
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 messageOnce 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:
  • registrationId=[value from database; associated with the device id]
  • collapse_key=[some value, such as 1 or whatever; see the google doc page]
  • data.[key]=[payload]. There can be any number of data keys, which will each be send to the client as name/value pairs. But remember the limit is 1024 bytes for everything.
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
        }
    }

No comments:

Post a Comment