¿Cómo Sincronizar Sqlite Con Mysql En Android?

¿Te has preguntado cómo sincronizar una base de datos local en Sqlite con una base de datos Mysql que está en un servidor?

Este es uno de los temas sobre Desarrollo Android que más trabajo puede exigir, para que nuestras aplicaciones conserven de forma exitosa los datos de los usuarios.

La idea clave para sincronizar es evitar realizar las acciones de actualización dentro del hilo principal.

Recuerda que si ejecutas acciones de larga duración en el hilo UI, puede ser nefasto para la experiencia de usuario, causando bloqueos frecuentes en tu app y provocando la furia de muchos.

Si no tienes una estrategia o arquitectura para sincronizar efectivamente, entonces este tutorial te será de gran ayuda.

Sigue leyendo para aprender a usar Sync Adapters. Elementos destinados a correr de forma asíncrona las actualizaciones de nuestros datos, apoyándose del framework de sincronización de Android.

Por supuesto toda la explicación está basada en un ejemplo didáctico que implementará los conceptos vistos.

El tema de hoy requiere que tengas conocimientos previos para comprenderlo en su totalidad. Te recomienda leas los siguientes artículos:

Descargar Proyecto Android Studio De Crunch Expenses

En el video anterior puedes ver el resultado final de la app basada en un servicio web para la sincronización.

Su nombre es Crunch Expenses y si deseas desbloquear el link de descarga, sigue estas instrucciones:

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

Sincronización De Datos En Android

Como bien sabes, la sincronización de datos es el proceso de copiar automáticamente un conjunto de datos entre uno o más dispositivos, de tal forma que la información se encuentre al día.

Sincronización En Android

En nuestro caso deseamos que los datos que existen en un servidor sean depositados en nuestro dispositivo móvil. A esto se le llama sincronización local.

Por otro lado, también es posible que la aplicación realice cambios en el contenido local y sea necesario reflejar este comportamiento en el servidor. Esta acción se llama sincronización remota.

Sin embargo antes de implementar una solución para este problema, es conveniente que tú y tu equipo defina las condiciones de sincronización de datos.

Esto depende mucho del propósito de la aplicación.

Por ejemplo…

—No es lo mismo una aplicación de uso público a una solución empresarial. Ambos casos se rigen por requerimientos distintos.

—No es lo mismo la sincronización en un gestor de actividades que en un lector rss, ya que uno actualiza datos en tiempo real y otro se alimenta de una base de datos remota.

Algunas de las cosas que debes pensar antes de desarrollar tu aplicación son:

  • ¿El usuario podrá sincronizar los datos manualmente?
  • En caso de que la sincronización sea manual, ¿cuál es el tiempo necesario entre una sincronización y otra?
  • ¿Cómo evitar que un registro que está pendiente por sincronizarse sea puesto de nuevo en cola?
  • ¿Si el dispositivo no tiene conexión de red, de qué forma se controlará la sincronización?
  • ¿Cómo evitar que los tipos de datos entre tecnologías afecte la información de las bases de datos?
  • ¿Qué estilo de servicio web se implementará (REST, SOAP, etc.)?
  • ¿Cuál es el pronóstico del volumen de datos que será sincronizado?
  • ¿La sincronización debe ser en tiempo real?
  • ¿Qué tan necesario crees que la sincronización debe ser automática y llevada a cabo a una hora determinada del día?
  • ¿Cuál será el límite de registros que tendrás en tu base de datos local para evitar sobrecarga?
  • ¿Es necesario usar paginado?
  • ¿De qué forma entregarás el control de la sincronización en tu actividad de preferencias?
  • Muchas más…

Con esto claro, ya es posible pensar en desarrollar los componentes necesarios en tu aplicación Android.

Arquitectura Basada En Un SyncAdapter

Virgil Dobjanschi en su conferencia «Google I/O 2010 – Android REST client applications» expone tres estilos arquitecturales que podemos usar a la hora de comunicar nuestras aplicaciones Android con un servicio REST.

Aunque en este artículo no usaremos el estilo REST, el tercer enfoque (puedes ver el minuto 44:00) mencionado es ideal para sincronizar los datos de un servicio web.

El patrón está basado en un componente llamado SyncAdapter, cuya funcionalidad es sincronizar datos en segundo plano entre una aplicación Android y un servidor remoto.

Arquitectura De Sincronización Para REST APIs

Se caracteriza por realizar las acciones asíncronicamente, es decir, en periodos de tiempo sin inicio o fin determinados. Por ello puede que la transferencia de datos no suceda cuando esperamos, sin embargo asegurando la integridad de la información.

Esta arquitectura se enfoca en evitar realizar la sincronización en el UI Thread. Lo que libera a nuestras actividades de operaciones en la base de datos, peticiones Http, etc.

Si observas el diagrama anterior, se muestra un ContentProvider para implementar el modelo de datos. Esto se debe a la seguridad que brinda a la estructura de la base de datos Sqlite y la capacidad de compartir dicha información entre aplicaciones.

Además el content provider permite la actualización de la interfaz de usuario en tiempo real gracias a los Loaders.

Las peticiones Http son manejadas por el mismo SyncAdapter, evitando comprometer el hilo principal que procesa los eventos de interfaz.

Autenticación De Usuarios En Android

La sincronización en Android está ligada al uso de cuentas de usuario para determinar qué datos asociados serán procesados.

Esto quiere decir que no es posible usar sincronización sin crear al menos una cuenta asociada a la aplicación.

Cuentas En Android 5

Incluso si tu aplicación no usa un login, debe incluirse obligatoriamente la creación de una cuenta local auxiliar que satisfaga este requerimiento.

Por ello, para implementar el esquema de transferencia de datos en Android se deben implementar dos partes: Autenticación y Sincronización.

Autenticación del cliente con el servidor remoto — Para usar el sistema de Autenticación de Cuentas de Android debes incluir en tu app las siguientes piezas:

  1. Una autenticador de cuentas que extienda de la clase AbstractAccountAuthenticator. Este elemento se encarga de comprobar las credenciales, escribir las opciones de acceso, crear las cuentas, etc.
  2. Un bound service que sea lanzado por un intent con el filtro android.accounts.AccountAuthenticator y retorne una instancia del autenticador en su método onBind().
  3. Una descripción XML que especifique el diseño de la cuenta dentro de la sección Accounts de los ajustes de android.

Si tu aplicación no requiere autenticación, entonces debes crear las anteriores piezas sin ninguna funcionalidad, como un soporte auxiliar.

Implementar Un SyncAdapter

Este proceso es controlado por un elemento del sistema llamado SyncManager, el cual se encarga de añadir a una cola de gestión las sincronizaciones de todas las aplicaciones.

Incluso comprueba que la conexión a la red esté disponible antes de iniciar una sincronización. También se encarga de reiniciar una sincronización fallida por si nuestro Sync Adapter ha fallado.

Para acceder a este framework simplemente debemos usar cuatro piezas de código:

  1. Un Content provider que proporcione flexibilidad y seguridad a los datos locales. Este elemento es obligatorio. Si tu aplicación no usa un content provider, entonces crea uno auxiliar para satisfacer la característica.
  2. Un Sync Adapter que extienda de la clase AbstractThreadedSyncAdapter que maneje la sincronización de la aplicación. Donde se sobrescribe el método onPerfomSync() para indicar las acciones de actualización, peticiones http, parsing, etc.
  3. Un bound service que esté registrado para escuchar el filtro android.content.SyncAdapter y que retorne en su método onBind() el Sync Adapter. Este servicio se comunicará con el sistema para controlar la sincronización.
  4. Una definición XML que le diga al Sync Manager de qué forma se manipularán los datos a sincronizar.

Si notas, la autenticación y la sincronización usan servicios. Esto es para evitar la ruptura de la sincronización en caso de que la aplicación pase a segundo plano o se extienda prolongadamente.

Por otro lado, ¿Cómo se inicia un Sync Adapter?

Existen varias formas que pueden ejecutar la sincronización de este elemento.

  • Por Cambios en el servidor— En este escenario, el Sync Adapter se inicia debido a que se produce una petición desde el servidor hacia el dispositivo Android, cuando los datos en él cambian . A esto se le llama notificaciones push y podemos implementarlo con el servicio de Firebase Cloud Messaging.
  • Por Cambios en el contenido local— Cuando los datos del Content Provider son modificados en la aplicación local, el Sync Adapter puede iniciarse automáticamente para subir los datos nuevos al servidor y asegurar una actualización.
  • Al enviar mensaje de red— Android comprueba la disponibilidad de la red enviando un mensaje de prueba con frecuencia. Podemos indicar a nuestro Sync Adapter que se inicie cada vez que este mensaje es liberado.
  • Programando intervalos de tiempo— En este caso podemos programar el Sync Adapter para que se ejecute cada cierto tiempo continuamente o si lo deseas, en una hora determinada del día.
  • Manualmente (por demanda)— El sincronizador se inicia por petición del usuario en la interfaz. Por ejemplo, pulsando un action button.
    Action Button De Sincronización Manual En Palabre

Ejemplo De Aplicación Android Con Sincronización

La aplicación de ejemplo que crearemos se llama «Crunch Expenses». Su propósito es guardar los gastos personales que el usuario tiene a lo largo de su jornada.

Aplicación Android Para Administrar Gastos

La información está soportada en un servicio web implementado con Mysql y Php en el servidor local. La idea es cargar la lista completa de registros en la aplicación a través de un Sync Adapter.

Adicionalmente se permitirá añadir gastos a la base de datos local que a su vez deben ser programados inmediatamente para ser sincronizados remotamente.

Actividad De Inserción De Gastos

En esencia solo usaremos sincronización por demanda para ver un efecto inmediato de las acciones que vamos a realizar.

Las tareas a realizar son:

i) Crear servicio web con Php y Mysql

1. Diseñar e implementar base de datos

2. Implementar conexión a la base de datos

3. Implementar métodos de operación de datos

ii) Desarrollar Aplicación Android

4. Creación De Content Provider

5. Preparar capa de conexión con Volley

6. Añadir autenticación de usuarios

7. Añadir SyncAdapter

8. Implementar adaptador del RecyclerView

9. Creación de la actividad principal

10. Creación de la actividad de inserción

Veamos como empezar.

Crear Servicio Web Con Php Y Mysql

Diseñar E Implementar La Base De Datos

Paso 1. El diseño de la base de datos no nos costará trabajo debido a que solo tenemos una entidad.

Básicamente podemos describir un gasto por el monto de dinero usado, la etiqueta que el usuario pueda darle, como gastos de diversión, por comida, salud, etc.

Importante la fecha en que se realizó y alguna descripción que indique más aspectos sobre el gasto.

Recuerda instalar la herramienta integrada que más te guste para gestionar Apache, Mysql y Php. En mi caso usaré XAMPP para este propósito.

Diagrama Entidad Relación De Gastos

Paso 2. Inicia los servicios de XAMPP y abre phpMyAdmin. Luego crea una nueva base de datos llamada crunch_expenses, bien sea por el panel o usando el siguiente comando Sql:

CREATE TABLE gasto(
 idGasto INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 monto INT UNSIGNED NOT NULL,
 etiqueta VARCHAR(25) NOT NULL,
 fecha DATE NOT NULL,
 descripcion VARCHAR(125)
 )

Ahora añade 5 registros de prueba con el siguiente comando.

INSERT INTO gasto(idGasto, monto, etiqueta, fecha, descripcion)
VALUES 
(NULL, 12, 'Transporte', '2015/07/01', 'Viaje al centro'),
(NULL, 200, 'Comida', '2015/06/15', 'Mercado mensual'),
(NULL, 100, 'Diversion', '2015/07/02', 'Salida a cine'),
(NULL, 25, 'Transporte', '2015/07/04', 'Compra de gasolina'),
(NULL, 5, 'Comida', '2015/07/06', 'Almuerzo de negocios');

Implementar Conexión A La Base De Datos

Paso 3. Abre tu editor favorito de Php, crea un proyecto nuevo y añade dos directorios. Uno contendrá los archivos referentes a la conexión a la base de datos (data) y el otro los archivos de operación de datos (web).

Si deseas incrementar tus conocimientos en Php y conexiones Mysql, te recomiendo adquirir el curso Programador Web: PHP y Mysqli Profesional. Te será de gran ayuda.

Paso 4. Dentro de data añade un nuevo archivo llamado mysql_login.php. Su objetivo es contener las constantes para definir:

  • Nombre del host
  • Puerto de conexión
  • Nombre de la base de datos a la cual accederemos
  • Nombre de usuario
  • Contraseña.

mysql_login.php

<?php
/**
 * Provee las constantes para conectarse a la base de datos
 * Mysql.
 */
define("HOSTNAME", "localhost");// Nombre del host
define("PORT", "63342");// Número del puerto [ Opcional ]
define("DATABASE", "crunch_expenses"); // Nombre de la base de datos
define("USERNAME", "root"); // Nombre del usuario
define("PASSWORD", ""); // Constraseña

Usaremos localhost ya que estamos usando XAMPP para testear el servicio. Si no usas puerto, entonces deja en blanco este espacio.

El nombre de la base de datos ya sabemos que es "crunch_expenses". En el caso del nombre de usuario y su contraseña, especifica aquellos datos que configuraste. Normalmente el usuario "root" sin contraseña se encuentra por defecto.

Paso 5. Ahora añade un nuevo archivo con el nombre de DatabaseConnection.php en la carpeta data. Incluiremos la clase que encierre una instancia de PDO con un patrón singleton. De esta manera podremos usar la conexión en cualquier lugar:

DatabaseConnection.php

<?php
/**
 * Clase que envuelve una instancia de la clase PDO
 * para el manejo de la base de datos
 */

require_once 'mysql_login.php';


class DatabaseConnection
{

    /**
     * Única instancia de la clase
     */
    private static $db = null;

    /**
     * Instancia de PDO
     */
    private static $pdo;

    final private function __construct()
    {
        try {
            // Crear nueva conexión PDO
            self::getDb();
        } catch (PDOException $e) {
            // Manejo de excepciones
        }


    }

    /**
     * Retorna en la única instancia de la clase
     * @return DatabaseConnection|null
     */
    public static function getInstance()
    {
        if (self::$db === null) {
            self::$db = new self();
        }
        return self::$db;
    }

    /**
     * Crear una nueva conexión PDO basada
     * en los datos de conexión
     * @return PDO Objeto PDO
     */
    public function getDb()
    {
        if (self::$pdo == null) {
            self::$pdo = new PDO(
                'mysql:dbname=' . DATABASE .
                ';host=' . HOSTNAME .
                ';port:'.PORT.';',
                USERNAME,
                PASSWORD,
                array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8")
            );

            // Habilitar excepciones
            self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        }

        return self::$pdo;
    }

    /**
     * Evita la clonación del objeto
     */
    final protected function __clone()
    {
    }

    function _destructor()
    {
        self::$pdo = null;
    }
}

?>

Paso 6. Lo siguiente es crear una clase que facilite la operación de los registros en la base de datos. Crea un archivo llamado Gastos.php y agrega el siguiente código:

Gastos.php

<?php

/**
 * Representa el data de los gastos
 * almacenados en la base de datos
 */
require 'DatabaseConnection.php';

class Gastos
{
    // Nombre de la tabla asociada a esta clase
    const TABLE_NAME = "gasto";

    const MONTO = "monto";

    const ETIQUETA = "etiqueta";

    const FECHA = "fecha";

    const DESCRIPCION = "descripcion";

    function __construct()
    {
    }

    /**
     * Obtiene todos los gastos de la base de datos
     * @return array|bool Arreglo con todos los gastos o false en caso de error
     */
    public static function getAll()
    {
        $consulta = "SELECT * FROM " . self::TABLE_NAME;
        try {
            // Preparar sentencia
            $comando = DatabaseConnection::getInstance()->getDb()->prepare($consulta);
            // Ejecutar sentencia preparada
            $comando->execute();

            return $comando->fetchAll(PDO::FETCH_ASSOC);

        } catch (PDOException $e) {
            return false;
        }
    }

    public static function insertRow($object)
    {
        try {

            $pdo = DatabaseConnection::getInstance()->getDb();

            // Sentencia INSERT
            $comando = "INSERT INTO " . self::TABLE_NAME . " ( " .
                self::MONTO . "," .
                self::ETIQUETA . "," .
                self::FECHA . "," .
                self::DESCRIPCION . ")" .
                " VALUES(?,?,?,?)";

            // Preparar la sentencia
            $sentencia = $pdo->prepare($comando);

            $sentencia->bindParam(1, $monto);
            $sentencia->bindParam(2, $etiqueta);
            $sentencia->bindParam(3, $fecha);
            $sentencia->bindParam(4, $descripcion);

            $monto = $object[self::MONTO];
            $etiqueta = $object[self::ETIQUETA];
            $fecha = $object[self::FECHA];
            $descripcion = $object[self::DESCRIPCION];

            $sentencia->execute();

            // Retornar en el último id insertado
            return $pdo->lastInsertId();
        } catch (PDOException $e) {
            return false;
        }

    }
}

?>

Esta clase implementa la conexión a la base de datos para producir los métodos getAll() e insertRow().

getAll() se encarga de retornar todos los gastos que hay en la base de datos a través de un comando SELECT.

insertRow() añade un nuevo registro a la base de datos basado en un array como parámetro de entrada. Superimportante retornar en el id remoto del registro recién insertado a través del método lastInsertId(), ya que en la base de datos de la aplicación Android se requerirá una copia de este valor.

Implementar Métodos De Operación De Datos

Paso 7. Dentro del directorio web añade un archivo con el nombre de obtener_gastos.php. Esta implementación buscará leer solo las peticiones GET desde el cliente para retornar en todos los registros de la base de datos Mysql.

obtener_gastos.php

<?php
/**
 * Obtiene todos los gastos de la base de datos
 */

/**
 * Constantes para construcción de respuesta
 */
const ESTADO = "estado";
const DATOS = "gastos";
const MENSAJE = "mensaje";

const CODIGO_EXITO = 1;
const CODIGO_FALLO = 2;

require '../data/Gastos.php';


if ($_SERVER['REQUEST_METHOD'] == 'GET') {

    // Obtener gastos de la base de datos
    $gastos = Gastos::getAll();

    // Definir tipo de la respuesta
    header('Content-Type: application/json');

    if ($gastos) {
        $datos[ESTADO] = CODIGO_EXITO;
        $datos[DATOS] = $gastos;
        print json_encode($datos);
    } else {
        print json_encode(array(
            ESTADO => CODIGO_FALLO,
            MENSAJE => "Ha ocurrido un error"
        ));
    }
}

Recuerda que usamos el método json_encode() para formatear el array de gastos en un objeto Json equivalente. Si obtuviéramos los datos actuales de la base de datos, la respuesta sería esta:

{
    "estado":1,
   "gastos":[
      {
          "idGasto":"30",
         "monto":"12",
         "etiqueta":"Transporte",
         "fecha":"2015-07-01",
         "descripcion":"Viaje al centro"
      },
      {
          "idGasto":"31",
         "monto":"200",
         "etiqueta":"Comida",
         "fecha":"2015-06-15",
         "descripcion":"Mercado mensual"
      },
      {
          "idGasto":"32",
         "monto":"100",
         "etiqueta":"Diversion",
         "fecha":"2015-07-02",
         "descripcion":"Salida a cine"
      },
      {
          "idGasto":"33",
         "monto":"25",
         "etiqueta":"Transporte",
         "fecha":"2015-07-04",
         "descripcion":"Compra de gasolina"
      },
      {
          "idGasto":"34",
         "monto":"5",
         "etiqueta":"Comida",
         "fecha":"2015-07-06",
         "descripcion":"Almuerzo de negocios"
      },
      {
          "idGasto":"35",
         "monto":"100",
         "etiqueta":"Diversiu00f3n",
         "fecha":"2015-07-23",
         "descripcion":"Salida a cine"
      },
      {
          "idGasto":"37",
         "monto":"288",
         "etiqueta":"Comida",
         "fecha":"2015-07-15",
         "descripcion":"Mercado mensual "
      },
      {
          "idGasto":"38",
         "monto":"2000",
         "etiqueta":"Diversiu00f3n",
         "fecha":"2015-07-20",
         "descripcion":"Salida a san andres"
      }
   ]
}

De lo contrario sería:

{
    "estado":1,
   "mensaje":"Ha ocurrido un error"
}

Paso 8. Ahora añade el archivo que manejará el método POST para la inserción de nuestros gastos. Crea un nuevo archivo llamado insertar_gasto.php e implementa la siguiente lógica:

insertar_gasto.php

<?php
/**
 * Insertar un nuevo gasto en la base de datos
 */

// Constantes para construir la respuesta
const ESTADO = 'estado';
const MENSAJE = 'mensaje';
const ID_GASTO = "idGasto";

const CODIGO_EXITO = '1';
const CODIGO_FALLO = '2';

require '../data/Gastos.php';

if ($_SERVER['REQUEST_METHOD'] == 'POST') {

    // Decodificando formato Json
    $body = json_decode(file_get_contents("php://input"), true);

    // Insertar gasto
    $idGasto = Gastos::insertRow($body);

    if ($idGasto) {
        // Código de éxito
        print json_encode(
            array(
                ESTADO => CODIGO_EXITO,
                MENSAJE => 'Creación éxitosa',
                ID_GASTO => $idGasto)

        );
    } else {
        // Código de falla
        print json_encode(
            array(
                ESTADO => CODIGO_FALLO,
                MENSAJE => 'Creación fallida')
        );
    }
}

Este método espera recibir un objeto Json en el cuerpo de la petición con los datos de un gasto. Esa es la razón del porque usamos json_decode() para extraer la información.

Si la inserción es válida, entonces incluiremos en la respuesta el identificador remoto del registro, por lo que añadimos un tercer atributo para ello. Una respuesta de éxito típica sería:

{
    "estado":1,
   "mensaje":"Creación éxitosa",
   "idGasto":"2"
}

O de lo contrario tendríamos:

{
    "estado":2,
   "mensaje":"Creación fallida"
}

Preparar Proyecto En Android Studio

Paso 1. Abre Android Studio y ve a File > New > New Project… para crear un nuevo proyecto. Asignale el nombre acordado y luego elige una actividad en blanco (Blank Activity).

Paso 2. Debido a que usaremos varios componentes definidos por comportamiento, crearemos los siguientes paquetes Java para tener una división de las funcionalidades más destacada:

Estructura De Paquetes En Android Studio

  • provider: Como su nombre lo indica, tendrá la estructura del Content Provider para la encapsulación de la base de datos Sqlite.
  • sync: Contiene las piezas para la autenticación y la sincronización.
  • ui: contiene los elementos relacionados con la interfaz de usuario como lo son las actividades.
  • utils: Todas aquellas clases que aíslen constantes y métodos comunes.
  • web: Aquí incluiremos las clases de conexión a la red como lo es Volley.

Paso 3. Lo siguiente es añadir las siguientes dependencias funcionales al archivo build.gradle:

build.gradle

dependencies {
    ...
    compile 'com.android.support:appcompat-v7:22.2.0'
    compile 'com.android.support:design:22.2.0'
    compile 'com.android.support:recyclerview-v7:22.2.0'
    compile 'com.mcxiaoke.volley:library:1.0.16'
    compile 'com.google.code.gson:gson:2.3.1'
}

Paso 4. Ahora preparemos los strings que vamos a usar en los layouts y en la app en general.

<resources>
    <string name="app_name">Crunch Expenses</string>

    <string name="action_sync">Sincronizar</string>
    <string name="action_settings">Settings</string>

    <string name="account_type">com.herprogramacion.crunch_expenses.account</string>
    <string name="provider_authority">com.herprogramacion.crunch_expenses</string>

    <string name="empty_list">No hay datos</string>

    <string name="title_activity_detail">Nuevo gasto</string>
    <string name="monto_hint">Monto</string>
    <string name="descripcion_hint">Descripción</string>
    <string name="etiqueta_title">Etiqueta</string>
    <string name="fecha_title">Fecha</string>
    <string-array name="etiquetas">
        <item>Comida</item>
        <item>Transporte</item>
        <item>Diversión</item>
    </string-array>
    <string name="boton">AGREGAR GASTO</string>
</resources>

Paso 5. A continuación crea y abre el archivo /res/values/colors.xml para establecer los colores de tu esquema en material design:

colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="window_background">#FFF5F5F5</color>

    <color name="primaryColor">#353D68</color>
    <color name="primaryDarkColor">#28284E</color>
    <color name="accentColor">#03A9F4</color>
</resources>

Paso 6. El siguiente paso para preparar el proyecto es añadir las dimensiones y medidas que habrá en los layouts. Dirígete al archivo /res/values/dimens.xml y añade los siguientes ítems:

dimens.xml

<resources>
    <!-- Default screen margins, per the Android Design guidelines. -->
    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>

    <dimen name="size_fab">56dp</dimen>
    <dimen name="fab_margin">16dp</dimen>

    <dimen name="universal_margin">16dp</dimen>
</resources>

Paso 7. Ahora es turno de crear la carpeta transition-v21 para añadir un archivo llamado explode.xml. Por simplicidad solo incorporaremos un elemento <explode> para usar en las transiciones entre actividades.

explode.xml

<?xml version="1.0" encoding="utf-8"?>
<explode />

Paso 8. Finalizando esta parte, modificaremos los estilos de la aplicación. Para el archivo genérico /res/values/styles.xml añade la siguiente definición:

styles.xml

<resources>

    <style name="AppTheme" parent="Crunch.Base" />

    <style name="Crunch.Base" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary">@color/primaryColor</item>
        <item name="colorPrimaryDark">@color/primaryDarkColor</item>
        <item name="colorAccent">@color/accentColor</item>

        <item name="colorButtonNormal">@color/accentColor</item>
    </style>

</resources>

Crea una nueva variación de los estilos en la versión 21 y agrega el soporte de las transiciones:

values-21/styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="AppTheme" parent="Crunch.Base">
        <item name="android:windowContentTransitions">true</item>

        <item name="android:windowEnterTransition">@transition/explode</item>
        <item name="android:windowExitTransition">@transition/explode</item>
        <item name="android:windowReenterTransition">@transition/explode</item>
        <item name="android:windowReturnTransition">@transition/explode</item>
    </style>
</resources>

Creación De Content Provider

Paso 9. Dentro del paquete provider crea una nueva clase para el contrato del provider llamada ContractParaGastos.java. Recuerda que en esta definición debemos incluir:

  • Autoridad del provider
  • Nombre de las tablas del esquema
  • Tipos MIME
  • URIs de contenido
  • Columnas de las tablas
import android.content.UriMatcher;
import android.net.Uri;
import android.provider.BaseColumns;

/**
 * Contract Class entre el provider y las aplicaciones
 */
public class ContractParaGastos {
    /**
     * Autoridad del Content Provider
     */
    public final static String AUTHORITY
            = "com.herprogramacion.crunch_expenses";
    /**
     * Representación de la tabla a consultar
     */
    public static final String GASTO = "gasto";
    /**
     * Tipo MIME que retorna la consulta de una sola fila
     */
    public final static String SINGLE_MIME =
            "vnd.android.cursor.item/vnd." + AUTHORITY + GASTO;
    /**
     * Tipo MIME que retorna la consulta de {@link CONTENT_URI}
     */
    public final static String MULTIPLE_MIME =
            "vnd.android.cursor.dir/vnd." + AUTHORITY + GASTO;
    /**
     * URI de contenido principal
     */
    public final static Uri CONTENT_URI =
            Uri.parse("content://" + AUTHORITY + "/" + GASTO);
    /**
     * Comparador de URIs de contenido
     */
    public static final UriMatcher uriMatcher;
    /**
     * Código para URIs de multiples registros
     */
    public static final int ALLROWS = 1;
    /**
     * Código para URIS de un solo registro
     */
    public static final int SINGLE_ROW = 2;


    // Asignación de URIs
    static {
        uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        uriMatcher.addURI(AUTHORITY, GASTO, ALLROWS);
        uriMatcher.addURI(AUTHORITY, GASTO + "/#", SINGLE_ROW);
    }

    // Valores para la columna ESTADO
    public static final int ESTADO_OK = 0;
    public static final int ESTADO_SYNC = 1;


    /**
     * Estructura de la tabla
     */
    public static class Columnas implements BaseColumns {

        private Columnas() {
            // Sin instancias
        }

        public final static String MONTO = "monto";
        public final static String ETIQUETA = "etiqueta";
        public final static String FECHA = "fecha";
        public final static String DESCRIPCION = "descripcion";

        public static final String ESTADO = "estado";
        public static final String ID_REMOTA = "idRemota";
        public final static String PENDIENTE_INSERCION = "pendiente_insercion";

    }
}

En las columnas de la tabla gasto cabe aclarar la funcionalidad de tres de ellas.

  • ESTADO: Marca de un registro que indica si está siendo sincronizado o está intacto. Para referirnos a estas dos condiciones usamos las banderas ESTADO_OK y ESTADO_SYNC.
  • ID_REMOTA: Es necesario tener una copia de la llave primaria que tiene el registro local en la base de datos del servidor. Esto permite mantener una actualización basada en una sola referencia.
  • PENDIENTE_INSERCION: Cuando un gasto es añadido en la base de datos local Sqlite, debe ser marcado como «pendiente de inserción». ¿Por qué? … Bueno, es necesario «ensuciar» este registro por si la conexión a internet falla o si la sincronización se detiene por causas externas. Así cuando se vuelva a intentar retomar la sincronización, el registro que quedó pendiente, podrá intentarse subir de nuevo y no perder los datos.

Paso 10. Lo siguiente es crear la clase gestora de la base de datos local. Añade un nuevo archivo llamado DatabaseHelper.java y extiende el contenido de la clase SQLiteOpenHelper.

Crea la tabla de los gastos en su método onCreate() y sobrescribe onUpgrade() para que reconstruya el esquema completo, cada vez que se actualiza la versión de la base de datos.

DatabaseHelper.java

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;

/**
 * Clase envoltura para el gestor de Bases de datos
 */
class DatabaseHelper extends SQLiteOpenHelper {


    public DatabaseHelper(Context context,
                          String name,
                          SQLiteDatabase.CursorFactory factory,
                          int version) {
        super(context, name, factory, version);
    }

    public void onCreate(SQLiteDatabase database) {
        createTable(database); // Crear la tabla "gasto"
    }

    /**
     * Crear tabla en la base de datos
     *
     * @param database Instancia de la base de datos
     */
    private void createTable(SQLiteDatabase database) {
        String cmd = "CREATE TABLE " + ContractParaGastos.GASTO + " (" +
                ContractParaGastos.Columnas._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
                ContractParaGastos.Columnas.MONTO + " TEXT, " +
                ContractParaGastos.Columnas.ETIQUETA + " TEXT, " +
                ContractParaGastos.Columnas.FECHA + " TEXT, " +
                ContractParaGastos.Columnas.DESCRIPCION + " TEXT," +
                ContractParaGastos.Columnas.ID_REMOTA + " TEXT UNIQUE," +
                ContractParaGastos.Columnas.ESTADO + " INTEGER NOT NULL DEFAULT "+ ContractParaGastos.ESTADO_OK+"," +
                ContractParaGastos.Columnas.PENDIENTE_INSERCION + " INTEGER NOT NULL DEFAULT 0)";
        database.execSQL(cmd);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        try { db.execSQL("drop table " + ContractParaGastos.GASTO); }
        catch (SQLiteException e) { }
        onCreate(db);
    }

}

Paso 11. Finalmente construye tu Content Provider personalizado. Para ello crea una nueva clase llamada ProviderDeGastos.java  e incluye el código de abajo.

Sobrescribe los métodos onCreate(), query(), insert(), update(), delete() y getType().

ProviderDeGastos.java

import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.text.TextUtils;

/**
 * Content Provider personalizado para los gastos
 */
public class ProviderDeGastos extends ContentProvider {
    /**
     * Nombre de la base de datos
     */
    private static final String DATABASE_NAME = "crunch_expenses.db";
    /**
     * Versión actual de la base de datos
     */
    private static final int DATABASE_VERSION = 1;
    /**
     * Instancia global del Content Resolver
     */
    private ContentResolver resolver;
    /**
     * Instancia del administrador de BD
     */
    private DatabaseHelper databaseHelper;

    @Override
    public boolean onCreate() {
        // Inicializando gestor BD
        databaseHelper = new DatabaseHelper(
                getContext(),
                DATABASE_NAME,
                null,
                DATABASE_VERSION
        );

        resolver = getContext().getContentResolver();

        return true;
    }

    @Override
    public Cursor query(
            Uri uri,
            String[] projection,
            String selection,
            String[] selectionArgs,
            String sortOrder) {

        // Obtener base de datos
        SQLiteDatabase db = databaseHelper.getWritableDatabase();
        // Comparar Uri
        int match = ContractParaGastos.uriMatcher.match(uri);

        Cursor c;

        switch (match) {
            case ContractParaGastos.ALLROWS:
                // Consultando todos los registros
                c = db.query(ContractParaGastos.GASTO, projection,
                        selection, selectionArgs,
                        null, null, sortOrder);
                c.setNotificationUri(
                        resolver,
                        ContractParaGastos.CONTENT_URI);
                break;
            case ContractParaGastos.SINGLE_ROW:
                // Consultando un solo registro basado en el Id del Uri
                long idGasto = ContentUris.parseId(uri);
                c = db.query(ContractParaGastos.GASTO, projection,
                        ContractParaGastos.Columnas._ID + " = " + idGasto,
                        selectionArgs, null, null, sortOrder);
                c.setNotificationUri(
                        resolver,
                        ContractParaGastos.CONTENT_URI);
                break;
            default:
                throw new IllegalArgumentException("URI no soportada: " + uri);
        }
        return c;

    }

    @Override
    public String getType(Uri uri) {
        switch (ContractParaGastos.uriMatcher.match(uri)) {
            case ContractParaGastos.ALLROWS:
                return ContractParaGastos.MULTIPLE_MIME;
            case ContractParaGastos.SINGLE_ROW:
                return ContractParaGastos.SINGLE_MIME;
            default:
                throw new IllegalArgumentException("Tipo de gasto desconocido: " + uri);
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        // Validar la uri
        if (ContractParaGastos.uriMatcher.match(uri) != ContractParaGastos.ALLROWS) {
            throw new IllegalArgumentException("URI desconocida : " + uri);
        }
        ContentValues contentValues;
        if (values != null) {
            contentValues = new ContentValues(values);
        } else {
            contentValues = new ContentValues();
        }

        // Inserción de nueva fila
        SQLiteDatabase db = databaseHelper.getWritableDatabase();
        long rowId = db.insert(ContractParaGastos.GASTO, null, contentValues);
        if (rowId > 0) {
            Uri uri_gasto = ContentUris.withAppendedId(
                    ContractParaGastos.CONTENT_URI, rowId);
            resolver.notifyChange(uri_gasto, null, false);
            return uri_gasto;
        }
        throw new SQLException("Falla al insertar fila en : " + uri);
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        SQLiteDatabase db = databaseHelper.getWritableDatabase();

        int match = ContractParaGastos.uriMatcher.match(uri);
        int affected;

        switch (match) {
            case ContractParaGastos.ALLROWS:
                affected = db.delete(ContractParaGastos.GASTO,
                        selection,
                        selectionArgs);
                break;
            case ContractParaGastos.SINGLE_ROW:
                long idGasto = ContentUris.parseId(uri);
                affected = db.delete(ContractParaGastos.GASTO,
                        ContractParaGastos.Columnas.ID_REMOTA + "=" + idGasto
                                + (!TextUtils.isEmpty(selection) ?
                                " AND (" + selection + ')' : ""),
                        selectionArgs);
                // Notificar cambio asociado a la uri
                resolver.
                        notifyChange(uri, null, false);
                break;
            default:
                throw new IllegalArgumentException("Elemento gasto desconocido: " +
                        uri);
        }
        return affected;
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {

        SQLiteDatabase db = databaseHelper.getWritableDatabase();
        int affected;
        switch (ContractParaGastos.uriMatcher.match(uri)) {
            case ContractParaGastos.ALLROWS:
                affected = db.update(ContractParaGastos.GASTO, values,
                        selection, selectionArgs);
                break;
            case ContractParaGastos.SINGLE_ROW:
                String idGasto = uri.getPathSegments().get(1);
                affected = db.update(ContractParaGastos.GASTO, values,
                        ContractParaGastos.Columnas.ID_REMOTA + "=" + idGasto
                                + (!TextUtils.isEmpty(selection) ?
                                " AND (" + selection + ')' : ""),
                        selectionArgs);
                break;
            default:
                throw new IllegalArgumentException("URI desconocida: " + uri);
        }
        resolver.notifyChange(uri, null, false);
        return affected;
    }

}

Punto importante. El método notifyChange() recibe en su tercer parámetro una bandera que indica si el Sync Adapter será ejecutado automáticamente, al momento en que el contenido del content provider cambie.

¿De qué sirve esto?

Bueno, es útil si deseas dejar en manos el content provider la sincronización remota. Sin embargo, el Sync Adapter no es iniciado justo después del cambio.

Esta sincronización se retarda algunos segundos (normalmente unos 30 o 40 segundos) por si se producen varias actualizaciones. Esto ayuda a recolectar un conjunto de operaciones, que al final serán ejecutadas en batch para consumir menos recursos del dispositivo.

En este ejemplo no usaremos este comportamiento, por lo que usaremos false en este parámetro. Si usas la versión de notifyChange() con dos parámetros, debes saber que la sincronización automática está habilita por defecto.

Preparar Capa De Conexión Con Volley

Paso 11. Añade el singleton Volley que hemos usado a lo largo de los tutoriales de Desarrollo Android vistos que generar peticiones HTTP. Este archivo debe ir en el paquete web.

VolleySingleton.java

import android.content.Context;

import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.toolbox.Volley;

/**
 * Clase que representa un cliente HTTP Volley
 */

public final class VolleySingleton {

    // Atributos
    private static VolleySingleton singleton;
    private RequestQueue requestQueue;
    private static Context context;


    private VolleySingleton(Context context) {
        VolleySingleton.context = context;
        requestQueue = getRequestQueue();
    }

    /**
     * Retorna la instancia unica del singleton
     *
     * @param context contexto donde se ejecutarán las peticiones
     * @return Instancia
     */
    public static synchronized VolleySingleton getInstance(Context context) {
        if (singleton == null) {
            singleton = new VolleySingleton(context.getApplicationContext());
        }
        return singleton;
    }

    /**
     * Obtiene la instancia de la cola de peticiones
     *
     * @return cola de peticiones
     */
    private RequestQueue getRequestQueue() {
        if (requestQueue == null) {
            requestQueue = Volley.newRequestQueue(context.getApplicationContext());
        }
        return requestQueue;
    }

    /**
     * Añade la petición a la cola
     *
     * @param req petición
     * @param <T> Resultado final de tipo T
     */
    public <T> void addToRequestQueue(Request<T> req) {
        getRequestQueue().add(req);
    }

}

Paso 12. En el interior de web también podemos incluir el POJO del gasto para el parsing Json con Gson que haremos a la hora de recibir las respuestas HTTP.

Gasto.java

/**
 * Esta clase representa un gasto individual de cada registro de la base de datos
 */
public class Gasto {
    public String idGasto;
    public int monto;
    public String etiqueta;
    public String fecha;
    public String descripcion;


    public Gasto(String idGasto, int monto, String etiqueta, String fecha, String descripcion) {
        this.idGasto = idGasto;
        this.monto = monto;
        this.etiqueta = etiqueta;
        this.fecha = fecha;
        this.descripcion = descripcion;
    }
}

2. Añadir Autenticación De Usuarios

Paso 13. La autenticación requiere un acceso a las cuentas de usuario del sistema por parte de nuestra app. Esto solo será posible si añades al AndroidManifest.xml el permiso AUTHENTICATE_ACCOUNTS.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.herprogramacion.crunch_expenses">
    ...

    <!-- Autenticación -->
    <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />

    ...    
</manifest>

Paso 14. Lo siguiente es crear un autenticador. Como vimos al inicio, se debe crear una nueva clase Java que extienda de AbstractAccountAuthenticator.

No usaremos login en la aplicación, así que sobrescribimos todos los métodos para que no realicen ninguna acción.

Teniendo esto claro, crea una nueva clase llamada ExpenseAuthenticator.java dentro del paquete sync y añade el código de abajo.

ExpenseAuthenticator.java

import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.os.Bundle;

/*
 * Autenticador auxiliar para la aplicación
 */
public class ExpenseAuthenticator extends AbstractAccountAuthenticator {

    public ExpenseAuthenticator(Context context) {
        super(context);
    }

    @Override
    public Bundle editProperties(
            AccountAuthenticatorResponse r, String s) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle addAccount(
            AccountAuthenticatorResponse r,
            String s,
            String s2,
            String[] strings,
            Bundle bundle) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle confirmCredentials(
            AccountAuthenticatorResponse r,
            Account account,
            Bundle bundle) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle getAuthToken(
            AccountAuthenticatorResponse r,
            Account account,
            String s,
            Bundle bundle) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public String getAuthTokenLabel(String s) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle updateCredentials(
            AccountAuthenticatorResponse r,
            Account account,
            String s, Bundle bundle) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle hasFeatures(
            AccountAuthenticatorResponse r,
            Account account, String[] strings) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }
}

Los métodos anteriores permiten manejar todo el proceso de verificación de una cuenta. Puedes aprender más sobre ellos en la implementación de la clase.

Paso 15. Crea un directorio xml dentro de /res y agrega un nuevo archivo llamado authenticator.xml.

Habíamos hablado que este recurso contiene un nodo del tipo <account-authenticator>, el cual define la forma en que se verá la nueva cuenta local dentro de la sección «Accounts» de Android.

authenticator.xml

<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="@string/account_type"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:smallIcon="@mipmap/ic_launcher" />

¿Cuáles son las funciones de los atributos?

  • accountType: Representa el tipo de cuenta de la aplicación. Toda aplicación que use un sync adapter debe diferenciar sus cuentas con este identificador. Como casi siempre, para asegurar valores únicos, puedes usar un string que contenga el nombre del paquete de la aplicación. En nuestro caso es "com.herprogramacion.crunch_expenses.account".
  • icon: Es el icono distintivo que aparecerá en el elemento de la lista.
  • label: Título para el tipo de cuenta.
  • smallIcon: Es la versión pequeña del icono que tendrá la cuenta. Se usa cuando las dimensiones de la pantalla no son lo suficientemente amplias para el icono normal.

El archivo definido produce el siguiente diseño dentro de Ajustes > Cuentas:

Diseño De Cuenta Del SyncAdapter En Ajustes

Paso 16. Ahora es turno de crear el servicio que ayudará a comunicar las acciones de nuestro autenticador con el framework de gestión de cuentas. Dentro del paquete sync crea una nueva clase llamada AuthenticationService.java y añade la siguiente implementación.

AuthenticationService.java

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

/**
 * Bound service para gestionar la autenticación
 */
public class AuthenticationService extends Service {

    // Instancia del autenticador
    private ExpenseAuthenticator autenticador;

    @Override
    public void onCreate() {
        // Nueva instancia del autenticador
        autenticador = new ExpenseAuthenticator(this);
    }

    /*
     * Ligando el servicio al framework de Android
     */
    @Override
    public IBinder onBind(Intent intent) {
        return autenticador.getIBinder();
    }
}

Como ves, solo retorna en su método onBind() la interfaz de comunicación que el autenticador proporciona con getIBinder().

No tenemos que controlar las acciones del servicio, ya que el framework de administración de cuentas será el encargado de iniciarlo y manejar todo su ciclo de vida.

Paso 17. Declara el servicio de autenticación en el archivo AndroidManifest.xml.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.herprogramacion.crunch_expenses">

    ...

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme">

        ...

        <!-- SERVICIO DE AUTENTICACIÓN -->
        <service android:name=".sync.AuthenticationService">
            <intent-filter>
                <action android:name="android.accounts.AccountAuthenticator" />
            </intent-filter>

            <meta-data
                android:name="android.accounts.AccountAuthenticator"
                android:resource="@xml/authenticator" />
        </service>

        ...
    </application>

</manifest>

Para que el servicio sea iniciado y pase el autenticador al sistema, es necesario añadir un filtro con la acción "android.accounts.AccountAuthenticator".

También debes añadir una etiqueta <meta-data> para especificar el recurso que inflará la cuenta. Lo que requiere usar el descriptor del autenticador en el atributo android:name y la referencia xml del recurso a inflar en android:resource.

Añadir SyncAdapter

Antes de comenzar esta parte, explicaré la estrategia que usaré para sincronizar los datos.

Lo primero es reconocer que usaremos una sincronización local y una remota.

Local en la obtención de los datos del servidor para poblar la lista de gasto y remota para enviar los nuevos gastos hacia el servidor.

Ahora pregúntate… ¿Que debe pasar en una sincronización desde el servidor hacia el dispositivo Android?

A groso modo, nuestra aplicación debe realizar una petición al servidor para que retorne todos los gastos que existen. Luego de ello debemos parsear la información. Si el estado fue un éxito, entonces entramos a comparar los registros buscando cual se debe añadir, cual debe modificarse o incluso cual se eliminará.

Al final se aplican los cambios sobre nuestro content provider y así la lista se actualizará.

Diagrama Sincronización Local

En la sincronización remota las cosas cambian un poco.

Cuando un gasto es creado, se marca en la base de datos como «pendiente por inserción» por si la red llega a colapsar o no está disponible.

Inmediatamente el sync adapter es iniciado para procesar la sincronización. Este lee aquellos registros que estén por insertarse y les marca con un estado adicional de «en sincronización», lo que evita insertar dos veces el mismo registro.

Por cada uno de los registros «sucios» se realiza una petición POST al servidor. Si todo sale bien, la respuesta traerá el id remoto de cada registro, el cual asignarás en el campo ID_REMOTA.

Diagrama De Flujo Sincronización Remota

Aunque ambos enfoques son simples y minimizados, son una base para que puedas crear estrategias mucho más elaboradas que mantengan la congruencia en los datos.

Ahora, veamos cómo plasmar esto en código…

Paso 18. Antes que nada, nuestra aplicación debe incluir un permiso de sincronización de datos a través de una cuenta relacionada.

Debido a que el framework de sincronización necesita saber qué tipo de ajustes generales tiene el usuario se requiere usar el permiso READ_SYNC_SETTINGS.

En el caso de la creación de la habilitación de sincronización sobre la cuenta necesitaremos a WRITE_SYNC_SETTINGS.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.herprogramacion.crunch_expenses">

    ...

    <!-- Sincronización -->
    <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
    <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />

    ...

</manifest>

Paso 19. Dentro del paquete sync crea una nueva clase llamada SyncAdapter.java que herede de AbstractThreadedSyncAdapter. A continuación incluye el siguiente código:

SyncAdapter.java

import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
import android.content.SyncResult;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.RemoteException;
import android.util.Log;

import com.android.volley.Request;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonObjectRequest;
import com.google.gson.Gson;
import com.herprogramacion.crunch_expenses.utils.Utilidades;
import com.herprogramacion.crunch_expenses.utils.Constantes;
import com.herprogramacion.crunch_expenses.R;
import com.herprogramacion.crunch_expenses.provider.ContractParaGastos;
import com.herprogramacion.crunch_expenses.web.Gasto;
import com.herprogramacion.crunch_expenses.web.VolleySingleton;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Maneja la transferencia de datos entre el servidor y el cliente
 */
public class SyncAdapter extends AbstractThreadedSyncAdapter {
    private static final String TAG = SyncAdapter.class.getSimpleName();

    ContentResolver resolver;
    private Gson gson = new Gson();

    /**
     * Proyección para las consultas
     */
    private static final String[] PROJECTION = new String[]{
            ContractParaGastos.Columnas._ID,
            ContractParaGastos.Columnas.ID_REMOTA,
            ContractParaGastos.Columnas.MONTO,
            ContractParaGastos.Columnas.ETIQUETA,
            ContractParaGastos.Columnas.FECHA,
            ContractParaGastos.Columnas.DESCRIPCION
    };

    // Indices para las columnas indicadas en la proyección
    public static final int COLUMNA_ID = 0;
    public static final int COLUMNA_ID_REMOTA = 1;
    public static final int COLUMNA_MONTO = 2;
    public static final int COLUMNA_ETIQUETA = 3;
    public static final int COLUMNA_FECHA = 4;
    public static final int COLUMNA_DESCRIPCION = 5;

    public SyncAdapter(Context context, boolean autoInitialize) {
        super(context, autoInitialize);
        resolver = context.getContentResolver();
    }

    /**
     * Constructor para mantener compatibilidad en versiones inferiores a 3.0
     */
    public SyncAdapter(
            Context context,
            boolean autoInitialize,
            boolean allowParallelSyncs) {
        super(context, autoInitialize, allowParallelSyncs);
        resolver = context.getContentResolver();
    }

    public static void inicializarSyncAdapter(Context context) {
        obtenerCuentaASincronizar(context);
    }

    @Override
    public void onPerformSync(Account account,
                              Bundle extras,
                              String authority,
                              ContentProviderClient provider,
                              final SyncResult syncResult) {

        Log.i(TAG, "onPerformSync()...");

        boolean soloSubida = extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false);

        if (!soloSubida) {
            realizarSincronizacionLocal(syncResult);
        } else {
            realizarSincronizacionRemota();
        }
    }

    private void realizarSincronizacionLocal(final SyncResult syncResult) {
        Log.i(TAG, "Actualizando el cliente.");

        VolleySingleton.getInstance(getContext()).addToRequestQueue(
                new JsonObjectRequest(
                        Request.Method.GET,
                        Constantes.GET_URL,
                        new Response.Listener<JSONObject>() {
                            @Override
                            public void onResponse(JSONObject response) {
                                procesarRespuestaGet(response, syncResult);
                            }
                        },
                        new Response.ErrorListener() {
                            @Override
                            public void onErrorResponse(VolleyError error) {
                                Log.d(TAG, error.networkResponse.toString());
                            }
                        }
                )
        );
    }

    /**
     * Procesa la respuesta del servidor al pedir que se retornen todos los gastos.
     *
     * @param response   Respuesta en formato Json
     * @param syncResult Registro de resultados de sincronización
     */
    private void procesarRespuestaGet(JSONObject response, SyncResult syncResult) {
        try {
            // Obtener atributo "estado"
            String estado = response.getString(Constantes.ESTADO);

            switch (estado) {
                case Constantes.SUCCESS: // EXITO
                    actualizarDatosLocales(response, syncResult);
                    break;
                case Constantes.FAILED: // FALLIDO
                    String mensaje = response.getString(Constantes.MENSAJE);
                    Log.i(TAG, mensaje);
                    break;
            }

        } catch (JSONException e) {
            e.printStackTrace();
        }
    }

    private void realizarSincronizacionRemota() {
        Log.i(TAG, "Actualizando el servidor...");

        iniciarActualizacion();

        Cursor c = obtenerRegistrosSucios();

        Log.i(TAG, "Se encontraron " + c.getCount() + " registros sucios.");

        if (c.getCount() > 0) {
            while (c.moveToNext()) {
                final int idLocal = c.getInt(COLUMNA_ID);

                VolleySingleton.getInstance(getContext()).addToRequestQueue(
                        new JsonObjectRequest(
                                Request.Method.POST,
                                Constantes.INSERT_URL,
                                Utilidades.deCursorAJSONObject(c),
                                new Response.Listener<JSONObject>() {
                                    @Override
                                    public void onResponse(JSONObject response) {
                                        procesarRespuestaInsert(response, idLocal);
                                    }
                                },
                                new Response.ErrorListener() {
                                    @Override
                                    public void onErrorResponse(VolleyError error) {
                                        Log.d(TAG, "Error Volley: " + error.getMessage());
                                    }
                                }

                        ) {
                            @Override
                            public Map<String, String> getHeaders() {
                                Map<String, String> headers = new HashMap<String, String>();
                                headers.put("Content-Type", "application/json; charset=utf-8");
                                headers.put("Accept", "application/json");
                                return headers;
                            }

                            @Override
                            public String getBodyContentType() {
                                return "application/json; charset=utf-8" + getParamsEncoding();
                            }
                        }
                );
            }

        } else {
            Log.i(TAG, "No se requiere sincronización");
        }
        c.close();
    }

    /**
     * Obtiene el registro que se acaba de marcar como "pendiente por sincronizar" y
     * con "estado de sincronización"
     *
     * @return Cursor con el registro.
     */
    private Cursor obtenerRegistrosSucios() {
        Uri uri = ContractParaGastos.CONTENT_URI;
        String selection = ContractParaGastos.Columnas.PENDIENTE_INSERCION + "=? AND "
                + ContractParaGastos.Columnas.ESTADO + "=?";
        String[] selectionArgs = new String[]{"1", ContractParaGastos.ESTADO_SYNC + ""};

        return resolver.query(uri, PROJECTION, selection, selectionArgs, null);
    }

    /**
     * Cambia a estado "de sincronización" el registro que se acaba de insertar localmente
     */
    private void iniciarActualizacion() {
        Uri uri = ContractParaGastos.CONTENT_URI;
        String selection = ContractParaGastos.Columnas.PENDIENTE_INSERCION + "=? AND "
                + ContractParaGastos.Columnas.ESTADO + "=?";
        String[] selectionArgs = new String[]{"1", ContractParaGastos.ESTADO_OK + ""};

        ContentValues v = new ContentValues();
        v.put(ContractParaGastos.Columnas.ESTADO, ContractParaGastos.ESTADO_SYNC);

        int results = resolver.update(uri, v, selection, selectionArgs);
        Log.i(TAG, "Registros puestos en cola de inserción:" + results);
    }

    /**
     * Limpia el registro que se sincronizó y le asigna la nueva id remota proveida
     * por el servidor
     *
     * @param idRemota id remota
     */
    private void finalizarActualizacion(String idRemota, int idLocal) {
        Uri uri = ContractParaGastos.CONTENT_URI;
        String selection = ContractParaGastos.Columnas._ID + "=?";
        String[] selectionArgs = new String[]{String.valueOf(idLocal)};

        ContentValues v = new ContentValues();
        v.put(ContractParaGastos.Columnas.PENDIENTE_INSERCION, "0");
        v.put(ContractParaGastos.Columnas.ESTADO, ContractParaGastos.ESTADO_OK);
        v.put(ContractParaGastos.Columnas.ID_REMOTA, idRemota);

        resolver.update(uri, v, selection, selectionArgs);
    }

    /**
     * Procesa los diferentes tipos de respuesta obtenidos del servidor
     *
     * @param response Respuesta en formato Json
     */
    public void procesarRespuestaInsert(JSONObject response, int idLocal) {

        try {
            // Obtener estado
            String estado = response.getString(Constantes.ESTADO);
            // Obtener mensaje
            String mensaje = response.getString(Constantes.MENSAJE);
            // Obtener identificador del nuevo registro creado en el servidor
            String idRemota = response.getString(Constantes.ID_GASTO);

            switch (estado) {
                case Constantes.SUCCESS:
                    Log.i(TAG, mensaje);
                    finalizarActualizacion(idRemota, idLocal);
                    break;

                case Constantes.FAILED:
                    Log.i(TAG, mensaje);
                    break;
            }
        } catch (JSONException e) {
            e.printStackTrace();
        }

    }

    /**
     * Actualiza los registros locales a través de una comparación con los datos
     * del servidor
     *
     * @param response   Respuesta en formato Json obtenida del servidor
     * @param syncResult Registros de la sincronización
     */
    private void actualizarDatosLocales(JSONObject response, SyncResult syncResult) {

        JSONArray gastos = null;

        try {
            // Obtener array "gastos"
            gastos = response.getJSONArray(Constantes.GASTOS);
        } catch (JSONException e) {
            e.printStackTrace();
        }
        // Parsear con Gson
        Gasto[] res = gson.fromJson(gastos != null ? gastos.toString() : null, Gasto[].class);
        List<Gasto> data = Arrays.asList(res);

        // Lista para recolección de operaciones pendientes
        ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();

        // Tabla hash para recibir las entradas entrantes
        HashMap<String, Gasto> expenseMap = new HashMap<String, Gasto>();
        for (Gasto e : data) {
            expenseMap.put(e.idGasto, e);
        }

        // Consultar registros remotos actuales
        Uri uri = ContractParaGastos.CONTENT_URI;
        String select = ContractParaGastos.Columnas.ID_REMOTA + " IS NOT NULL";
        Cursor c = resolver.query(uri, PROJECTION, select, null, null);
        assert c != null;

        Log.i(TAG, "Se encontraron " + c.getCount() + " registros locales.");

        // Encontrar datos obsoletos
        String id;
        int monto;
        String etiqueta;
        String fecha;
        String descripcion;
        while (c.moveToNext()) {
            syncResult.stats.numEntries++;

            id = c.getString(COLUMNA_ID_REMOTA);
            monto = c.getInt(COLUMNA_MONTO);
            etiqueta = c.getString(COLUMNA_ETIQUETA);
            fecha = c.getString(COLUMNA_FECHA);
            descripcion = c.getString(COLUMNA_DESCRIPCION);

            Gasto match = expenseMap.get(id);

            if (match != null) {
                // Esta entrada existe, por lo que se remueve del mapeado
                expenseMap.remove(id);

                Uri existingUri = ContractParaGastos.CONTENT_URI.buildUpon()
                        .appendPath(id).build();

                // Comprobar si el gasto necesita ser actualizado
                boolean b = match.monto != monto;
                boolean b1 = match.etiqueta != null && !match.etiqueta.equals(etiqueta);
                boolean b2 = match.fecha != null && !match.fecha.equals(fecha);
                boolean b3 = match.descripcion != null && !match.descripcion.equals(descripcion);

                if (b || b1 || b2 || b3) {

                    Log.i(TAG, "Programando actualización de: " + existingUri);

                    ops.add(ContentProviderOperation.newUpdate(existingUri)
                            .withValue(ContractParaGastos.Columnas.MONTO, match.monto)
                            .withValue(ContractParaGastos.Columnas.ETIQUETA, match.etiqueta)
                            .withValue(ContractParaGastos.Columnas.FECHA, match.fecha)
                            .withValue(ContractParaGastos.Columnas.DESCRIPCION, match.descripcion)
                            .build());
                    syncResult.stats.numUpdates++;
                } else {
                    Log.i(TAG, "No hay acciones para este registro: " + existingUri);
                }
            } else {
                // Debido a que la entrada no existe, es removida de la base de datos
                Uri deleteUri = ContractParaGastos.CONTENT_URI.buildUpon()
                        .appendPath(id).build();
                Log.i(TAG, "Programando eliminación de: " + deleteUri);
                ops.add(ContentProviderOperation.newDelete(deleteUri).build());
                syncResult.stats.numDeletes++;
            }
        }
        c.close();

        // Insertar items resultantes
        for (Gasto e : expenseMap.values()) {
            Log.i(TAG, "Programando inserción de: " + e.idGasto);
            ops.add(ContentProviderOperation.newInsert(ContractParaGastos.CONTENT_URI)
                    .withValue(ContractParaGastos.Columnas.ID_REMOTA, e.idGasto)
                    .withValue(ContractParaGastos.Columnas.MONTO, e.monto)
                    .withValue(ContractParaGastos.Columnas.ETIQUETA, e.etiqueta)
                    .withValue(ContractParaGastos.Columnas.FECHA, e.fecha)
                    .withValue(ContractParaGastos.Columnas.DESCRIPCION, e.descripcion)
                    .build());
            syncResult.stats.numInserts++;
        }

        if (syncResult.stats.numInserts > 0 ||
                syncResult.stats.numUpdates > 0 ||
                syncResult.stats.numDeletes > 0) {
            Log.i(TAG, "Aplicando operaciones...");
            try {
                resolver.applyBatch(ContractParaGastos.AUTHORITY, ops);
            } catch (RemoteException | OperationApplicationException e) {
                e.printStackTrace();
            }
            resolver.notifyChange(
                    ContractParaGastos.CONTENT_URI,
                    null,
                    false);
            Log.i(TAG, "Sincronización finalizada.");

        } else {
            Log.i(TAG, "No se requiere sincronización");
        }

    }

    /**
     * Inicia manualmente la sincronización
     *
     * @param context    Contexto para crear la petición de sincronización
     * @param onlyUpload Usa true para sincronizar el servidor o false para sincronizar el cliente
     */
    public static void sincronizarAhora(Context context, boolean onlyUpload) {
        Log.i(TAG, "Realizando petición de sincronización manual.");
        Bundle bundle = new Bundle();
        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
        bundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
        if (onlyUpload)
            bundle.putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true);
        ContentResolver.requestSync(obtenerCuentaASincronizar(context),
                context.getString(R.string.provider_authority), bundle);
    }

    /**
     * Crea u obtiene una cuenta existente
     *
     * @param context Contexto para acceder al administrador de cuentas
     * @return cuenta auxiliar.
     */
    public static Account obtenerCuentaASincronizar(Context context) {
        // Obtener instancia del administrador de cuentas
        AccountManager accountManager =
                (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE);

        // Crear cuenta por defecto
        Account newAccount = new Account(
                context.getString(R.string.app_name), Constantes.ACCOUNT_TYPE);

        // Comprobar existencia de la cuenta
        if (null == accountManager.getPassword(newAccount)) {

            // Añadir la cuenta al account manager sin password y sin datos de usuario
            if (!accountManager.addAccountExplicitly(newAccount, "", null))
                return null;

        }
        Log.i(TAG, "Cuenta de usuario obtenida.");
        return newAccount;
    }

}

La clase SyncAdapter es la más extensa debido a que es donde implementamos toda la lógica de actualización.

¿Te imaginas ubicar estás acciones en la actividad principal?

¡Sería desastroso!

Ahora pon toda tu atención en el propósito de cada pieza de código. Cada uno de estos métodos lleva a cabo una de las tareas vistas en los algoritmos:

  • inicializarSyncAdapter(): Inicia las condiciones necesarios para que el sync adapter funcione. En este caso se crea por primera vez la cuenta con el método obtenerCuentaASincronizar().
  • obtenerCuentaASincronizar(): Crea una cuenta directamente sobre el AccountManager. Las cuentas son representadas por la clase Account, donde su constructor recibe el nombre de la cuenta y el tipo de cuenta. Puedes añadirlas al sistema con el método AccountManager.addAccountExplicitly(). Este recibe como parámetros la cuenta, el password y datos del usuario. Sin embargo, como nuestra cuenta es auxiliar, no especificamos nada.
  • sincronizarAhora(): Inicia manualmente el sync adapter con el método ContentResolver.requestSync(). Este recibe la cuenta de usuario asociada a la sincronización, la autoridad del content provider y valores extras que serán retomados en onPerformSync(). Para habilitar la sincronización manual usa los extras SYNC_EXTRAS_EXPEDITEDSYNC_EXTRAS_MANUAL con el valor de true. SYNC_EXTRAS_UPLOAD indica si deseamos subir datos al servidor, lo que nos servirá para la sincronización remota (al usar notifiyChange() con el valor de true, esta bandera puesta en true automáticamente.
  • onPerformSync(): Como habíamos dicho, este método ejecuta la sincronización cuando se inicia el Sync Adapter. Basado en el valor de SYNC_EXTRAS_UPLOAD iniciará una sincronización local (doLocalSync()) o una remota (doRemoteSync()).
  • realizarSincronizacionLocal(): Realiza una petición GET con Volley al servicio web, para obtener los registros del servidor. La respuesta es procesada con el método procesarRespuestaGet().
  • procesarRespuestaGet(): Determina si el estado fue exitoso o fallido. Si es exitoso actualiza los datos locales con el método actualizarDatosLocales().
  • actualizarDatosLocales(): Este método es el encargado de modificar el contenido del Content Provider. Primero parsea el arreglo Json proveniente de la web en una lista de objetos Gasto. Luego mapea cada elemento junto a su id. En seguida consulta todos los registros locales de la base de datos y comienza a comparar entre sí ambos conjuntos. Las operaciones no se realizan inmediatamente, estas se van guardando en una lista de ContentProviderOperation, una clase que representa operaciones sobre el content provider. Cuando todo se termina, se aplican los cambios con applyBatch() y luego se notifican los cambios con notifyChange().
  • realizarSincronizacionRemota(): Se encarga de subir los registros nuevos al servidor. En primera instancia cambio a estado de sincronización aquellos registros pendientes de inserción. Luego obtiene todos los registros marcados y realiza una petición POST por cada uno para enviar su contenido en forma de objeto Json.
  • iniciarActualizacion(): Cambia el valor de la columna "estado" a ESTADO_SYNC de todos aquellos registros que tengan un valor de ESTADO_OK y que estén marcados en su columna "pendiente_insercion" por el valor "1".
  • obtenerRegistrosSucios(): Retorna un cursor con todos los registros que en su columna "estado" tengan el valor de ESTADO_SYNC y en "pendiente_insercion" el valor de "1".
  • procesarRespuestaInsert(): Se encarga de procesar el estado de la respuesta. Si es exitosa, se modifica la columna "id_remota" del elemento local con el valor retornado del servidor.
  • finalizarActualizacion(): Limpia el registro insertado en el servidor y le asigna el identificador remoto obtenido como respuesta.

Paso 20. Crea un nuevo servicio dentro de sync llamado SyncService.java y añade el siguiente código:

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

/**
 * Bound Service que interactua con el sync adapter para correr las sincronizaciones
 */
public class SyncService extends Service {

    // Instancia del sync adapter
    private static SyncAdapter syncAdapter = null;
    // Objeto para prevenir errores entre hilos
    private static final Object lock = new Object();

    @Override
    public void onCreate() {
        synchronized (lock) {
            if (syncAdapter == null) {
                syncAdapter = new SyncAdapter(getApplicationContext(), true);
            }
        }
    }

    /**
     * Retorna interfaz de comunicación para que el sistema llame al sync adapter
     */
    @Override
    public IBinder onBind(Intent intent) {
        return syncAdapter.getSyncAdapterBinder();
    }
}

Al igual que el servicio de autenticación, el servicio de sincronización retorna la interfaz de comunicación en su método onBind().

No olvides declararlo en el Android Manifest.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.herprogramacion.crunch_expenses">

    ...

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme">

        ...

        <!-- SERVICIO DE SINCRONIZACIÓN -->
        <service
            android:name=".sync.SyncService"
            android:exported="true">
            <intent-filter>
                <action android:name="android.content.SyncAdapter" />
            </intent-filter>

            <meta-data
                android:name="android.content.SyncAdapter"
                android:resource="@xml/sync_adapter" />
        </service>
    </application>

</manifest>

El servicio de sincronización es iniciado al momento de recibir un filtro del tipo android.content.SyncAdapter , por lo que debes añadir una etiqueta <intent-filter> con esta referencia.

Adicionalmente se debe especificar los metadatos del sync adapter al servicio. Para ello usa el par entre la etiqueta android.content.SyncAdapter y el valor del recurso que veremos a continuación.

Paso 21. Lo siguiente es crear el archivo de metadatos para el Sync Adapter. Ve a /res/xml y añade un nuevo recurso llamado sync_adapter.xml.

sync_adapter.xml

<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="@string/account_type"
    android:allowParallelSyncs="false"
    android:contentAuthority="@string/provider_authority"
    android:isAlwaysSyncable="true"
    android:supportsUploading="true"
    android:userVisible="false" />

Los atributos que incluimos indican varios comportamientos del sincronizador.

  • allowParallelSyncs: Determina si es posible crear más de dos instancias del sync adapter para realizar sincronizaciones en paralelo. Se usa solo si tu app permite varias cuentas de usuario.
  • contentAuthority: Es la autoridad del content provider al que está ligado el sync adapter.
  • isAlwaysSyncable: Permite iniciar el sync adapter cada vez que se le indique. Usa false para permitir su ejecución solamente en el código.
  • supportsUploading: Si usas true, el sync adapter será iniciado automáticamente cada vez que el content provider cambie su contenido al usar notifyChange() con el tercer parámetro en true como había mencionado antes. Esto con el fin de subir los cambios recientes al servidor.
  • userVisible: Controla la forma en que se ven los datos de la cuenta en la sección de ajustes del sistema.

Implementar Adaptador Del Recycler View

Paso 22. Dentro de la carpeta /res/layout añade el diseño para los ítems de la lista llamado item_layout.xml. La idea es color el monto del gasto, su categoría y la fecha en la que fue realizado.

Layout Para Items Del RecyclerView

item_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:minHeight="?android:attr/listPreferredItemHeight"
    android:orientation="vertical"
    android:padding="16dp">


    <TextView
        android:id="@+id/monto"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:text="$Monto"
        android:textAppearance="?android:attr/textAppearanceSmall"
        android:textColor="@color/primaryColor"
        android:textSize="24sp" />

    <TextView
        android:id="@+id/etiqueta"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_below="@+id/monto"
        android:text="Etiqueta"
        android:textAppearance="?android:attr/textAppearanceSmall" />

    <TextView
        android:id="@+id/fecha"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignParentRight="true"
        android:layout_alignParentTop="true"
        android:text="Fecha"
        android:textAppearance="?android:attr/textAppearanceSmall"
        android:textColor="@android:color/black" />
</RelativeLayout>

Paso 23. Crea una nueva clase java llamada AdaptadorDeGastos.java que represente el adaptador con cursor para poblar el recycler view.

AdaptadorDeGastos.java

import android.content.Context;
import android.database.Cursor;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import com.herprogramacion.crunch_expenses.R;

/**
 * Adaptador del recycler view
 */
public class AdaptadorDeGastos extends RecyclerView.Adapter<AdaptadorDeGastos.ExpenseViewHolder> {
    private Cursor cursor;
    private Context context;

    public static class ExpenseViewHolder extends RecyclerView.ViewHolder {
        // Campos respectivos de un item
        public TextView monto;
        public TextView etiqueta;
        public TextView fecha;


        public ExpenseViewHolder(View v) {
            super(v);
            monto = (TextView) v.findViewById(R.id.monto);
            etiqueta = (TextView) v.findViewById(R.id.etiqueta);
            fecha = (TextView) v.findViewById(R.id.fecha);

        }
    }

    public AdaptadorDeGastos(Context context) {
        this.context= context;

    }

    @Override
    public int getItemCount() {
        if (cursor!=null)
        return cursor.getCount();
        return 0;
    }

    @Override
    public ExpenseViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
        View v = LayoutInflater.from(viewGroup.getContext())
                .inflate(R.layout.item_layout, viewGroup, false);
        return new ExpenseViewHolder(v);
    }

    @Override
    public void onBindViewHolder(ExpenseViewHolder viewHolder, int i) {
        cursor.moveToPosition(i);

        String monto;
        String etiqueta;
        String fecha;

        monto = cursor.getString(1);
        etiqueta = cursor.getString(2);
        fecha = cursor.getString(3);

        viewHolder.monto.setText("$"+monto);
        viewHolder.etiqueta.setText(etiqueta);
        viewHolder.fecha.setText(fecha);
    }

    public void swapCursor(Cursor newCursor) {
        cursor = newCursor;
        notifyDataSetChanged();
    }

    public Cursor getCursor() {
        return cursor;
    }
}

Creación De La Actividad Principal

Paso 24. Ve a /res/layout y abre el archivo activity_main.xml para modificar el diseño de la actividad principal. Usaremos un recycler view para la lista y un floating action button para la inserción de nuevos gastos.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/coordinator"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- En caso de lista vacía -->
    <TextView
        android:id="@+id/recyclerview_data_empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center_vertical|center_horizontal"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:paddingEnd="@dimen/activity_horizontal_margin"
        android:paddingLeft="@dimen/activity_horizontal_margin"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:text="No hay datos que mostrar" />

    <!-- Recycler -->
    <android.support.v7.widget.RecyclerView
        android:id="@+id/reciclador"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="3dp"
        android:scrollbars="vertical"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

    <!-- App Bar -->
    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

        <!-- Toolbar -->
        <android.support.v7.widget.Toolbar xmlns:app="http://schemas.android.com/apk/res-auto"
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" />

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

    <!-- FAB -->
    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="@dimen/size_fab"
        android:layout_height="@dimen/size_fab"
        android:layout_margin="@dimen/fab_margin"
        android:onClick="onClickFab"
        android:src="@drawable/ic_add"
        app:borderWidth="0dp"
        app:elevation="@dimen/fab_elevation"
        app:layout_anchor="@id/coordinator"
        app:layout_anchorGravity="bottom|right" />

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

También existe un text view adicional que mostrará un mensaje en caso de que la lista no tenga datos o los esté cargando.

Paso 25. Ahora vamos a modificar la clase MainActivity.

Esta actividad debe:

  • Poblar el recycler view con el adaptador que ya hemos creado.
  • Crear la cuenta de usuario auxiliar al iniciar la aplicación.
  • Inicializar el Sync Adapter cuando el botón de sincronización sea pulsado por el usuario.
  • Implementar loaders para cargar los datos del content provider en segundo plano y mantener actualizada la lista.
  • Iniciar la actividad de inserción al pulsar el fab.

MainActivity.java

import android.app.ActivityOptions;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.CursorLoader;
import android.support.v4.content.Loader;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;

import com.herprogramacion.crunch_expenses.utils.Utilidades;
import com.herprogramacion.crunch_expenses.R;
import com.herprogramacion.crunch_expenses.provider.ExpenseContract;
import com.herprogramacion.crunch_expenses.sync.SyncAdapter;

public class MainActivity extends AppCompatActivity
        implements LoaderManager.LoaderCallbacks<Cursor> {

    private RecyclerView recyclerView;
    private LinearLayoutManager layoutManager;
    private AdaptadorDeGastos adapter;
    private TextView emptyView;

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

        setToolbar();

        recyclerView = (RecyclerView) findViewById(R.id.reciclador);
        layoutManager = new LinearLayoutManager(this);
        recyclerView.setLayoutManager(layoutManager);
        adapter = new AdaptadorDeGastos(this);
        recyclerView.setAdapter(adapter);
        emptyView = (TextView) findViewById(R.id.recyclerview_data_empty);

        getSupportLoaderManager().initLoader(0, null, this);

        SyncAdapter.inicializarSyncAdapter(this);
    }

    private void setToolbar() {
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    }

    public void onClickFab(View v) {
        Intent intent = new Intent(this, InsertActivity.class);
        if (Utilidades.materialDesign())
            startActivity(intent,
                    ActivityOptions.makeSceneTransitionAnimation(this).toBundle());

        else startActivity(intent);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.menu_main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        int id = item.getItemId();
        if (id == R.id.action_sync) {
            SyncAdapter.sincronizarAhora(this, false);
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        emptyView.setText("Cargando datos...");
        // Consultar todos los registros
        return new CursorLoader(
                this,
                ExpenseContract.CONTENT_URI,
                null, null, null, null);
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        adapter.swapCursor(data);
        emptyView.setText("");
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
        adapter.swapCursor(null);
    }
}

Creación De La Actividad De Inserción

Paso 26. Ve a File > New > Activity > Blank Activity para crear la actividad de inserción. Nómbrala InsertActivity.java y confirma.

Paso 27. Para el diseño usaremos un formulario simple que recolecte los campos de un gasto. Al final del diseño pondremos un botón que agregue el registro.

Abre el archivo activity_insert.xml y copia la siguiente descripción.

activity_insert.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.herprogramacion.crunch_expenses.ui.InsertActivity">

    <!-- Toolbar -->
    <android.support.v7.widget.Toolbar xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"
        app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
        app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" />

    <LinearLayout
        android:id="@+id/formulario"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/toolbar"
        android:layout_centerHorizontal="true"
        android:orientation="vertical"
        android:padding="@dimen/activity_horizontal_margin">

        <android.support.design.widget.TextInputLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/universal_margin">

            <EditText
                android:id="@+id/monto"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:ems="10"
                android:hint="@string/monto_hint"
                android:inputType="number" />
        </android.support.design.widget.TextInputLayout>

        <TextView
            android:id="@+id/etiqueta_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/universal_margin"
            android:text="@string/etiqueta_title"
            android:textAppearance="?android:attr/textAppearanceSmall"
            android:textColor="@color/accentColor" />

        <Spinner
            android:id="@+id/categoria"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:entries="@array/etiquetas" />

        <TextView
            android:id="@+id/fecha_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/universal_margin"
            android:text="@string/fecha_title"
            android:textAppearance="?android:attr/textAppearanceSmall"
            android:textColor="@color/accentColor" />

        <TextView
            android:id="@+id/fecha"
            style="@android:style/Widget.DeviceDefault.Light.Spinner"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="2015-07-11"
            android:textAppearance="?android:attr/textAppearanceSmall" />

        <android.support.design.widget.TextInputLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/universal_margin">

            <EditText
                android:id="@+id/descripcion"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/descripcion_hint" />
        </android.support.design.widget.TextInputLayout>

    </LinearLayout>

    <Button
        android:id="@+id/boton"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_margin="@dimen/universal_margin"

        android:onClick="alClickearBoton"
        android:text="@string/boton"
        android:textColor="@android:color/white" />
</RelativeLayout>

Por primera vez incluyo el nuevo elemento de la librería de diseño llamado TextInputLayout. Su objetivo es proveer un foco más visual entre los edit texts que hay en la escena y mejorar el manejo de errores de su contenido.

Paso 28. Finalmente modifica el código de la actividad de inserción para guardar los datos en el content provider.

La idea es que luego de insertar el registro con el método insert() del Content Resolver se ejecute el Sync Adapter manualmente para procesar la sincronización inmediata.

Esta forma es muy forzada por mi parte, sin embargo nos servirá como un buen ejemplo para ver cómo se actualiza el servidor.

InsertActivity.java

package com.herprogramacion.crunch_expenses.ui;

import android.app.DatePickerDialog;
import android.content.ContentValues;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.View;
import android.widget.DatePicker;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;

import com.herprogramacion.crunch_expenses.utils.Utilidades;
import com.herprogramacion.crunch_expenses.R;
import com.herprogramacion.crunch_expenses.provider.ExpenseContract;
import com.herprogramacion.crunch_expenses.sync.SyncAdapter;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * Actividad de inserción para los gastos
 */
public class InsertActivity extends AppCompatActivity implements DatePickerDialog.OnDateSetListener {
    EditText monto;
    Spinner etiqueta;
    TextView fecha;
    EditText descripcion;


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

        setToolbar();

        monto = (EditText) findViewById(R.id.monto);
        etiqueta = (Spinner) findViewById(R.id.categoria);
        fecha = (TextView) findViewById(R.id.fecha);
        descripcion = (EditText) findViewById(R.id.descripcion);

        fecha.setOnClickListener(
                new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        new DateDialog().show(getSupportFragmentManager(), "DatePicker");
                    }
                }
        );
    }

    private void setToolbar() {
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        if (getSupportActionBar() != null)
            getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    }

    public void alClickearBoton(View v) {
        String montoText = monto.getText().toString();
        String etiquetaText = etiqueta.getSelectedItem().toString();
        String fechaText = fecha.getText().toString();
        String descripcionText = descripcion.getText().toString();

        ContentValues values = new ContentValues();
        values.put(ExpenseContract.Columnas.MONTO, montoText);
        values.put(ExpenseContract.Columnas.ETIQUETA, etiquetaText);
        values.put(ExpenseContract.Columnas.FECHA, fechaText);
        values.put(ExpenseContract.Columnas.DESCRIPCION, descripcionText);
        values.put(ExpenseContract.Columnas.PENDIENTE_INSERCION, 1);

        getContentResolver().insert(ExpenseContract.CONTENT_URI, values);
        SyncAdapter.sincronizarAhora(this, true);

        if (Utilidades.materialDesign())
            finishAfterTransition();
        else finish();
    }

    @Override
    public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {

        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        Date date = null;
        try {
            date = dateFormat.parse(year + "-" + monthOfYear + "-" + dayOfMonth);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        String outDate = dateFormat.format(date);

        fecha.setText(outDate);
    }

}

Paso 29. Ejecuta la aplicación en Android Studio para probar las funcionalidades de sincronización.

Actividad De Inserción Con Selección De Fecha

Otras Formas De Ejecución Del SyncAdapter

Habíamos dicho que un Sync Adapter puede ser disparado por:

  • Modificaciones en el servidor
  • Cambios en el content provider
  • Por la petición de red que hace android regularmente
  • A través de intervalos de tiempo
  • Manualmente

Nosotros usamos la última forma al llamar al método requestSync() con las banderas correspondientes.

¿Pero qué hay de las otras formas?

Para el manejo de notificaciones del servidor se requiere un tutorial completo que explique cómo usar Google Cloud Messaging. Así que no podemos entrar en detalle.

La segunda opción la explicamos a lo largo del artículo, sin embargo podemos decir que debemos:

  • Habilitar la subida de datos al servidor con el atributo supportsUploading de la definición XML del sync adapter.
  • Indicarle al Sync Adapter que que ese ejecute luego de que el content provider cambie con el método notifyChange(uri, null, true).
  • Comprobar en onPerformSync() el valor de la bandera SYNC_EXTRAS_UPLOAD.

La tercer opción podemos lograrla con el método ContentResolver.setSyncAutomatically(), el cual recibe la instancia de la cuenta, el nombre de la autoridad y la confirmación:

ContentResolver.setSyncAutomatically(
 newAccount, context.getString(R.string.provider_authority), true);

La sincronización periódica podemos habilitarla con el método ContentResolver.addPeriodicSync(). El primer parámetro es la cuenta asociada, el segundo la autoridad del content provider, el tercer un nuevo objeto Bundle y el cuarto la cantidad en segundos a esperar entre sincronizaciones.

ContentResolver.addPeriodicSync(
        newAccount, context.getString(R.string.provider_authority), new Bundle(),3600);

Conclusión

La sincronización es un tema extenso que puede darte complicaciones si no tienes claras las condiciones necesarios para transferir tus datos.

Aunque este artículo te ha presentado algunas ideas para el uso de Sync Adapters, te recomiendo sigas estudiando y practicando mucho más.

Recuerda que es posible iniciar una sincronización a través de Google Cloud Messaging por cambios en el servidor; cuando cambia el contenido de un Content Provider; programando intervalos de tiempo regulares; permitiendo al usuario hacerlo manualmente o a través del envío de una petición de red.

Cualquiera de las posibilidades debe estar acompañada de la administración de cuentas para la autenticación, indiferentemente si usas login o no.

¿Necesitas Otro Ejemplo De Servicio Web?

Hace unos días lancé un tutorial detallado para crear un servicio web REST para productos, clientes y pedidos. Donde consumo sus recursos desde una aplicación llamada App Productos. Échale un vistazo a todo lo que incluye (tutorial PDF, código completo en Android Studio, código completo PHP, script MySQL con 100 productos de ejemplo).

Comprar app productos

Ú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!