Android Push Notifications Con Firebase Cloud Messaging

Las push notifications o notificaciones push son paquetes de datos enviados desde un servidor hacia sus clientes sin necesidad de una petición previa.

Firebase Cloud Messaging es una de las muchas plataformas que existen para generar este comportamiento.

Así que sigue leyendo este tutorial para que comprendas como este servicio de Google puede ayudarte a enviar mensajes push.

A continuación puedes ver el resultado final del tutorial:

Desbloquea el link de descarga del proyecto en Android Studio en la siguiente caja:

[sociallocker id=»7121″]Descargar Gratis[/sociallocker]

¿Qué es Firebase Cloud Messaging?

Firebase Cloud Messaging o FCM es un servicio de Google cuya funcion popular es el envío de push notifications desde una aplicación servidor hacia una aplicación cliente. Esto es posible gracias a la intervención del servicio de mensajería del servidor de Firebase.

Firebase Cloud Messaging

Anteriormente este se conocía como Google Cloud Messaging o GCM, sin embargo la plataforma actualizó las características de la arquitectura con esta nueva versión, así que se recomienda comenzar o importar los proyectos lo más pronto posible con FCM.

¿Qué utilidades de uso frecuente tienen las notificaciones push en Android?

  • Comunicar la posible sincronización con respecto a un nuevo cambio
  • Crear recordatorios de servicios
  • Promocionar descuentos en productos
  • Notificación del estado de un pedido en un eCommerce
  • Enviar alertas informativas relacionadas a la ubicación del usuario
  • Petición de datos para feedback de servicios
  • Implementar chats entre usuarios

Como ves, los Android developers podemos sacarle gran provecho a esta situación, ya que se deja atrás el esquema de un servidor perezoso que espera por peticiones de los clientes.

Ahora el servidor puede notificar a los dispositivos con el fin de enganchar a los usuarios, disminuyendo el consumo de batería.

Upstream Messages

FCM no solo permite el envío de mensajes servidor-cliente (downstream messages).

También permite retornar por el mismo canal mensajes cliente-servidor. A estos se les conoce como upstream messages y son de gran utilidad para creación de apps de chat como WhatsApp.

Sin embargo en este tutorial solo veremos sobre downstream messages.

Arquitecture de FCM

Existen tres elementos en la interacción de FCM:

  • Tu app servidor
  • Tu app Android (cliente)
  • El servidor de Firebase

Firebase Cloud Messaging Architecture

Como se muestra en el diagrama anterior, la aplicación servidor envía una petición al servidor de Firebase. Luego la plataforma notifica a todos los dispositivos que estén registrados al proyecto.

Con esa sencilla relación, la receta a la hora de usar Firebase Cloud Messaging consiste de tres pasos:

  1. Configurar un proyecto FCM en la consola de Firebase
  2. Desarrollar tu app Android cliente
  3. Desarrollar tu app servidor

¡Comencemos!

Car Insurance App

Para desarrollar el contenido del tutorial se usará un ejemplo llamado Car Insurance. Se trata de una aplicación Android sencilla que sostiene el servicio de una compañía de seguros para automóviles.

Su construcción se basa en los siguientes requerimientos:

  • Como cliente de Car Insurance, deseo iniciar sesión con mi email.
  • Como supervisor de la compañía de seguros, deseo enviar notificaciones sobre las promociones y descuentos de la compañía a los usuarios.

El siguiente wireframe sencillo muestra las características:

Wireframe de app Android de Seguros de carros

En la scren de Login el usuario tiene tres puntos de interacción: Los campos de texto para tipear sus credenciales y el botón de inicio de sesión.

Si somos totalmente optimistas, una vez presionado el botón, el sistema de autenticación dará luz verde para pasar a la screen de Notificaciones, donde se irán apilando las notificaciones enviadas por el supervisor de ventas.

Arquitectura Modelo-Vista-Controlador (MVP)

Car Insurance se basará en el patrón Model-View-Presenter para mejorar la separación de componentes, incrementar la legibilidad en el testing de la app y optimizar el mantenimiento de sí misma.

Aunque no voy a entrar a explicar a fondo los fundamentos de MVP, te daré una descripción general y el funcionamiento de cada capa.

La interacción es sencilla:

  • Vista: Muestra los datos al usuario y comunica las interacciones de este al presentador. Un ejemplo de implementación en Android son las Actividades o los fragmentos.
  • Presentador: Agente intermediario entre la vista y el modelo. Carga los datos del modelo, los formatea y los muestra en la vista. También se encarga de actualizar el modelo si es necesario.
  • Modelo: Genera la interacción necesaria para cargar los datos a proyectar en la app. Normalmente consulta fuentes de datos como SQLite, Web services, el disco local, etc. con el fin de proveer las entidades que alimentan la vista.

Arquitectura MVP

Si deseas saber más puedes consultar estas dos excelentes fuentes:

Instalar Google Play Services

Antes de comenzar con el desarrollo preparemos nuestro entorno de desarrollo.

1. En Android Studio inicia el SDK Manager a través del botón de acceso rápido.

SDK Manager en Android Studio2. En el asistente que aparece presiona Launch Standalone SDK Manager.

Launch standalone SDK Manager

3. Busca la categoría Extras y marca la línea Google Play Services. Luego presiona Install 1 package…

SDK Manager > Extras > Google Play Services

4. Acepta los términos de la licencia de Google Play Services e instala su paquete.

Aceptar lincencia de Google Play Services

Al terminar la carga, tendrás disponible las librerías para el posterior uso de Firebase Cloud Messaging u otros servicios de Google (Google Maps, Analytics, Gmail, Apps Script, etc.)

Añadir Firebase A Tu Aplicación Android

1. Ve a Google Firebase Console.

2. Haz click en «CREAR NUEVO PROYECTO».

Crear nuevo proyecto en Google Firebase Console

Si tienes un proyecto con el antiguo Google Cloud Messaging, clickea «o importar un proyecto de Google».

3. Pon como nombre al proyecto «Car Insurance» y selecciona tu país de origen. Luego confirma con «CREAR PROYECTO».

Configurar nombre y país en proyecto Firebase

4. Haz click en «Añade Firebase a tu aplicación de Android».

Añade Firebase a tu aplicación de Android

5. Ubica el nombre del paquete de la app en el primer espacio. En mi caso es com.herprogramacion.carinsurance. Luego haz click en «AÑADIR APLICACION».

Nombre del paquete de app Android para Firebase

Al igual que vimos en el tutorial de Google Maps API v2, la huella digital SHA-1 se obtiene desde la consola de comandos de tu sistema operativo con la utilidad keytool.

Abre la venta Terminal de Android Studio y ejecuta el siguiente comando ajustando la sintaxis a tu directorio local:

keytool -exportcert -list -v -alias <nombre-de-key> -keystore <ruta-de-keystore>

Para este ejemplo usaremos la keystore para modo debug, así que el comando tendría la siguiente forma:

keytool -exportcert -list -v -alias androiddebugkey -keystore "C:UsersTUUSUARIO.androiddebug.keystore"

El resultado sería una cadena similar a la siguiente:

Huellas digitales del Certificado:
SHA1: 7A:33:08:75:0E:AD:20:67:45:6D:06:28:DE:6F:C3:C1:33:99:D3:68

Ahora ponlo en el campo «Certificado de firma de depuración SHA-1 (Opcional)».

6. Descarga a tu PC el archivo google-services.json que acaba de generar Firebase Console.

Descargar archivo google-services.json para Firebase

7. Como lo indica el segundo paso del asistente, mueve el archivo google-services.json a la carpeta raíz del proyecto en Android Studio «Car Insurance».

Mover google-services.json a carpeta raiz de proyecto en Android Studio

Firebase Authentication Para Login En Android

Firebase Authentication es otro producto de Firebase para facilitar la identificación usuarios.

Ya sabes que es  superimportante proteger la información de tus usuarios y salvarla de forma segura en la nube.

La principal ventaja de este servicio es la gran cantidad de procesos de autenticación que permite.

El SDK te deja realizar la autenticación con proveedores populares como Facebook, Twitter, Github y Google.

Auth Providers de Firebase

También provee un SDK para que integres el proceso de identificación con tu aplicación servidor si tienes cuentas personalizadas.

Integrar Firebase Authentication con sistema de cuentas personalizado

También te permite usar las credenciales básicas del usuario para generar la autenticación.

Autenticación con Email en Firebase Authentication

Este último será el que usaremos en el ejemplo actual.

1. Autenticación basada en email y password

Firebase Authentication permite a los android developers generar el registro y login de un usuario a través del email y password como se hace comúnmente.

Proceso de generación de User ID en Firebase Authentication

Sin importar que método elijas, siempre se asignará un ID de usuario único que será empleado para el usuario. Este será guardado en la base de datos de la Console de Firebase.

Por esta vía Firebase console provee una sección de usuarios para administrar su creación y almacenamiento.

Incluso te facilita una serie de plantillas para procesar el double opt-in para los usuarios que se registren.

Veamos como habilitar este servicio.

Establecer método de email en Firebase Console

1. Abre Android Studio y añade la dependencia de Firebase Authentication en el archivo build.gradle del módulo.

dependencies {
    //...
    compile 'com.google.firebase:firebase-auth:9.0.2'
}

2. Accede a la consola de Firebase y selecciona el proyecto Car Insurance.

3. Expande el Navigation Drawer y ve a DEVELOP > Auth.

Firebase console: DEVELOP > Auth

4. A continuación encontrarás una sección de administración divida en tres pestañas:

  • USUARIOS: Te permite administrar todos los usuarios registrados en tu proyecto Firebase. Aquí te será posible añadir, eliminar, deshabilitar cuentas y reenviar passwords.
  • SIGN-IN METHOD: Permite habilitar y configurar los métodos de autenticación que usarás en tu app.
  • PLANTILLAS DE CORREO ELECTRÓNICO: Permite administrar las autorespuestas por email que serán enviados a los nuevos usuarios para la confirmación o reset de password.

Tabs en Firebase Authentication Console

Con esto en mente, lo que harás es ir a SIGN-IN METHOD y presionar el primer provider Email/password.

SIGN-IN METHOD en Firebase

En la siguiente sheet que se desprende, cambia el switch para habilitar el servicio y luego guarda la configuración.

Habilitar provider Email/password en Firebas Authentication

Crear user de prueba

Sitúate en la pestañas USUARIOS y agrega un nuevo user.

AÑADIR USUARIO en Firebase Authentication

Escribe un correo y contraseña para el nuevo usuario que tendrá acceso a Car Insurance:

Añadir un nuevo usuario con email y password

Con eso tendrás el usuario que probaremos en la app cuyo ID ya ha sido asignado.

UID de usuario en Firebase

2. Crear Login en Android

El wireframe inicial muestra una screen de login que consta de dos campos de texto. Uno para el email y otro para el password.

Y el punto de interacción para iniciar la autorización es un raised button.

Para desarrollar esta característica crea un paquete llamado login y agrega los siguientes archivos:

Archivo Propósito
LoginActivity.java Actividad que actúa como controlador del login
LoginFragment.java Fragmento que muestra el formulario de login como implementación de la vista
LoginContract.java Define la capa de vista y la interacción con el presentador
NotificationsPresenter.java Implementación de la capa de presentación para actualizar la vista
LoginInteractor.java Interactor para autorizar a los usuarios y validar restricciones de datos y dependencias externas.

Arquitectura

Los componentes definidos anteriormente interactúan de la siguiente manera.

  1. La vista recibe comunica al presentador cuando el usuario presiono el botón de login.
  2. El presentador le ordena al interactor que inicie el proceso de autenticación.
  3. El interactor hace una llamado al Firebase Authentication SDK para autenticar en el server.
  4. Firebase Authentication inicia un proceso asíncrono para enviar una petición al server y se queda esperando por la respuesta.
  5. Al llegar la respuesta se le transmite al interactor.
  6. El interactor notifica al presenter si el login fue exitoso o hubo algún fallo.
  7. El presenter actualiza la vista ya sea para alertar al usuario de los errores o para dar paso a la actividad de notificaciones.

El siguiente diagrama resume este comportamiento:

Arquitectura de Login en Android con Firebase Authentication

Representar el patrón MVP

En esta parte debes preguntarte:

¿Qué debe mostrar la vista cuando el usuario interactúe con los campos existentes?

La ruta feliz sería que el usuario introduzca su email/contraseña y al presionar el botón de login:

  • Se muestre un indicador de progreso
  • Se abra la screen de notificaciones

Pero existen factores que pueden alterar este flujo. Cosas como:

  • Error en la sintaxis de email
  • Error en la sintaxis del password
  • Error en la autenticación con Firebase Authentication
  • Error en la disponibilidad de la APK de Google Play Services
  • Error en la disponibilidad de la red

Por el lado del presentador puedes preguntarte:

¿Qué fuentes de datos se necesitan consultar para realizar el login?

Aquí ya sabemos que solo será el servidor de Firebase para la autenticar con base a la petición que enviaremos.

Así que en resumen el contrato de interacciones quedaría así:

LoginContract.java

/**
 * Interacción MVP en Login
 */
public interface LoginContract {

    interface View extends BaseView<Presenter>{
        void showProgress(boolean show);

        void setEmailError(String error);

        void setPasswordError(String error);

        void showLoginError(String msg);

        void showPushNotifications();

        void showGooglePlayServicesDialog(int errorCode);

        void showGooglePlayServicesError();

        void showNetworkError();
    }

    interface Presenter extends BasePresenter{
        void attemptLogin(String email, String password);
    }
}

Donde BaseView y BasePresenter son interfaces con el comportamiento general que esperamos de todas las vistas y presentadores en la app.

BasePresenter.java

/**
 * Interfaz de comportamiento general de presenters
 */
public interface BasePresenter {
    void start();
}

BaseView.java

/**
 * Interfaz de comportamiento general de vistas
 */
public interface BaseView<T> {
    void setPresenter(T presenter);
}

El controlador start() ejecuta todos los comportamientos iniciales por defecto que la vista requiere del presentador.

Por otro lado setPresenter() crea un vínculo view-presenter para realacionar ambas instancias.

Si quieres ver más implementaciones parecidas los repositorios de Google podrían serte de ayuda:

Android Architecture Blueprints [beta]

Si ves las interacciones de una forma más conveniente, entonces eres libre de experimentar e implementar el patrón de acuerdo a tus necesidades.

Crear formulario de login

Editar el layout de la activity de login

La actividad actúa como un contenedor simplificado. Esto significa que puedes usar un solo nodo para representar el contenido.

Usa el id login_container para incrustar el fragmento a la hora de inflar el contenido.

activity_login.xml

<android.support.v4.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/login_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    android:gravity="center_horizontal"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin" />

Editar layout del fragmento

Abre el layout de LoginFragment y borra su contenido.

Para crear la jerarquía XML debes tener en cuenta que tendrás el logo y el formulario por debajo.

Por lo que un LinearLayout vertical sería buena opción para la distribución.

Adicionalmente agrega una ProgressBar con visibilidad gone, para poder intercambiarlo con el formulario cuando el login se inicie.

Veamos:

fragment_login.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:tools="http://schemas.android.com/tools"
    android:gravity="center_horizontal"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin">

    <!-- Logo -->
    <TextView
        android:id="@+id/tv_logo"
        android:textColor="@android:color/white"
        android:layout_width="wrap_content"
        android:textStyle="bold"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginBottom="56dp"
        android:layout_marginTop="108dp"
        android:text="CAR INSURANCE"
        android:textAppearance="@style/TextAppearance.AppCompat.Display2" />

    <!-- Indicador de progreso -->
    <ProgressBar
        android:id="@+id/login_progress"
        style="?android:attr/progressBarStyleLarge"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:visibility="gone" />

    <!-- Formulario de login -->
    <LinearLayout
        android:id="@+id/login_form"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <android.support.design.widget.TextInputLayout
            android:id="@+id/til_email_error"
            android:textColorHint="@android:color/white"

            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <android.support.design.widget.TextInputEditText
                android:id="@+id/tv_email"
                android:layout_width="match_parent"
                android:theme="@style/LoginEditText"
                android:layout_height="wrap_content"
                android:hint="@string/hint_email"
                android:inputType="textEmailAddress"
                android:maxLines="1"
                android:singleLine="true" />

        </android.support.design.widget.TextInputLayout>

        <android.support.design.widget.TextInputLayout
            android:id="@+id/til_password_error"
            android:layout_width="match_parent"
            android:textColorHint="@android:color/white"
            android:layout_height="wrap_content">

            <android.support.design.widget.TextInputEditText
                android:id="@+id/tv_password"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:theme="@style/LoginEditText"
                android:hint="@string/hint_password"
                android:imeActionId="@+id/login"
                android:imeActionLabel="@string/action_sign_in_ime"
                android:imeOptions="actionUnspecified"
                android:inputType="textPassword"
                android:maxLines="1"
                android:singleLine="true" />

        </android.support.design.widget.TextInputLayout>

        <Button
            android:id="@+id/b_sign_in"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom"
            android:layout_marginTop="16dp"
            android:text="@string/action_sign_in"
            android:textStyle="bold"
            android:theme="@style/AppTheme.AccentButton" />

    </LinearLayout>
</LinearLayout>

Implementa el fragmento de login

En el fragmento de login obtén las referencias de UI para:

  • El indicador de progreso
  • El formulario
  • Los campos de texto y sus etiquetas flotantes
  • El botón de SIGN-IN

Ahora maneja los siguientes eventos:

  • Al comenzar a escribir en los EditTexts el error del TextInputLayout debe desaparecer. Esto con el fin de que el usuario distinga entre intentos de tipeo.
  • Al presionar el botón r_sign_in debe ejecutarse el método attempLogin(). Más adelante veremos que este método comunica al presentador la necesidad de loguear con las credenciales actuales.

LoginFragment.java

public class LoginFragment extends Fragment {

   
    private TextInputEditText mEmail;
    private TextInputEditText mPassword;
    private Button mSignInButton;
    private View mLoginForm;
    private View mLoginProgress;
    private TextInputLayout mEmailError;
    private TextInputLayout mPasswordError;

    public static LoginFragment newInstance(String param1, String param2) {
        LoginFragment fragment = new LoginFragment();
        // Setup de argumentos en caso de que los haya
        return fragment;
    }

    public LoginFragment() {
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (getArguments() != null) {
            // Extracción de argumentos en caso de que los haya
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View root = inflater.inflate(R.layout.fragment_login, container, false);
        mLoginForm = root.findViewById(R.id.login_form);
        mLoginProgress = root.findViewById(R.id.login_progress);

        mEmail = (TextInputEditText) root.findViewById(R.id.tv_email);
        mPassword = (TextInputEditText) root.findViewById(R.id.tv_password);
        mEmailError = (TextInputLayout) root.findViewById(R.id.til_email_error);
        mPasswordError = (TextInputLayout) root.findViewById(R.id.til_password_error);

        mSignInButton = (Button) root.findViewById(R.id.b_sign_in);

        // Eventos
        mEmail.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {

            }

            @Override
            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
                mEmailError.setError(null);
            }

            @Override
            public void afterTextChanged(Editable editable) {

            }
        });
        mPassword.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {

            }

            @Override
            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
                mPasswordError.setError(null);
            }

            @Override
            public void afterTextChanged(Editable editable) {

            }
        });
        mSignInButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                attemptLogin();
            }
        });
        return root;
    }

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
    }

    @Override
    public void onDetach() {
        super.onDetach();
    }

    @Override
    public void onResume() {
        super.onResume();
    }

    private void attemptLogin() {
    }

}

Realizar transacción del fragmento

Ve a LoginActivity y realiza una transacción add() para agregar el fragmento al contenedor.

/**
 * Screen de login basada en el método email/password de Firebase
 */
public class LoginActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        LoginFragment loginFragment = (LoginFragment) getSupportFragmentManager()
                .findFragmentById(R.id.login_container);
        if (loginFragment == null) {
            loginFragment = LoginFragment.newInstance();
            getSupportFragmentManager().beginTransaction()
                    .add(R.id.login_container, loginFragment)
                    .commit();
        }
    }
}

Usar el fragmento como vista

Implementa LoginContract.View sobre LoginFragment y agrega las firmas de los controladores.

public class LoginFragment extends Fragment implements LoginContract.View {

Luego sobrescribe los controladores:

@Override
public void showProgress(boolean show) {
    mLoginForm.setVisibility(show ? View.GONE : View.VISIBLE);
    mLoginProgress.setVisibility(show ? View.VISIBLE : View.GONE);
}

@Override
public void setEmailError(String error) {
    mEmailError.setError(error);
}

@Override
public void setPasswordError(String error) {
    mPasswordError.setError(error);
}

@Override
public void showLoginError(String msg) {
    Toast.makeText(getActivity(), msg, Toast.LENGTH_LONG).show();
}

@Override
public void showPushNotifications() {
    startActivity(new Intent(getActivity(), NotificationsActivity.class));
    getActivity().finish();
}

@Override
public void showGooglePlayServicesDialog(int codeError) {
    mCallback.onInvokeGooglePlayServices(codeError);
}

@Override
public void showGooglePlayServicesError() {
    Toast.makeText(getActivity(),
            "Se requiere Google Play Services para usar la app", Toast.LENGTH_LONG)
            .show();
}

@Override
public void showNetworkError() {
    Toast.makeText(getActivity(),
            "La red no está disponible. Conéctese y vuelva a intentarlo", Toast.LENGTH_LONG)
            .show();
}

@Override
public void setPresenter(LoginContract.Presenter presenter) {
    if (presenter != null) {
        mPresenter = presenter;
    } else {
        throw new RuntimeException("El presenter no puede ser nulo");
    }
}

Mostrar diálogo de error de Google Play Services

En el código anterior, el método showGooglePlayServicesDialog() llama al método onInvokeGooglePlayServices() de un campo nombrado mCallback.

Si viste mi artículo sobre creación de diálogo en Android, sabrás que este comportamiento permite comunicar la el fragmento con la actividad contenedora.

Para ello defines una escucha interna en el fragmento con los controladores necesarios. En nuestro caso necesitamos mostrar un diálogo de error sobre Google Play Services basado en el código de error:

private Callback mCallback;

//...

interface Callback {
    void onInvokeGooglePlayServices(int codeError);
}

Luego en onAttach() salvas la instancia de la actividad en mCallback y onDetach() le quitas la referencia.

@Override
public void onAttach(Context context) {
    super.onAttach(context);
    if (context instanceof Callback) {
        mCallback = (Callback) context;
    } else {
        throw new RuntimeException(context.toString()
                + " debe implementar Callback");
    }
}

@Override
public void onDetach() {
    super.onDetach();
    mCallback = null;
}

Lo siguiente es abrir LoginActivity e implementas la interfaz.

public class LoginActivity extends AppCompatActivity implements LoginFragment.Callback {

Y finalmente implementas el controlador:

@Override
public void onInvokeGooglePlayServices(int errorCode) {
    showPlayServicesErrorDialog(errorCode);
}

void showPlayServicesErrorDialog(
        final int errorCode) {
    Dialog dialog = GoogleApiAvailability.getInstance()
            .getErrorDialog(
                    LoginActivity.this,
                    errorCode,
                    REQUEST_GOOGLE_PLAY_SERVICES);
    dialog.show();
}

Donde shoPlayServicesErrorDialog() se apoya en el método de utilidad GooglePlayAvailability.getErrorDialog() para crear un diálogo prefabricado que permita solucionar los inconvenientes de Google Play Services.

Por ejemplo:

Diálogo de error de Google Play Services en Android

Implementar presentador del login

Abre la clase LoginPresenter para implementar LoginContract.Presenter y añade todos los métodos a sobrescribir.

LoginPresenter.java

public class LoginPresenter implements LoginContract.Presenter, LoginInteractor.Callback {

    private final LoginContract.View mLoginView;
    private LoginInteractor mLoginInteractor;

    public LoginPresenter(@NonNull LoginContract.View loginView,
                          @NonNull LoginInteractor loginInteractor) {
        mLoginView = loginView;
        loginView.setPresenter(this);
        mLoginInteractor = loginInteractor;
    }

    @Override
    public void start() {
    }

    @Override
    public void attemptLogin(String email, String password) {
    }

    @Override
    public void onEmailError(String msg) {
    }

    @Override
    public void onPasswordError(String msg) {
    }

    @Override
    public void onAuthSuccess() {
    }

    @Override
    public void onAuthFailed(String msg) {
    }

    @Override
    public void onBeUserResolvableError(int errorCode) {
    }

    @Override
    public void onGooglePlayServicesFailed() {
    }

    @Override
    public void onNetworkConnectFailed() {
    }

}

Si te fijas bien, tenemos dos campos en la clase. Una instancia de LoginContract.View y de LoginInteractor.

El primero ya sabes es el vínculo hacia la vista, el cual entra como primer parámetro en el constructor y es ligado con su método setPresenter().

Por otro lado el interactor entra como segundo parámetro y es retenido en el atributo. Además tenemos la implementación de LoginInteractor.Callback para la comunicación con el presenter.

Crear interactor del login

La función del interactor es pasarle el email y password al SDK de Firebase Authentication para realizar la autenticación en el servidor.

Luego procesa la respuesta y se la comunica al presentador para que este actualice la vista basado en el resultado.

1. Crea un método de entrada llamado login(). Este debe recibir las credenciales y realizar las siguientes acciones:

  • isValidEmail(String), isValidPassword(String): Valida el contenido de las crendenciales
  • isNetworkAvailable(): Comprueba la disponibilidad de la red
  • isGooglePlayServicesAvailable(): Comprueba la disponibilidad de Google Play Services
  • signInUser(): Inicia la sesión del usuario
/**
 * Interactor del login
 */
public class LoginInteractor {

    private final Context mContext;
    private FirebaseAuth mFirebaseAuth;

    public LoginInteractor(Context context, FirebaseAuth firebaseAuth) {
        mContext = context;
        if (firebaseAuth != null) {
            mFirebaseAuth = firebaseAuth;
        } else {
            throw new RuntimeException("La instancia de FirebaseAuth no puede ser null");
        }
    }

    public void login(String email, String password, final Callback callback) {
        // Check lógica
        boolean c1 = isValidEmail(email, callback);
        boolean c2 = isValidPassword(password, callback);
        if (!(c1 && c2)) {
            return;
        }

        // Check red
        if (!isNetworkAvailable()) {
            callback.onNetworkConnectFailed();
            return;
        }

        // Check Google Play Service
        if (!isGooglePlayServicesAvailable(callback)) {
            return;
        }

        // Consultar Firebase Authentication
        signInUser(email, password, callback);

    }

    private boolean isValidPassword(String password, Callback callback) {
        return false;
    }

    private boolean isValidEmail(String email, Callback callback) {
        return false;
    }

    private boolean isNetworkAvailable() {
        return false;
    }

    private boolean isGooglePlayServicesAvailable(Callback callback) {
        return false;
    }

    private void signInUser(String email, String password, final Callback callback) {
        
    }

}

Ten en cuenta que signInUser() se llama solo si las tres restricciones han sido cumplidas.

2. Declara una interfaz llamada Callback para comunicar los resultados al presenter.

interface Callback {

    void onEmailError(String msg);

    void onPasswordError(String msg);

    void onNetworkConnectFailed();

    void onBeUserResolvableError(int errorCode);

    void onGooglePlayServicesFailed();

    void onAuthFailed(String msg);

    void onAuthSuccess();
}

El propósito de cada uno es:

  • onEmailError(String): Reporta el presenter que hubo un error en el email. El parámetro es el mensaje que se mostrará al usuario.
  • onPasswordError(String): Similar a onEmailError() pero para el campo de la contraseña.
  • onNetworkConnectFailed(): Reporta al presenter la no disponibilidad de la red.
  • onBeUserResolvableError(): Reporta al presenter que hay un error de Play Services, pero es posible que el usuario pueda arreglarlo con un asistente de Android.
  • onGooglePlayServicesFailed(): Reporta al presenter un error de Play Services que no puede resolver el usuario.
  • onAuthFailed(): Reporta al presenter un error en la autenticación en Firebase.
  • onAuthSuccess(): Reporta al presenter que la autenticación en Firebase fue exitosa.

3. Validar email.

Aquí debes incluiré todas las business rules que tu app requiera.

Por el momento solo pondremos las básicas: El correo no debe estar vacío y debe cumplir con el patrón correspondiente.

private boolean isValidEmail(String email, Callback callback) {
    boolean isValid = true;
    if (TextUtils.isEmpty(email)) {
        callback.onEmailError("Escribe tu correo");
        isValid = false;
    }

    if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
        callback.onEmailError("Correo no válido");
        isValid = false;
    }
    // Más reglas de negocio...
    return isValid;
}

No olvides reportar al presenter con onEmailError() los inconvenientes.

4. Validar password.

En este ejemplo la contraseña solo la restringiré a que no esté vacía.

private boolean isValidPassword(String password, Callback callback) {
    boolean isValid = true;
    if (TextUtils.isEmpty(password)) {
        callback.onPasswordError("Escribe tu contraseña");
        isValid = false;
    }

    // Más reglas de negocio...
    return isValid;
}

5. Verificar la conexión de red en Android.

Usa el componente ConnectivityManager y obtén la información de la red activa actualmente con getActiveNetworkInfo(). Luego comprueba si está conectada con isConnected().

private boolean isNetworkAvailable() {
    ConnectivityManager connMgr =
            (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo networkInfo = connMgr.getActiveNetworkInfo();
    return (networkInfo != null && networkInfo.isConnected());
}

6. Verificar si Google Play Services está disponible.

Asegurate de que exista la APK de Google Play Services usando el componente GoogleApiAvailability y su método isGooglePlayServicesAvailable().

private boolean isGooglePlayServicesAvailable(Callback callback) {
    int statusCode = GoogleApiAvailability.getInstance()
            .isGooglePlayServicesAvailable(mContext);

    if (GoogleApiAvailability.getInstance().isUserResolvableError(statusCode)) {
        callback.onBeUserResolvableError(statusCode);
        return false;
    } else if (statusCode != ConnectionResult.SUCCESS) {
        callback.onGooglePlayServicesFailed();
        return false;
    }

    return true;
}

Este no retorna un booleano si no un código de estado, el cual determina si la APK no existe, si está desactualizada, deshabilitada, etc.

Para evitar comprobar con cada estado, usa isUserResolvableError() para agrupar aquellos códigos que permiten que el usuario arregle el inconveniente. Si no es así entonces reporta con Callback.onGooglePlayServicesFailed().

Más adelante veremos cómo implementar singInUser().

Implementar el flujo de UI para la autenticación

1. Abre LoginActivity y dentro de onCreate() crea el presentador.

@Override
protected void onCreate(Bundle savedInstanceState) {
    //...

    // Obtener instancia FirebaseAuth
    mFirebaseAuth = FirebaseAuth.getInstance();

    LoginInteractor loginInteractor = new LoginInteractor(getApplicationContext(), mFirebaseAuth);
    mPresenter = new LoginPresenter(mLoginFragment, loginInteractor);
}

2. Desde LoginFragment inicia el presentador en onResume().

@Override
public void onResume() {
    super.onResume();
    mPresenter.start();
}

3. Allí mismo, modifica attemptLogin() para que obtenga las credenciales y se las pase al presentador.

private void attemptLogin() {
    mPresenter.attemptLogin(
            mEmail.getText().toString(),
            mPassword.getText().toString());
}

4. Abre LoginPresenter y modifica attemptLogin() para que el interactor inicie la autenticación.

@Override
public void attemptLogin(String email, String password) {
    mLoginView.showProgress(true);
    mLoginInteractor.login(email, password, this);
}

Usa showProgress() para mostrar la carga mientras ello sucede.

5. Sobrescribe todos los controladores de LoginInteractor.Callback para reportar los estados de resultado a la vista.

LoginPresenter.java

/**
 * Presentador del login
 */
public class LoginPresenter implements LoginContract.Presenter, LoginInteractor.Callback {

    private final LoginContract.View mLoginView;
    private LoginInteractor mLoginInteractor;

    public LoginPresenter(@NonNull LoginContract.View loginView,
                          @NonNull LoginInteractor loginInteractor) {
        mLoginView = loginView;
        loginView.setPresenter(this);
        mLoginInteractor = loginInteractor;
    }

    @Override
    public void start() {
        // Comprobar si el usuario está logueado
    }

    @Override
    public void attemptLogin(String email, String password) {
        mLoginView.showProgress(true);
        mLoginInteractor.login(email, password, this);
    }

    @Override
    public void onEmailError(String msg) {
        mLoginView.showProgress(false);
        mLoginView.setEmailError(msg);
    }

    @Override
    public void onPasswordError(String msg) {
        mLoginView.showProgress(false);
        mLoginView.setPasswordError(msg);
    }

    @Override
    public void onAuthSuccess() {
        mLoginView.showPushNotifications();
    }

    @Override
    public void onAuthFailed(String msg) {
        mLoginView.showProgress(false);
        mLoginView.showLoginError(msg);
    }

    @Override
    public void onBeUserResolvableError(int errorCode) {
        mLoginView.showProgress(false);
        mLoginView.showGooglePlayServicesDialog(errorCode);
    }

    @Override
    public void onGooglePlayServicesFailed() {
        mLoginView.showGooglePlayServicesError();
    }

    @Override
    public void onNetworkConnectFailed() {
        mLoginView.showProgress(false);
        mLoginView.showNetworkError();
    }


}

Corre la app y prueba las validaciones:

Android Login App

3. Validar credenciales del usuario con el Firebase Authentication SDK

Loguear usuario con email y password

Para iniciar la sesión de un usuario en Firebase realiza los siguientes pasos:

1. Obtén la instancia de FirebaseAuth en el método onCreate() de tu activity o fragment. Esto lo haces con el método getInstance(). Guarda la referencia en un campo.

Este objeto es el punto de entrada del SDK de Firebase Authentication. Desde su definición se ejecuta todo el modelo asíncrono de programación para enviar/recibir datos al server de forma automática, facilitando la implementación al android developer.

En nuestro caso añadiremos un nuevo campo a LoginFragment y luego asignaremos el singleton en onCreate().

//...
private FirebaseAuth mFirebaseAuth;

//...

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    if (getArguments() != null) {
        // Extracción de argumentos en caso de que los haya
    }

    // Obtener instancia FirebaseAuth
    mFirebaseAuth = FirebaseAuth.getInstance();
}

2. Crea una escucha de cambios de estado de inicio de sesión AuthStateListener en onCreate(). Luego regístrala al framework con FirebaseAuth.addAuthStateListener() en start().

La eliminación del registro hazla en onStop() con FirebaseAuth.removeAuthStateListener().

@Override
public void onStart() {
    super.onStart();
    mFirebaseAuth.addAuthStateListener(mAuthListener);
}

@Override
public void onStop() {
    super.onStop();
    if (mAuthListener != null) {
        mFirebaseAuth.removeAuthStateListener(mAuthListener);
    }
}

AuthStateListener es llamada cuando se detecta un cambio en el estado de la sesión del usuario actual. Para controlar esto se usa onAuthStateChanged().

El controlador recibe una instancia de FirebaseAuth para diferenciar el usuario al cual se le hace referencia si es que piensas usar varios proveedores.

3. Envía una petición de autenticación con FirebaseAuth.signInWithEmailAndPassword() pasando como parámetros el email y password del usuario.

Este método retorna en un objeto Task<AuthResult>. Donde Task representa una operación asíncrona en Google Apis y AuthResult contiene los datos del usuario si la autenticación fue un éxito.

Para saber en qué momento termina la operación, debes añadir una escucha OnCompleteListener con el método addOnCompleteListener().

Esta trae consigo el controlador onComplete() el cual se llama cuando la tarea se termina.

Nosotros ya sabemos que esta llamada va en el método signInUser() de LoginInteractor:

private void signInUser(String email, String password, final Callback callback) {
    mFirebaseAuth.signInWithEmailAndPassword(email, password)
            .addOnCompleteListener(new OnCompleteListener<AuthResult>() {
                @Override
                public void onComplete(@NonNull Task<AuthResult> task) {

                    if (!task.isSuccessful()) {
                        callback.onAuthFailed(task.getException().getMessage());
                    } else {
                        callback.onAuthSuccess();
                    }
                }
            });
}

Usa el método isSuccesful() para comprobar si la autenticación fue exitosa y reporta al presentador el resultado.

Si deseas obtener los datos del usuario usa AuthResult.getUser().

No obstante si el login fue exitoso, la escucha de cambios estado detectará este movimiento y podrás obtener los datos del usuario en ese lugar.

Cerrar sesión de un usuario

Car Insurance no implementará esta característica, pero si en algún momento deseas cerrar la sesión usa el método signOut():

FirebaseAuth.getInstance().signOut();

Añadir Firebase Cloud Messaging En Android

1. Incluye en tu archivo <proyecto>/build.gradle la siguiente dependencia:

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        // ...

        classpath 'com.google.gms:google-services:3.0.0'
    }
}

2. Incluye en tu archivo gradle de aplicación <proyecto>/<modulo>/build.gradle las siguientes dependencias:

dependencies {
 compile 'com.google.firebase:firebase-messaging:9.0.2'
}

// Incluyela al final
apply plugin: 'com.google.gms.google-services'

Al final sincroniza de nuevo la app con la acción emergente Sync Now.

Ambas ediciones de Gradle se ven resumidas en el paso 3 del asistente de Firebase. Confirma todo clickeando «FINALIZAR».

Añadir a build.gradle las dependencias de Firebase

3. Debes ver en el escritorio una card con la información del nuevo proyecto creado.

Proyecto Car Insurance en Firebase con Push Notifications

Para futuras modificaciones presiona el botón de overflow y luego selecciona «Administrar».

Administrar proyecto en Google Firebase Console

Modifica Tu AndroidManifest.xml

Para que el FCM SDK funcione con el framework de Android debes agregar dos services. Uno para la captación de mensajes y otro para la actualización de tokens.

Crea un paquete java llamado fcm para incluir estos elementos.

Agrega un Service que extienda de FirebaseMessagingService.

En Android Studio haz click derecho en la carpeta java y luego selecciona New > Service > Service. Usa el nombre «FCMService» y confirma.

Con ello el service se agrega en el manifesto como etiqueta <service>. Búscalo y agrega el siguiente intent filter:

<service android:name=".FCMService">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

Ahora abre la clase java del Service y extiéndela de FirebaseMessagingService.

public class FCMService extends FirebaseMessagingService {
...

Agrega un Service que extienda de FirebaseInstanceIdService.

Crea un nuevo service como hiciste en el paso anterior y nómbralo «FCMInstanceIdService». Luego agrega el siguiente intent filter en el manifesto:

<service android:name=".FCMInstanceIdService">
    <intent-filter>
        <action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
    </intent-filter>
</service>

Por último extiende la clase de FirebaseInstanceIdService.

public class FCMInstanceIdService extends FirebaseInstanceIdService {
...

Crear Screen De Notificaciones

Como viste en la descripción inicial de Car Insurance, las notificaciones push serán recibidas en una activity con un fragment cuyo contenido es una lista.

Con esto en mente debes crear un paquete Java con el nombre notifications. En él debes agregar los siguientes elementos:

Archivo Propósito
NotificationsActivity.java Actividad que actúa como screen de las notificaciones
NotificationsFragment.java Fragmento que despliega una lista de notificaciones
NotificationsAdapter.java Adaptador del RecyclerView de notificaciones
NotificationsContract.java Interfaz para la representación general de la vista y el presentador
NotificationsPresenter.java Presentador para cargar una lista de notificaciones

Por otro lado, los componentes asociados a los datos los pondremos en un paquete llamado data.

Archivo Propósito
PushNotification.java POJO java para representar la entidad de las notificaciones
PushNotificationsRepository.java Repositorio temporal para el guardado de las notificaciones entrantes

Arquitectura

La arquitectura de esta característica es similar al screen de login, solo que ahora interviene un repositorio ficticio de datos para almacenar las notificaciones push de forma temporal.

Analicemos el flujo:

  1. Usamos la consola de Firebase en la sección Notifications para crear y enviar un mensaje al servidor
  2. El Push Messaging Service lo recibe y lo despacha hacia la app Android, donde el Firebase Cloud Messaging internamente obtendrá el mensaje.
  3. Desde el FCM SDK envíamos un broadcast hacia la vista, donde estará esperando un BroadcastReceiver.
  4. Las vista comunica al presenter que llegó una nueva push notification
  5. El presenter se comunica con el repositorio de datos para salvar la instancia temporalmente.
  6. Una vez el repositorio haya guardado satisfactoriamente la entidad, se lo comunica al presenter.
  7. Con ello el presenter actualiza la lista en la vista, apilando el nuevo ítem.

Arquitectura MVP Android para FCM

Conectar la vista con el presentador

Los siguientes son comportamientos que debe tener la vista de las notificaciones al iniciarse la screen:

  • Mostrar notificaciones
  • Mostrar mensaje al no haber notificaciones

En cuanto al presentador debería:

  • Cargar las notificaciones de la fuente de datos
  • Registrar el cliente FCM

Este resumen produciría las siguientes interfaces.

NotificationsContract.java

/**
 * Conexión View - Presenter
 */
public interface NotificationContract {

    interface View extends BaseView<Presenter>{

        void showNotifications(List<PushMessage> notifications);

        void showNoMessagesView();
    }

    interface Presenter extends BasePresenter{

        void registerAppClient();

        void loadNotifications();
    }
}

Establecer el modelo

Definir entidades

Los datos que mostraremos en la vista se representan por la entidad PushNotification. Esta contiene los siguientes atributos:

  • id: Identificador para las notificaciones.
  • title: Título de la promoción
  • description: Descripción de la promoción
  • expiryDate: Fecha de expedición
  • discount: El valor del descuento en decimal (D= [0, 1.0]).

Con esto claro solo debemos crear una clase tradicional con un constructor, gets y sets.

PushNotification.java

/**
 * Representación de una promoción en forma de push notification
 */
public class PushNotification {
    private String id;
    private String mTitle;
    private String mDescription;
    private String mExpiryDate;
    private float mDiscount;

    public PushNotification() {
        id = UUID.randomUUID().toString();
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return mTitle;
    }

    public void setTitle(String title) {
        this.mTitle = title;
    }

    public void setDescription(String description) {
        this.mDescription = description;
    }

    public String getDescription() {
        return mDescription;
    }

    public void setExpiryDate(String expiryDate) {
        mExpiryDate = expiryDate;
    }

    public String getExpiryDate() {
        return mExpiryDate;
    }

    public void setDiscount(float discountValue) {
        mDiscount = discountValue;
    }

    public float getDiscount() {
        return mDiscount;
    }
}

Crear repositorio de notificaciones

El repositorio seguirá un patrón de acceso de datos estático, del cual obtendremos y guardaremos notificaciones.

Para ello debes:

  • Implementar un singleton que posea un ArrayMap de objetos PushNotification.
  • Definir una interfaz de comunicación que retorne los datos del mapa.
  • Establecer dos métodos. Uno para guardar las notificaciones y otro para retornar todos los elementos.

Con eso tendremos:

/**
 * Repositorio de push notifications
 */
public final class PushNotificationsRepository {

    private static ArrayMap<String, PushNotification> LOCAL_PUSH_NOTIFICATIONS = new ArrayMap<>();
    private static PushNotificationsRepository INSTANCE;

    private PushNotificationsRepository() {
    }

    public static PushNotificationsRepository getInstance() {
        if (INSTANCE == null) {
            return new PushNotificationsRepository();
        } else {
            return INSTANCE;
        }
    }

    public void getPushNotifications(LoadCallback callback) {
        callback.onLoaded(new ArrayList<>(LOCAL_PUSH_NOTIFICATIONS.values()));
    }

    public void savePushNotification(PushNotification notification) {
        LOCAL_PUSH_NOTIFICATIONS.put(notification.getId(), notification);
    }

    public interface LoadCallback {
        void onLoaded(ArrayList<PushNotification> notifications);
    }

}

Fijate que getPushNotifications() recibe una instancia de la interfaz de comunicación, la cual se espera sea creada de forma anónima en el presentador.

En cuanto a savePushNotification(), recibe un objeto nuevo, el cual es guardado con put() en la fuente.

La capa de Vista

Modificar layout del fragment de notificaciones

Abre el archivo res/layout/fragment_notifications.xml y añade un RecyclerView para representar la lista.

Si deseas mostrar un mensaje cuando no hayan elementos, entonces crea un Empty state pattern sencillo con un icono y texto descriptivo:

App Screen con Empty State Pattern

Con ambos elementos tendrás algo parecido a esto:

fragment_notifications.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rv_notifications_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="LinearLayoutManager"
        tools:listitem="@layout/item_list_notification" />

    <LinearLayout
        android:id="@+id/noMessages"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:orientation="vertical"
        tools:visibility="gone">

        <ImageView
            android:id="@+id/noMessagesIcon"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_gravity="center"
            android:scaleType="fitXY"
            android:src="@drawable/ic_bell"
            android:tint="@android:color/darker_gray" />

        <TextView
            android:id="@+id/noMessagesText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginTop="8dp"
            android:gravity="center"
            android:text="@string/no_message_text"
            android:textAppearance="@style/TextAppearance.AppCompat.Small" />
    </LinearLayout>


</RelativeLayout>

Crear layout para el ítem de lista

El siguiente mock de alto nivel muestra el resultado final para la distribución de los views del ítem con respecto a los atributos descritos en la clase PushNotification:

Card de Promociones con Material Design en Android

Como ves, el nodo es un CardView para la creación de la tarjeta. Puedes ver un tuto sobre cómo usarlas aquí:

Tutorial de Cards

La distribución interna puedes realizarla en un RelativeLayout ya que tendremos ubicaciones atípicas.

En definitiva, crea un nuevo layout llamado item_list_notification.xml y añade la siguiente definición XML:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:cardUseCompatPadding="true"
    app:contentPaddingLeft="@dimen/activity_horizontal_margin"
    app:contentPaddingRight="@dimen/activity_horizontal_margin"
    app:contentPaddingTop="8dp"
    app:contentPaddingBottom="8dp"
    android:layout_marginRight="@dimen/activity_horizontal_margin"
    android:layout_marginLeft="@dimen/activity_horizontal_margin"
    android:layout_marginTop="8dp"
    android:minHeight="?attr/listPreferredItemHeight"
    android:layout_height="wrap_content">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/tv_discount"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_centerVertical="true"
            android:text="New Text"
            android:textAppearance="@style/TextAppearance.AppCompat.Display1"
            tools:text="50%" />

        <TextView
            android:id="@+id/tv_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentTop="true"
            android:layout_marginLeft="16dp"
            android:layout_toRightOf="@+id/tv_discount"
            android:text="New Text"
            android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
            tools:text="¡Cyberlunes!" />

        <TextView
            android:id="@+id/tv_description"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/tv_title"
            android:layout_marginLeft="16dp"
            android:layout_toRightOf="@+id/tv_discount"
            android:text="New Text"
            tools:text="Compra en línea ahora mismo y ahorra hasta un 50% en el seguro de tu automóvil" />

        <TextView
            android:id="@+id/tv_expiry_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/tv_description"
            android:layout_marginLeft="16dp"
            android:layout_marginTop="8dp"
            android:layout_toRightOf="@+id/tv_discount"
            android:text="New Text"
            android:textAppearance="@style/TextAppearance.AppCompat.Caption"
            android:textStyle="italic"
            tools:text="Valido hasta el 7/07/2016" />

        <Button
            android:id="@+id/button"
            style="@style/Widget.AppCompat.Button.Borderless"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/tv_expiry_date"
            android:layout_toRightOf="@+id/tv_discount"
            android:text="REDIMIR"
            android:textAppearance="@style/TextAppearance.AppCompat.Caption"
            android:textColor="?attr/colorPrimary"
            android:textSize="13sp" />

    </RelativeLayout>
</android.support.v7.widget.CardView>

Finalmente el mock del screen de las notificaciones se proyectaría así:

Lista de notificaciones de FCM Android

Crear adaptador de push notifications

Crea un adaptador para el RecyclerView clase que extienda de RecyclerView.Adapter y sobrescribe los métodos de inflado y binding.

La idea es que se base en una fuente de datos basada en una colección de objetos PushNotification.

PushNotificationsAdapter.java

/**
 * Adaptador de notificaciones
 */
public class PushNotificationsAdapter
        extends RecyclerView.Adapter<PushNotificationsAdapter.ViewHolder> {

    ArrayList<PushNotification> pushNotifications = new ArrayList<>();

    public PushNotificationsAdapter() {
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        Context context = parent.getContext();
        LayoutInflater inflater = LayoutInflater.from(context);
        View itemView = inflater.inflate(R.layout.item_list_notification, parent, false);
        return new ViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        PushNotification newNotification = pushNotifications.get(position);

        holder.title.setText(newNotification.getTitle());
        holder.description.setText(newNotification.getDescription());
        holder.expiryDate.setText(String.format("Válido hasta el %s", newNotification.getExpiryDate()));
        holder.discount.setText(String.format("%d%%", (int) (newNotification.getDiscount() * 100)));
    }

    @Override
    public int getItemCount() {
        return pushNotifications.size();
    }

    public void replaceData(ArrayList<PushNotification> items) {
        setList(items);
        notifyDataSetChanged();
    }

    public void setList(ArrayList<PushNotification> list) {
        this.pushNotifications = list;
    }

    public void addItem(PushNotification pushMessage) {
        pushNotifications.add(0, pushMessage);
        notifyItemInserted(0);
    }

    public class ViewHolder extends RecyclerView.ViewHolder {
        public TextView title;
        public TextView description;
        public TextView expiryDate;
        public TextView discount;

        public ViewHolder(View itemView) {
            super(itemView);
            title = (TextView) itemView.findViewById(R.id.tv_title);
            description = (TextView) itemView.findViewById(R.id.tv_description);
            expiryDate = (TextView) itemView.findViewById(R.id.tv_expiry_date);
            discount = (TextView) itemView.findViewById(R.id.tv_discount);
        }
    }
}

Este adaptador tiene los siguientes tres métodos personalizados:

  • replaceData(): Actualiza los datos actuales por un nuevo conjunto.
  • setList(): Asignador de ítems con checkeo de restricción nula.
  • addItem(): Añade un solo ítem en la posición 0.

Implementar la vista con el fragmento de notificaciones

El fragmento NotificationsFragment debe implementar la interfaz de vista NotificationsContract.View y luego sobrescribir los controladores.

NotificationsFragment.java

/**
 * Muestra lista de notificaciones
 */
public class PushNotificationsFragment extends Fragment implements PushNotificationContract.View {

   
    private RecyclerView mRecyclerView;
    private LinearLayout mNoMessagesView;
    private PushNotificationsAdapter mNotificatiosAdapter;

    private PushNotificationsPresenter mPresenter;


    public PushNotificationsFragment() {
    }

    public static PushNotificationsFragment newInstance() {
        PushNotificationsFragment fragment = new PushNotificationsFragment();
        // Setup de Argumentos
        return fragment;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (getArguments() != null) {
            // Gets de argumentos
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View root = inflater.inflate(R.layout.fragment_notifications, container, false);

        mNotificatiosAdapter = new PushNotificationsAdapter();
        mRecyclerView = (RecyclerView) root.findViewById(R.id.rv_notifications_list);
        mNoMessagesView = (LinearLayout) root.findViewById(R.id.noMessages);
        mRecyclerView.setAdapter(mNotificatiosAdapter);
        return root;
    }

    @Override
    public void onResume() {
        super.onResume();
        mPresenter.start();
    }

    @Override
    public void onPause() {
        super.onPause();
    }

    @Override
    public void showNotifications(ArrayList<PushNotification> notifications) {
        mNotificatiosAdapter.replaceData(notifications);
    }

    @Override
    public void showEmptyState(boolean empty) {
        mRecyclerView.setVisibility(empty ? View.GONE : View.VISIBLE);
        mNoMessagesView.setVisibility(empty ? View.VISIBLE : View.GONE);
    }

    @Override
    public void popPushNotification(PushNotification pushMessage) {
        mNotificatiosAdapter.addItem(pushMessage);
    }

    @Override
    public void setPresenter(PushNotificationContract.Presenter presenter) {
        if (presenter != null) {
            mPresenter = (PushNotificationsPresenter) presenter;
        } else {
            throw new RuntimeException("El presenter de notificaciones no puede ser null");
        }
    }

}

El código anterior realiza las siguientes acciones:

  • onCreateView(): Asignamos el adaptador al RecyclerView.
  • onResume(): Se inicia el presentador.
  • showNotifications(): Actualiza los datos del adaptador con las notificaciones existentes.
  • showEmptyState(): Cambia entre empty state y la lista. El método View.setVisible() te lo permite junto a las constantes View.GONE y View.VISIBLE.
  • setPresenter(): Vincula la vista actual (fragmento) con el presentador.

Implementar presentador de notificaciones

Abre la clase NotificationsPresenter e implementa la interfaz NotificationsContract.Presenter.

public class NotificationsPresenter implements NotificationContract.Presenter {

Lo primero es añadir dos campos para la vista y otro para el componente FirebaseMessaging.

private final NotificationContract.View mNotificationView;
private final FirebaseMessaging mFCMInteractor;

El punto de entrada del constructor debe asignar ambas instancias de los campos. Adicionalmente haz efectivo el vínculo del presenter con la view con setPresenter.

private final NotificationContract.View mNotificationView;
private final FirebaseMessaging mFCMInteractor;

Ahora sobrescribe el método base start() con el registro de FCM con el método subscribeToTopic(). Aunque yo uso el tema «promos», ya depende de ti como nombrar el tema y que condiciones usarás para asignarlo si es que tienes más de uno.

Lo siguiente es llamar a loadNotifications() para iniciar la carga de datos.

@Override
public void start() {
    registerAppClient();
    loadNotifications();
}
@Override
public void registerAppClient() {
    mFCMInteractor.subscribeToTopic("promos");
}

@Override
public void loadNotifications() {
    NotificationsRepository.getInstance().getNotifications(
            new NotificationsRepository.LoadNotificationsCallback() {
                @Override
                public void onNotificationsLoaded(List<PushMessage> notifications) {
                    if (notifications.size() > 0){
                        mNotificationView.showEmptyState(false);
                        mNotificationView.showNotifications(notifications);
                    }else {
                        mNotificationView.showEmptyState(true);
                    }
                }
            }
    );
}

En loadNotifications() llamamos el método getNofications() del repositorio de notificaciones.

Esto requiere crear una escucha anónima del tipo LoadNotificationsCallback(), donde se sobrescribirá el controlador onNotificationsLoaded(), el cual retorna la lista de notificaciones existentes.

Luego hay dos reglas de la aplicación. Si hay más de una notificación, entonces poblamos la lista con View.showNotifications().

De lo contrario invocamos al empty state desde la vista con View.showEmptyState().

Preparar la actividad de notificaciones

La actividad de notificaciones debe:

  • Realizar una transacción add() de PushNotificationsFragment
  • Crear un instancia de PushNotificationsPresenter
  • Comprobar si hay un usuario con sesión iniciada. Si no es así, entonces debes redirigir la app a la LoginActivity.

PushNotificationsActivity.java

public class PushNotificationsActivity extends AppCompatActivity {

    private static final String TAG = PushNotificationsActivity.class.getSimpleName();

    private PushNotificationsFragment mNotificationsFragment;
    private PushNotificationsPresenter mNotificationsPresenter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_notifications);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        setTitle(getString(R.string.title_activity_notifications));

        // ¿Existe un usuario logueado?
        if (FirebaseAuth.getInstance().getCurrentUser() == null) {
            startActivity(new Intent(this, LoginActivity.class));
            finish();
        }

        mNotificationsFragment =
                (PushNotificationsFragment) getSupportFragmentManager()
                        .findFragmentById(R.id.notifications_container);
        if (mNotificationsFragment == null) {
            mNotificationsFragment = PushNotificationsFragment.newInstance();
            getSupportFragmentManager()
                    .beginTransaction()
                    .add(R.id.notifications_container, mNotificationsFragment)
                    .commit();
        }

        mNotificationsPresenter = new PushNotificationsPresenter(
                mNotificationsFragment, FirebaseMessaging.getInstance());
    }
}

Enviar Mensajes con Firebase Notifications

Firebase Notifications es un servicio gratuito de la plataforma Firebase Cloud para permitir enviar downstream messages a las aplicaciones clientes.

Arquitectura Firebase Notifications

Esta solución provee un panel de administración sencillo con los requerimientos mínimos de UI para enviar una notificación con personalización media.

Esto quiere decir, que se comporta como una app servidor para el envío de mensajes.

La usaremos en el ejemplo actual ya que no se requiere personalización compleja, pero si tus push notifications se basan en comportamientos ligados a un sistema personalizado, es mejor que crees tu propia app server.

Un plus es que te permite realizar tracking del número de mensajes enviados, leídos, programados, de la cantidad de usuarios activos, etc. de forma automática con el servicio Firebase Analytics.

Enviar notificaciones desde la consola Notifications

Ve a la consola de Firebase y selecciona el proyecto Car Insurance:

Proyecto Car Insurance en Firebase console

Ahora dirígete al panel derecho, busca la sección GROW y selecciona Notifications.

Firebase > GROW > Notifications

Con ello tendrás el área de acción para enviar notificaciones.

Para enviar tu primer mensaje presiona el raised button que dice «ENVÍA TU PRIMER MENSAJE».

Firebase > Notifications > ENVÍA TU PRIMER MENSAJE

A continuación verás un formulario para redactar el contenido de la notificación push y configurar su comportamiento.

Redactar mensaje en Firebase Notifications

Veamos el propósito de las propiedades más frecuentes.

Texto del mensaje

Es el cuerpo de la notificación como tal. El contenido primario con la mayoría de datos informativos para el usuario.

Texto del mensaje en Firebase Notifications

Etiqueta del mensaje

Nombre que actúa como identificador  del mensaje en la base de datos interna de Firebase Notifications.

Etiqueta del mensaje en Firebase Notifications

Aquí puedes determinar un estándar de nombrado para seguir un orden lógico y distintivo.

Úsalo con cuidado, ya que lo verás en cada ítem de lista en el área de Notifications.

Lista de notificaciones en Firebase

Fecha de entrega

Determina el instante en el que enviarás la notificación push.

Tendrás dos opciones «Enviar ahora» y «Enviar más tarde».

Fecha de entrega en Firebase Notifications

La primera despacha lo más pronto posible el mensaje hacia los clientes Android.

La segunda te permite programar el periodo futuro para el envío. Esto es de gran utilidad por si tienes un plan de campañas mensual con diferentes avisos.

"Enviar más tarde" en Redacción de notificación

La configuración posterior la puedes basar en una fecha, hora y zona horaria.

Por defecto se usa la zona horario del dispositivo del usuario, pero puedes desmarcar el checkbox y elegir cualquier rango GMT.

Configuración de zona horaria de notificación

Objetivo

Es el usuario o conjunto de usuarios a los cuáles se les enviará el mensaje.

Objetivo de la notificación

La opción Segmento de usuarios hace referencia a un subconjunto de clientes filtrados por criterios como la Aplicación en la que están registrados, el tipo de Audiencia definida en Analytics, el Idioma del dispositivo y la Versión de la app o del FCM SDK usado.

Opción Segmento de usuarios en edición de notificaciones

La opción Tema se refiere al concepto Topic del registro en Firebase Messaging. Aquí aparecerá una lista de todos los topics registrados desde clientes Android para la selección apropiada.

Opción Tema en sección Objetivo

Puede que la aparición de un nuevo topic en la lista demore hasta 24 horas. Así que no te preocupes si no ves los tuyos cuando recién inicias el envío de notificaciones.

En última instancia tenemos la opción Un único dispositivo. Permite enviar a un solo dispositivo identificado por su Token de registro de FCM.

Opción Un único dispositivo en Firebase Notifications

Recuerda que la implementación de FirebaseInstanceIdService te permite obtener el token al momento de ser creado o actualizado.

Eventos de conversión

Un evento de conversión es una interacción del usuario con la app, que dispara un trigger para el registro de dicha acción.

Opción Eventos de conversión en Firebase Notifications

Esta propiedad hace parte del estudio del servicio Firebase Analytics. El conteo de los eventos de conversión puede ser visto en Analytics > EVENTOS.

Firebase Analytics > EVENTOS

Opciones avanzadas

La última sección se trata de opciones avanzadas para el complemento de la push notification.

Opciones avanzadas en Firebase Notifications

Como dice el texto informativo inicial, todos los campos son opcionales.

Aunque el contenido de la mayoría es intuitivo, te dejo la descripción de cada uno:

  • Título: Encabezado de la notificación para impacto en el usuario
  • Datos personalizados: Una serie de pares clave-valor para enviar como atributos adicionales en el cuerpo de la notificación.
  • Prioridad: Determina la importancia de envío de un mensaje.
  • Sonido: Habilita/Deshabilita la reproducción del sonido de la notificación. Solo en iOS. En Android debemos añadir la característica al objeto Notification.
  • Fecha de caducidad: El tiempo que estará la notificación disponible en la base de datos de Firebase Notifications. Dejar sobrevivir el mensaje te permitirá duplicar su contenido para envíos posteriores que sean similares.

Procesar push notifications en Android

Acceder al token de registro de FCM

Antes de recibir la notificación, loguearemos el token de registro para tener en cuenta su existencia.

Para ello ve a IFirebaseInstanceIdService y sobrescribe onRefreshToken() con un logueo Log.d() cuyo texto central es el resultado del método FirebaseInstanceId.getInstance().getToken().

IFirebaseInstanceIdService.java

public class IFirebaseInstanceIdService extends FirebaseInstanceIdService {
    private static final String TAG = IFirebaseInstanceIdService.class.getSimpleName();

    public IFirebaseInstanceIdService() {
    }

    @Override
    public void onTokenRefresh() {
        String fcmToken = FirebaseInstanceId.getInstance().getToken();
        Log.d(TAG, "FCM Token: " + fcmToken);

        sendTokenToServer(fcmToken);
    }

    private void sendTokenToServer(String fcmToken) {
        // Acciones para enviar token a tu app server
    }
}

El método hipotético llamado sendTokenToServer() enviaría en un futuro el token a nuestra app server para guardarlo en la tabla de usuarios en una base de datos personalizada.

Esto podría serte de utilidad si deseas operar con notificaciones para un grupo de dispositivos personalizado. Donde consultarías los tokens a tu servicio web para crear el grupo.

Recibir y manejar mensajes en una app Android

1. Ya sabes que IFirebaseMessagingService recibe los mensajes en su controlador onMessageReceived(). Así que tan solo queda procesar su contenido para incluirlo en la lista.

La idea es crear una notificación de usuario para que muestre al usuario la llegada de una nueva promoción en la status bar o en el lock screen. Incluso puedes usar la prioridad Alta para mostrarla de forma emergente.

IFirebaseMessagingService.java

public class IFirebaseMessagingService extends FirebaseMessagingService {
    private static final String TAG = IFirebaseMessagingService.class.getSimpleName();

    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {
        Log.d(TAG, "¡Mensaje recibido!");
        displayNotification(remoteMessage.getNotification(), remoteMessage.getData());
        sendNewPromoBroadcast(remoteMessage);
    }

    private void sendNewPromoBroadcast(RemoteMessage remoteMessage) {
        Intent intent = new Intent(PushNotificationsFragment.ACTION_NOTIFY_NEW_PROMO);
        intent.putExtra("title", remoteMessage.getNotification().getTitle());
        intent.putExtra("description", remoteMessage.getNotification().getBody());
        intent.putExtra("expiry_date", remoteMessage.getData().get("expiry_date"));
        intent.putExtra("discount", remoteMessage.getData().get("discount"));
        LocalBroadcastManager.getInstance(getApplicationContext())
                .sendBroadcast(intent);
    }

    private void displayNotification(RemoteMessage.Notification notification, Map<String, String> data) {
        Intent intent = new Intent(this, PushNotificationsActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent,
                PendingIntent.FLAG_ONE_SHOT);

        Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
        NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this)
                .setSmallIcon(R.drawable.ic_car)
                .setContentTitle(notification.getTitle())
                .setContentText(notification.getBody())
                .setAutoCancel(true)
                .setSound(defaultSoundUri)
                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
                .setContentIntent(pendingIntent);

        NotificationManager notificationManager =
                (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);

        notificationManager.notify(0, notificationBuilder.build());
    }

}

Para reportar la nueva aparición de una notificación a la UI usaremos el componente LocalBroadcastManager.

Este permite enviar un Intent hacia otros componentes Android para procesar llamadas. La idea es enviar en los extras del intent los datos que conforman la notificación.

2. Ahora desde NotificationsFragment registraremos un BroadcastReceiver para recibir los intents. Su comportamiento nos dice que debemos registrarlo en onResume() y eliminar el registro en onPause():

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    if (getArguments() != null) {
        // Gets de argumentos
    }

    mNotificationsReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String title = intent.getStringExtra("title");
            String description = intent.getStringExtra("description");
            String expiryDate = intent.getStringExtra("expiry_date");
            String discount = intent.getStringExtra("discount");
            mPresenter.savePushMessage(title, description, expiryDate, discount);
        }
    };
}

@Override
public void onResume() {
 super.onResume();
 mPresenter.start();

 LocalBroadcastManager.getInstance(getActivity())
 .registerReceiver(mNotificationsReceiver, new IntentFilter(ACTION_NOTIFY_NEW_PROMO));
}

@Override
public void onPause() {
 super.onPause();
 LocalBroadcastManager.getInstance(getActivity())
 .unregisterReceiver(mNotificationsReceiver);
}

La instancia anónima creada en onCreate(), sobrescribe onReceive(), donde le diremos al presenter que guarde los datos en el repositorio.

Actualmente Firebase Notifications Console tiene algunos problemas con el envío de notificaciones cuando la app está cerrada o en background. Por lo que debemos esperar al equipo desarrollador para que nos provean de actualizaciones.

Enviar notificación push

Ve a la consola, compón un mensaje con las siguientes características y envíalo a todos los usuarios cuyo segmento sea el registro en com.herprogramacion.carinsurance o el paquete que hayas elegido para tu app.

  1. Título¡Cyberlunes!
  2. DescripciónCompra en línea ahora mismo y ahorra hasta un 50% en el seguro de tu automóvil
  3. EtiquetaPromo_#2_Junio_2016
  4. Datos personalizados: (expiry_date:30/06/2016), (disscount,0.5)

Cuando todo esté listo presiona «ENVIAR MENSAJE».

ENVIAR MENSAJE en Firebase Notifications

Deberías ver la aparición de la promoción en la lista y ver el icono del auto en la status bar.

Notificaciones Push en Android 6

Conclusión

Los nuevos servicios de Firebase tienen un potencial increíble por explotar.

Aunque el tema central de este tutorial era el envío de notificaciones push con Firebase Cloud Messaging, vimos que Firebase Authentication es un excelente servicio que nos facilita el login con múltiples proveedores en nuestra app.

También vimos que es posible integrar con Firebase Analytics para obtener informes con las métricas que consideres más importantes en el balance de tu app.

Así que ahora todo depende de ti conocer más a fondo este servicio. Aunque hay varios elementos que necesitan trabajo y aún presentan bugs, de seguro es un servicio que vale la pena agregar a nuestra caja de herramientas.

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!