Cómo enviar una alarma a un móvil - Firebase Cloud Messaging


Última revisión en febrero del 2022.

Esta entrada explica cómo usar Firebase Cloud Messaging para que nuestros dispositivos puedan enviar alarmas a un móvil que las mostrará como una notificación. En nuestro caso la aplicación cliente es Android y para la autentificación de los usuarios usamos Google Identity, pero se puede trasladar fácilmente a otras plataformas con el mismo Firebase.

Este post se centra en el envío de la notificación, la autentificación tanto de los usuarios como de los dispositivos está descrita en cómo autentificar dispositivos.

El proyecto

El esquema anterior actua como índice de la entrada, cada número está descrito en alguno de los párrafos posteriores

Firebase - FCM (Firebase Cloud Messaging)

Firebase es un BaaS (Backend as a Service), que actualmente pertenece a Google, que nos permite usar sus servicios en diferentes plataformas. Así en el caso del servicio FCM (Firebase Cloud Messaging) podemos empujar mensajes desde cualquier dispositivo a una plataforma Apple, Android y también a una página web con soporte para javascript.

FCM tiene una cónsola que teniendo el Registration Token de la aplicación cliente (App) permite enviarle mensajes.

En nuestro caso nos interesa hacerlo desde dispositivos (RPi4) que en algunos no permiten conexiones entrantes (no pueden actuar como servidores). Así usamos un servidor (Server) en la Cloud que nos hace de pasarela entre los diferentes elementos que forman el proyecto.

1 - App

Para implementar nuestra aplicación de Android, que nos muestra las alarmas de los dispositivos en forma de notificaciones, partiremos de la aplicación Firebase Cloud Messaging Quickstart la cual ya es capaz de recibir notificaciones enviadas a través de la cónsola de FCM.

A esta aplicación le añadiremos una autentificación del usuario y una suscripción para recibir mensajes a través de nuestro servidor que a su vez usa FCM.

Autentificación del usuario - Google Identity

Básicamente Google Identity nos devuelve un token que permite autentificar al usuario en el Servidor.

El código es el que nos sugiere Google Identity empotrado en el método onCreate de la clase MainActivity.

  
public class MainActivity extends AppCompatActivity
{

    private static final String TAG = "MainActivity";

    private static final int REQ_ONE_TAP = 2;  // Can be any integer unique to the Activity.
    private boolean showOneTapUI = true;

    private SignInClient oneTapClient;
    private BeginSignInRequest signUpRequest;

    private static String idToken = null;


    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data)
    {
        super.onActivityResult(requestCode, resultCode, data);

        switch (requestCode)
        {
            case REQ_ONE_TAP:
                try
                {
                    SignInCredential credential = oneTapClient.getSignInCredentialFromIntent(data);
                    idToken = credential.getGoogleIdToken();
                    if (idToken !=  null)
                    {
                        // Got an ID token from Google. Use it to authenticate
                        // with your backend.
                        Log.d(TAG, "Got ID token: " + idToken);
                    }
                }
                catch (ApiException e)
                {
                    Log.d(TAG, "token exception:" + e.getMessage());
                }
                break;
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        oneTapClient = Identity.getSignInClient(this);
        signUpRequest = BeginSignInRequest.builder()
                .setGoogleIdTokenRequestOptions(BeginSignInRequest.GoogleIdTokenRequestOptions.builder()
                        .setSupported(true)
                        // Your server's client ID, not your Android client ID.
                        .setServerClientId("xxxxxxxxxxx.apps.googleusercontent.com")
                        // Show all accounts on the device.
                        .setFilterByAuthorizedAccounts(false)
                        .build())
                .build();

        oneTapClient.beginSignIn(signUpRequest)
                .addOnSuccessListener(this, new OnSuccessListener<BeginSignInResult>()
                {
                    @Override
                    public void onSuccess(BeginSignInResult result)
                    {
                        try
                        {
                            startIntentSenderForResult(
                                    result.getPendingIntent().getIntentSender(), REQ_ONE_TAP,
                                    null, 0, 0, 0);
                        }
                        catch (IntentSender.SendIntentException e)
                        {
                            Log.e(TAG, "Couldn't start One Tap UI: " + e.getLocalizedMessage());
                        }
                    }
                })
                .addOnFailureListener(this, new OnFailureListener()
                {
                    @Override
                    public void onFailure(@NonNull Exception e)
                    {
                        // No Google Accounts found. Just continue presenting the signed-out UI.
                        Log.d(TAG, e.getLocalizedMessage());
                    }
                });
    }
}

Suscripción a recibir alarmas

Cuando la aplicación arranca, el FCM SDK genera un Registration Token que se envía al servidor usando un POST al método subscriber del recurso alarm. Esta solicitud va acompañada de un token de autenticación del usuario que hemos obtenido en el apartado anterior.

 
public void sendRegistrationToServer(String token)
{
    // Instantiate the RequestQueue.
    RequestQueue queue = Volley.newRequestQueue(this);
    String url ="http://192.168.1.123:8080/resource/alarm/subscriber";

    JSONObject jsonBody = new JSONObject();
    try
    {
        jsonBody.put("idToken", token);
    } catch (JSONException e)
    {
        e.printStackTrace();
    }

    // Request a string response from the provided URL.
    StringRequest  stringRequest = new StringRequest (Request.Method.POST, url,
            new Response.Listener<String>()
            {
                @Override
                public void onResponse(String response)
                {
                    // Display the first 500 characters of the response string.
                    Log.d(TAG, "Response is: "+ response.substring(0,500));
              }
            },
            new Response.ErrorListener()
            {
                @Override
                public void onErrorResponse(VolleyError error)
                {
                    Log.d(TAG, "That didn't work!");
                }
            })
    {

        @RequiresApi(api = Build.VERSION_CODES.KITKAT)
        @Override
        public byte[] getBody() throws AuthFailureError
        {
            return jsonBody == null ? null : jsonBody.toString().getBytes(StandardCharsets.UTF_8);
        }

        @Override
        public Map<String, String> getHeaders() throws AuthFailureError
        {
            Map<String,String> params = new HashMap<String, String>();
            params.put("Content-Type","application/json; charset=utf-8");
            params.put("Accept", "text/plain");
            params.put("Authorization", "Bearer " + MainActivity.getIdToken());
            return params;
        }
    };

    // Add the request to the RequestQueue.
    queue.add(stringRequest);

}

2 - RPi4B

El dispositivo, en nuestro caso una Raspberry Pi 4B, cuando quiere enviar una alarma a la aplicación cliente (App), envía un POST al método notification del recurso alarm que tenemos en el servidor (Server).

Este POST lleva un token JWT en su cabecera (Bearer Authentication), que contiene el Identificador del dispositivo, que permite al servidor autentificar y autorizar al dispositivo (Explicado en cómo autentificar un dispositivo).

El código para acceder al recurso alarm está implementado con Jersey JAX-RS Client. Muy sencillo comparado con lo barroco que resulta en Android.

  
public static void main(String[] args)
{
               
    try
    {
        Client client = ClientBuilder.newClient();
         
        // authentification token
        String jwt = createJwtRsa("Project Id", "MyFistDeviceID", "rsa_private_pkcs8");

        // entity
        NotificationDomain nd = new NotificationDomain("Title Notification", "Body Notification");
        Entity e = Entity.entity(nd, MediaType.APPLICATION_JSON);

        // Resource call
        Response response = client.target("http://localhost:8080")
            .path("resource/alarm/notification")                    
            .request(MediaType.TEXT_PLAIN)
            .header(HttpHeaders.AUTHORIZATION ,"Bearer " + jwt)
            .post(e);

        // Returned status
        System.out.println("status: " + response.getStatus());
           
    } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException ex)
    {
        Logger.getLogger(ClientTest.class.getName()).log(Level.SEVERE, null, ex);
    }
}

3 y 4 - Server

El servidor recibe la notificación y el token JWT. Extrae del token el Identificador del dispositivo y busca en el Cloud Firestore si este dispositivo ha estado inicializado con la clave pública para verificar la firma del token JWT. (Explicado en cómo autentificar un dispositivo).

Si todo es correcto, se envía la notification junto el Registration Token de la App al servicio Firebase Cloud Messaging que lo hace llegar a la aplicación cliente.

El servidor

Como servidor se ha usado Jetty en Java alojado en Google App Engine que es una Platform as a Service (PaaS).

Recurso alarm

El recurso alarm está implementado con el framework Jersey en el servidor Jetty.


package com.elmoli.security.resources;

import com.elmoli.security.FirebaseMessage;
import com.elmoli.security.controller.GoogleCloudStorage;
import com.elmoli.security.controller.SubscriberController;
import com.elmoli.security.domain.NotificationDomain;
import com.elmoli.security.domain.SubscriberDomain;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.SecurityContext;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;

/**
 *
 * @author joanr
 */
@Path("alarm")
public class AlarmREST
{

    SubscriberController subscriberController;

    public AlarmREST()
    {
        subscriberController = SubscriberController.getInstance();
    }

    @POST
    @SecuredUser
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    @Path("/subscriber")
    public Response addSubscriber(@Context SecurityContext securityContext, SubscriberDomain sd)
    {
        subscriberController.create(sd);
        return Response.created(URI.create("/subscriber/")).build();
    }

    @POST
    @SecuredDevice
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    @Path("/notification")
    public Response sendNotification(@Context SecurityContext securityContext, NotificationDomain nd)
    {
        FirebaseMessage fb = FirebaseMessage.getInstance();
        try
        {
            SubscriberDomain sd = subscriberController.get();
            String registrationToken = sd.getIdToken();

            fb.sendNotification(registrationToken, nd, null);
        } catch (IOException ex)
        {
            throw new BadRequestException(ex);
        }
        return Response.accepted().build();
    }

Envío de la notificación

Para enviar la notificación a FCM utilizamos Firebase Admin SDK.

 
package com.elmoli.security;

import com.elmoli.security.domain.NotificationDomain;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;


public class FirebaseMessage
{  
    public String sendNotification(String registrationToken, NotificationDomain nd) throws IOException
    {        

        FirebaseAppExt.getInstance();

        Notification n = Notification.builder().setTitle(nd.getTitle()).setBody(nd.getBody()).build();              

        // See documentation on defining a message payload.
        Message message = Message.builder()
                .setToken(registrationToken)
                .setNotification(n)
                .build();

        // Send a message to the device corresponding to the provided
        // registration token.
        String responseF = "";
        try
        {
            responseF = FirebaseMessaging.getInstance().send(message);            
        } catch (FirebaseMessagingException ex)
        {
            Logger.getLogger(FirebaseMessage.class.getName()).log(Level.SEVERE, null, ex);
        }
       
        return(responseF);
       
    }
}
 
package com.elmoli.security;

import com.google.firebase.FirebaseApp;

public class FirebaseAppExt
{
     private static FirebaseAppExt fba = new FirebaseAppExt();

        private FirebaseAppExt()
        {
            FirebaseApp.initializeApp();
        }

        public static FirebaseAppExt getInstance()
        {
            return(fba);
        }
}

5 - App

Se ha de configurar en Android el autoarranque, el paso a reposo y el ahorro de batería de la app para el funcionamiento correcto de las alarmas.
Si la app no está online, la FCM espera a que esté online para entregarle la notificación.

Si la aplicación está en background la notificación es manejada automáticamente por Android, en otro caso se recibe en un callback que maneja la notificación.

Para finalizar

Todo el mundo dispone de móvil, es una fantástica pantalla para nuestros dispositivos. Personalmente no me gustan las Apps y prefiero usar páginas web que pueden correr en cualquier browser. El inconveniente es que no son útiles a la hora de recibir alarmas de nuestros dispositivos ya que habría que mantener la página siempre en primer plano. Así, optamos por una App en background que nos avisa que ha habido una alarma en un dispositivo que nos conduce a abrir una aplicación en el browser para ver la información detallada.

Más información

Comentarios