Il y a 10 ans -
Temps de lecture 20 minutes
Google Cloud Messaging – Mise en place
Lors du précédent article nous avons pu découvrir ensemble les différentes fonctionnalités offertes par Google Cloud Messaging. Après la théorie vient l’heure de la pratique ! Grâce à cet article, vous découvrirez comment mettre en place GCM à la fois dans votre application mobile mais aussi au sein de votre backend.
Le backend
Pour cet article, le framework Play! (2.1.0) a été utilisé afin d’exposer les services REST utilisés par l’application mobile. La communication avec ces services se fait au format JSON.
Prérequis
Google met à disposition une librairie facilitant l’envoi de messages aux serveurs GCM (gcm-server.jar disponible avec le SDK) dont nous verrons le fonctionnement un peu plus loin. Le problème est qu’elle n’est disponible dans aucun repo Maven officiel. Fort heureusement, certains contributeurs ont eu la bonne idée de mettre à disposition cette librairie via des repos hébergés sur github. Pour ajouter cette dépendance dans Play!, modifiez le fichier Build.scala comme suit :
object ApplicationBuild extends Build { val appVersion = "1.0-SNAPSHOT" val appName = "gcm-notification-server" val appDependencies = Seq( "com.google.android.gcm" % "gcm-server" % "1.0.2", jdbc, anorm ) val main = play.Project(appName, appVersion, appDependencies).settings(cloudBeesSettings: _*) .settings(CloudBees.applicationId := Some("gcm-server-sample")) .settings(resolvers += "GCM Server Repository" at "https://raw.github" + ".com/slorber/gcm-server-repository/master/releases/") }
N.B. Vous pouvez aussi forker le projet pour disposer de votre propre repository.
Ensuite, comme précisé dans le précédent article, vous aurez besoin d’une clé d’API valide afin de pouvoir utiliser le service GCM côté serveur.
Les services exposés
Notre backend expose trois services dont deux ne seront consommés que par les devices mobiles :
- Un service d’inscription : il doit permettre à un appareil mobile d’envoyer sa clé d’enregistrement obtenue auprès des serveurs GCM
- Un service de désinscription : si un appareil ne souhaite plus être notifié il doit pouvoir en avertir le backend
- Un service d’envoi de message : il sera utilisé afin de vérifier que le push de notification fonctionne correctement
Les services d’inscription/de désinscription sont assez classiques et documentés et ne présentent pas un réel intérêt à être étudiés dans cet article. L’essentiel est de savoir qu’ils permettent d’enregistrer/supprimer des clés GCM en base de données.
L’envoi de messages
Pour cet article un service REST est exposé afin de tester l’envoi de messages. Dans une application réelle, cet envoi peut très bien intervenir suite à un traitement interne. Dans notre cas, un POST sur l’URI /notification/push de notre backend déclenchera l’envoi d’un message à tous les devices mobiles préalablement enregistrés. Le format du message est le suivant :
{ "message" : "le message à envoyer" "collapseKey" : "permet de spécifier une collapse key -> ce champs est optionnel" "ttl" : "permet d'overrider la ttl par défaut -> ce champ est optionnel" }
Pour commencer nous ajoutons une nouvelle entrée dans le fichier routes de Play! :
## Notification push POST /notification/push controllers.NotificationService.pushNotification
Maintenant jetons un oeil sur la méthode pushNotification de la classe NotificationService :
def pushNotification() = Action(parse.json) { implicit request => import play.api.libs.json.Json.fromJson import utils.Results._ import models.Notification.NotificationFormat import play.api.libs.concurrent.Execution.Implicits._ val allRegistrationIds = Device.allRegistrationIds val promiseOfMulticastResults = Future.sequence(NotificationSender push(fromJson(request.body).get, allRegistrationIds)) Async { promiseOfMulticastResults.toResults.map(results => { results zip (Stream.from(0)) map ({ case (result, currentDeviceIndex) => NotificationSender handleResult(allRegistrationIds(currentDeviceIndex), result) }) NoContent }).recover({ case _ => NoContent }) } }
Cette méthode se divise en deux grandes parties : l’envoi et la gestion des erreurs en retour. Cette gestion des erreurs sera détaillée dans le prochain paragraphe. Pour ce qui est de l’envoi, la première étape est de récupérer l’ensemble des clés d’enregistrement présentes dans la base (cf ligne 7). Ensuite nous adaptons le message JSON en un objet de notre model : Notification. Cette classe contient un objet implicite NotificationFormat facilitant la conversion du JSON (i.e. ligne 8 : fromJson(request.body)).
package models import com.google.android.gcm.server.Message import play.api.libs.json.{JsValue, Format} case class Notification(message: String, collapseKey: Option[String], ttl: Option[Int]) { // Construit un message GCM à partir des informations de la notification def asMessage: Message = { val messageBuilder = new Message.Builder() messageBuilder.addData("message", message) if (collapseKey.isDefined) { messageBuilder.collapseKey(collapseKey.get) } if (ttl.isDefined) { messageBuilder.timeToLive(ttl.get) } messageBuilder.build() } } object Notification { implicit object NotificationFormat extends Format[Notification] { def writes(o: Notification) = null def reads(json: JsValue) = { JsSuccess(Notification( (json \ "message").as[String], (json \ "collapseKey").asOpt[String], (json \ "ttl").asOpt[Int])) } } }
La classe NotificationSender est responsable de l’envoi effectif des messages. Nous lui passons alors la liste des clés avec la notification à transmettre au travers de la méthode push.
object NotificationSender { val MaxMulticastSize = 1000 val MaxRetry = 5 val Sender: Sender = new Sender("API_KEY") def push(notification: Notification, regIdsList: Array[String]): List[Promise[MulticastResult]] = { val message = notification.asMessage (regIdsList chunk MaxMulticastSize map (regIds => { Akka future (Sender send(message, regIds.toList, 5)) })).toList } ...
Dans cette classe, nous initialisons un objet Sender mis à disposition via la librairie gcm-server avec la clé d’API générée au préalable (cf. précédent article). Ensuite nous divisons la liste des éléments en lot de 1000 éléments maximum et nous appellons la méthode send de manière asynchrone. La méthode asMessage permet de construire un message GCM avec les informations reçues en entrée de notre service REST. Elle utilise la classe Message.Builder de la librairie gcm-server. Elle va convertir et adapter au format JSON le message en entrée puis effectuera un POST sur l’url https://android.googleapis.com/gcm/send. Il est possible de préciser le nombre de tentatives maximum si l’envoi échoue, un backoff exponentiel est appliqué entre chaque appel. Si vous ne souhaitez pas rejouer l’envoi en cas d’échec, il faudra privilégier la méthode sendNoRetry.
La gestion du MulticastResult
La méthode push retourne une liste de promesses de MulticastResult, nous utilisons la méthode Promise.sequence (ligne 8 de la méthode pushNotification de la classe NotificationService) afin de convertir cette liste en une promesse de liste de MulticastResult plus facile à manipuler. La classe MulticastResult contient une liste de Result qui décrit le statut de l’envoi du message pour chacune des clés d’enregistrement. Le seul souci est que pour retrouver la clé d’enregistrement associée à un Result, il faut se référer à la position de ce dernier dans la liste des Result et récupérer la clé d’enregistrement à la même position dans celle des clés. Cela explique la nécessité de maintenir un index lors du parcours de la liste des Result. Ensuite nous pouvons déléguer le tuple de valeurs à la méthode handleResult du NotificationSender :
def handleResult(regId: String, result: Result) { Option(result.getMessageId) match { case Some(s) => handleMultipleRegistration(regId, Option(result.getCanonicalRegistrationId)) case None => handleError(result.getErrorCodeName, regId) } } def handleMultipleRegistration(deviceRegistrationId: String, canonicalRegistrationId: Option[String]) { if (canonicalRegistrationId.isDefined) { H2DbDeviceStorage updateRegistrationId(deviceRegistrationId, canonicalRegistrationId.get) } } def handleError(deviceRegistrationId: String, errorCode: String) { errorCode match { case Constants.ERROR_NOT_REGISTERED => H2DbDeviceStorage delete (deviceRegistrationId) case _ => // Handle errors as you want } }
Le traitement d’un Result est le suivant :
- Si le messageId est non null, l’envoi du message a réussi cependant il faut vérifier si nous n’avons pas affaire à un enregistrement multiple (i.e. le même device s’est enregistré plus d’une fois). Pour cela il faut vérifier si le champ canonicalRegistrationId est non null, dans ce cas là il faut mettre à jour la clé GCM en base de données avec la valeur de ce champs.
- Si le messageId est null cela signifie qu’une erreur est survenue. Un cas classique d’erreur est qu’un téléphone s’est désinscrit du service GCM sans pouvoir en avertir le backend. Dans ce cas le champs errorCodeName vaut Constants.ERROR_NOT_REGISTERED, vu que le téléphone ne souhaite plus être notifié il faut supprimer la clé GCM de la base de données.
La partie mobile
L’application mobile utilisée pour cet article est celle présente dans les exemples fournis avec le SDK ansi que quelques refactoring afin de simplifier le code. Son fonctionnement est simple, elle notifie l’utilisateur de la réception d’un message GCM et en affiche le contenu sur la page principale.
Prérequis
Pour pouvoir utiliser les exemples de code ci-dessous, en plus d’avoir un backend qui tourne vous devrez renseigner deux constantes. La première concerne l’url d’accès de votre serveur (Commons.SERVER_ROOT_URL). Quant à la deuxième, il s’agit de votre project number (voir article précédent) qui devra être saisi dans le champs Commons.SENDER_ID.
Description globale du workflow d’inscription
Pour s’inscrire au service GCM, la première étape est de vérifier si notre application possède déjà une clé GCM valide. Deux alternatives sont alors possibles :
- L’application dispose d’une clé GCM valide, il faut vérifier si notre appareil mobile a pu la transmettre à notre backend. Si ce n’est pas le cas, on tente à nouveau d’envoyer cette information. En cas d’échecs répétés, rien ne sert de continuer on désinscrit l’application du service.
- L’application souhaite s’enregistrer afin d’obtenir une clé valide. On envoie alors un intent avec les informations nécessaires (essentiellement le project number) . A ce moment là, un broadcast receiver que nous verrons un peu plus loin, réceptionnera le statut de l’inscription. Il délègue alors le traitement de ce message à un service qui aura à charge de déterminer si l’inscription a réussi ou non. Si l’inscription a échoué, elle sera rejouée tout en appliquant un backoff exponentiel. Quand l’inscription est réussie, on reproduit les mêmes étapes qu’au 1. pour l’envoi des informations au backend.
Grâce à la librairie GCM la plupart de ces étapes ont été grandement simplifiées. Nous allons voir par la suite les différentes classes à disposition pour faciliter la mise en place du service.
Ajout des permissions
Afin de pouvoir utiliser le service GCM, notre application doit disposer des autorisations nécessaires, elles sont au nombre de 6 :
- Tout d’abord GCM n’est disponible qu’à partir de la version 2.2 d’Android, vous ne pourrez pas avoir un android:minSdkVersion inférieur à 8
- Une connexion internet est nécessaire pour pouvoir communiquer avec le service GCM
- Un compte google est obligatoire pour accéder au service
- Vous devez avoir la permission de poser un wake lock pour empêcher la mise en veille du processeur lors de la réception d’un message
- Il faut autoriser la réception de message GCM (com.google.android.c2dm.permission.RECEIVE). On remarquera au passage les restes de l’ancien projet c2dm dans les permissions.
- Pour finir vous devez créer une permission propre à votre application afin de vous assurer qu’elle sera la seule à recevoir ses messages. Le format de cette permission doit absolument être PACKAGE.permission.C2D_message
<!-- GCM requires Android SDK version 2.2 (API level 8) or above. --> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="17"/> <!-- GCM connects to Google Services. --> <uses-permission android:name="android.permission.INTERNET" /> <!-- GCM requires a Google account. --> <uses-permission android:name="android.permission.GET_ACCOUNTS" /> <!-- Keeps the processor from sleeping when a message is received. --> <uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- Creates a custom permission so only this app can receive its messages. NOTE: the permission *must* be called PACKAGE.permission.C2D_MESSAGE, where PACKAGE is the application's package name. --> <permission android:name="com.google.android.gcm.demo.app.permission.C2D_MESSAGE" android:protectionLevel="signature" /> <uses-permission android:name="com.google.android.gcm.demo.app.permission.C2D_MESSAGE" /> <!-- This app has permission to register and receive data message. --> <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
GCMRegistrar
GCMRegistrar est une classe utilitaire facilitant l’enregistrement des appareils mobiles. Pour commencer, elle met à disposition deux méthodes permettant de vérifier si la configuration de l’appareil mobile est valide pour utiliser GCM :
- checkDevice : vérifie que la version du sdk n’est pas inférieure à 8 et que le device dispose des classes du package GSF (Google Services Framework)
- checkManifest : s’assure que le manifest contient toutes les permissions nécessaires, mais aussi vérifie si le broadcast receiver pour GCM est bien configuré. Cette méthode peut être appelée lors du développement mais une fois en production, il n’est plus nécessaire de faire ces vérifications
Ensuite, elle expose la méthode register qui s’occupe de construire et d’envoyer l’intent d’enregistrement (de même pour la méthode unregister pour la désinscription). Cette classe permet aussi de récupérer la clé GCM d’une application (getRegistrationId). Il est intéressant de voir que cette clé, une fois récupérée, est stockée avec la version de l’application dans les données de préférences. En effet, dans la documentation officielle, il est bien précisé qu’une clé GCM peut devenir invalide suite à un upgrade de l’application. Lors du premier appel à getRegistrationId suite à une mise à jour, la clé stockée ne sera pas renvoyée afin de recommencer la procédure d’inscription.
GCMRegistrar fournit aussi un utilitaire permettant de sauvegarder le statut de l’envoi de la clé GCM au backend (setRegisteredOnServer). Cette information ne reste pas valide ad vitam eternam et par défaut expire au bout de 7 jours. Si vous trouvez ce délai trop court, vous pouvez le modifier en utilisant setRegisterOnServerLifespan. Cette méthode stockera dans les préférences la durée de validité souhaitée.
Pour finir, il faut savoir que GCMRegistrar est utilisée par les classes de base de la librairie GCM (GCMBaseIntentService et GCMBroadcastReceiver). Notamment si le service GCM n’est pas disponible lors de la phase d’inscription, un broadcast receiver sera enregistré au runtime afin de récupérer le message notifiant le rejeu de l’inscription. Ce broadcast receiver est enregistré avec le contexte de l’application. Il ne faut pas oublier d’appeler la méthode GCMRegistrar.onDestroy lorsque votre activité se termine. Néanmoins faites attention car il faut lui passer le contexte de l’application en paramètre et non celui de l’activité. Dans le cas contraire une erreur surviendra lors de la désinscription du broadcast receiver.
GCMBroadcastReceiver
La librairie GCM met à disposition un broadcast receiver (GCMBroadcastReceiver) permettant de réceptionner les messages GCM. Par défaut il délègue le traitement de chaque message à un service dont le nom est GCMIntentService et qui doit obligatoirement se trouver à la racine du package de l’application. Il est cependant préférable de pouvoir regrouper toutes les classes concernant GCM dans un même package que nous nommerons gcm. Pour pouvoir y parvenir, nous devons étendre la classe GCMBroadcastReceiver et overrider la méthode getGCMIntentServiceClassName afin de retourner le nom canonique du service qui gère nos messages (dans notre cas il s’agira de .gcm.GcmIntentService). Ce qui nous donne la configuration suivante dans l’AndroidManifest :
<receiver android:name=".gcm.GcmBroadcastReceiver" android:permission="com.google.android.c2dm.permission.SEND"> <intent-filter> <!-- Receives the actual messages. --> <action android:name="com.google.android.c2dm.intent.RECEIVE" /> <!-- Receives the registration id. --> <action android:name="com.google.android.c2dm.intent.REGISTRATION" /> <category android:name="com.google.android.gcm.demo.app" /> </intent-filter> </receiver> <service android:name=".gcm.GcmIntentService" />
Notre broadcast receiver, quant à lui, ressemblera à :
public class GcmBroadcastReceiver extends GCMBroadcastReceiver { public GcmBroadcastReceiver() { super(); } @Override protected String getGCMIntentServiceClassName(Context context) { return GcmIntentService.class.getCanonicalName(); } }
GCMBaseIntentService
En plus du broadcast receiver, la librairie gcm fournit un squelette de service qui s’occupe de la gestion du wake lock et détermine la nature des messages reçus pour en déléguer le traitement à des méthodes spécifiques qui sont au nombre de 6 :
- onRegistered : appelée quand le message reçu provient du callback d’enregistrement de GCM (l’action de l’intent est com.google.android.c2dm.intent.REGISTRATION) et que la clé d’enregistrement n’est pas null. Cela signifie que l’enregistrement auprès des serveurs GCM s’est bien déroulé
- onUnregistered : idem que précédemment, excepté que la clé d’enregistrement est null. Cela signifie que la désinscription du service a réussi
- onMessage : appelée quand le message provient du callback de réception de GCM (l’action de l’intent est com.google.android.c2dm.intent.RECEIVE) et que l’extra "message_type" de l’intent n’est pas "deleted_messages". Il s’agit tout simplement de la réception d’un message
- onDeletedMessages : idem que précédemment, excepté que l’extra "message_type" est valorisé à "deleted_messages". Ce type d’information ne peut être reçu que pour des messages envoyés avec une collapse_key. Il est alors possible de récupérer le nombre de messages supprimés vie l’extra "total_deleted"
- onRecoverableError : appelée si et seulement si l’extra "error" vaut "SERVICE_NOT_AVAILABLE". A ce moment là, si cette méthode retourne true, l’envoi de l’intent sera rejoué avec application d’un backoff exponentiel. L’implémentation par défaut de cette méthode retourne true.
- onError : toutes les erreurs autres que " SERVICE_NOT_AVAILABLE"
N.B. Seules les phases d’inscription/de désinscription peuvent générer des erreurs (i.e. intent avec un extra "error")
Notre service gérant les messages est relativement simple puisque pour chaque action il affichera un message sur l’écran d’accueil et notifiera l’utilisateur s’il s’agit de la réception d’un message. Cela nous donne le code suivant :
public class GcmIntentService extends GCMBaseIntentService { private static final String TAG = "GcmIntentService"; public GcmIntentService() { super("DemoGcm", SENDER_ID); } @Override protected void onRegistered(Context context, String registrationId) { Log.i(TAG, "Device registered: regId = " + registrationId); displayMessage(context, getString(R.string.gcm_registered)); if (!ServerUtilities.register(context, registrationId)) { // At this point all attempts to register with the app server failed, so we need to unregister the device // from GCM - the app will try to register again when it is restarted. Note that GCM will send an // unregistered callback upon completion, but GcmIntentService.onUnregistered() will ignore it. GCMRegistrar.unregister(context); } } @Override protected void onUnregistered(Context context, String registrationId) { Log.i(TAG, "Device unregistered"); displayMessage(context, getString(R.string.gcm_unregistered)); if (GCMRegistrar.isRegisteredOnServer(context)) { ServerUtilities.unregister(context, registrationId); } else { // This callback results from the call to unregister made on // ServerUtilities when the registration to the server failed. Log.i(TAG, "Ignoring unregister callback"); } } @Override protected void onMessage(Context context, Intent intent) { Log.i(TAG, "Received message"); displayMessageAndNotifyUser(context, intent.getExtras().getString("message")); } @Override protected void onDeletedMessages(Context context, int total) { Log.i(TAG, "Received deleted messages notification"); displayMessageAndNotifyUser(context, getString(R.string.gcm_deleted, total)); } @Override public void onError(Context context, String errorId) { Log.i(TAG, "Received error: " + errorId); displayMessageAndNotifyUser(context, getString(R.string.gcm_error, errorId)); } @Override protected boolean onRecoverableError(Context context, String errorId) { Log.i(TAG, "Received recoverable error: " + errorId); displayMessageAndNotifyUser(context, getString(R.string.gcm_recoverable_error, errorId)); return super.onRecoverableError(context, errorId); } public void displayMessageAndNotifyUser(Context context, String message) { displayMessage(context, message); notifyUser(context, message); } }
N.B. Les méthodes displayMessage et notifyUser sont des méthodes statiques qui simplifient l’envoi de notification et l’affichage du message sur l’écran d’accueil.
GcmRegistrationService
Nous créons un service qui aura pour responsabilité de gérer l’inscription au service GCM. La première étape est de vérifier si nous disposons d’une clé GCM valide via la méthode GCMRegistrar.isRegistered. Si aucune clé n’existe, un appel à GCMRegistrar.register déclenchera le processus d’inscription (réception du résultat de l’inscription par le broadcast receiver qui déléguera le traitement à la classe GcmIntentService). Dans le cas contraire, l’étape suivante est de vérifier si notre backend dispose de cette information via GCMRegistrar.isRegisteredOnServer. Si notre backend est déjà informé, le processus d’inscription prend fin et le service GCM est en place. Sinon nous tentons d’envoyer la clé (ServerUtilities.register), en cas d’échec répété on se désinscrit du service grâce à la méthode GCMRegistrar.unregister.
Pour finir nous exposons une méthode statique cancelPendingRegistration qui devra être appelée dans la méthode onDestroy de notre activité. Elle permet de s’assurer que l’on appelle GCMRegistrar.onDestroy avec le contexte de l’application et non celle de l’activité afin d’éviter toute erreur.
Le code de notre service d’enregistrement est le suivant :
public class GcmRegistrationService extends IntentService { public GcmRegistrationService() { super(GcmRegistrationService.class.getName()); } @Override protected void onHandleIntent(Intent intent) { checkGcmInformationsValidity(); if (!GCMRegistrar.isRegistered(this)) { registerDeviceOnGcm(); } else { // Device is already registered on GCM, check server. registerDeviceOnServer(); } } private void registerDeviceOnGcm() { GCMRegistrar.register(this, SENDER_ID); } private void registerDeviceOnServer() { if (GCMRegistrar.isRegisteredOnServer(this)) { // Skips registration. displayMessage(this, getString(R.string.already_registered)); } else { if (!ServerUtilities.register(this, GCMRegistrar.getRegistrationId(this))) { // At this point all attempts to register with the app server failed, so we need to unregister the device // from GCM - the app will try to register again when it is restarted. Note that GCM will send an // unregistered callback upon completion, but GcmIntentService.onUnregistered() will ignore it. GCMRegistrar.unregister(this); } } } private void checkGcmInformationsValidity() { checkNotNull(SERVER_ROOT_URL, "SERVER_URL"); checkNotNull(SENDER_ID, "SENDER_ID"); // Make sure the device has the proper dependencies. GCMRegistrar.checkDevice(this); // Make sure the manifest was properly set - comment out this line // while developing the app, then uncomment it when it's ready. GCMRegistrar.checkManifest(this); } private void checkNotNull(Object reference, String name) { if (reference == null) { throw new NullPointerException(getString(R.string.error_config, name)); } } public static final void cancelPendingRegistration(Context context) { GCMRegistrar.onDestroy(context.getApplicationContext()); } }
N.B. Dans l’implémentation de base fournit dans les exemples du SDK, toute la logique d’enregistrement se faisait dans l’activité. Une asynctask était utilisée afin de pouvoir communiquer avec notre backend. Afin d’éviter de surcharger le code de notre activité, tout cette logique a été déportée dans un IntentService (code ci-dessus). Cependant, le problème est que contrairement à une asynctask, il n’est pas possible d’annuler une opération courante lors de la destruction de notre activité. Il est possible d’optimiser ce service afin de ne pas accumuler les messages lors des changements de configuration mais cela est hors de portée de cet article.
Mise en place dans l’activité
Pour boucler la boucle, nous mettons en place l’appel de notre service dans la méthode onCreate de notre activité. Il ne faut cependant pas oublier l’appel à notre méthode GcmRegistrationService.cancelPendingRegistration dans onDestroy.
public class DemoActivity extends Activity { ... @Override public void onCreate(Bundle savedInstanceState) { ... startService(new Intent(this, GcmRegistrationService.class)); } @Override protected void onDestroy() { ... GcmRegistrationService.cancelPendingRegistration(this); super.onDestroy(); } }
Conclusion
Voilà, vous venez de mettre en place Google Cloud Messaging au sein de votre application mobile. Avec la librairie GCM, cette mise en place est grandement facilitée aussi bien pour l’application mobile que pour le backend. Il est néanmoins intéressant de comprendre ce qu’elle fait pour nous faciliter la vie.
Le code source de cet article est disponible ici
Commentaire