¿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:

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":"Diversi\u00f3n",
         "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":"Diversi\u00f3n",
         "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

Puedes encontrar las clases de utilidades y la creación del dialogo de fechas descargando el proyecto completo desde aquí:

[fancy_box id=5 linked_cu=5991]Click AQUÍ para descargar el proyecto en Android Studio.[/fancy_box]

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

  • Robinson Batista

    Muito bom, estou testando mas tem alguns pontos para acertar ai. Na main activity e na activity de insercion no estas traaduzido a classe GastosParaContract. No manifest no foi dito para colocar as tags

  • Robinson Batista

    Muito bom, estou testando mas tem alguns pontos para acertar ai. Na main activity e na activity de insercion no estas traaduzido a classe GastosParaContract. No manifest no foi dito para colocar as tags

  • Eduardo Diaz

    Hola, interesante el material, no tendrás un ejemplo con ASP.NET y SQL Server?.
    Saludos.

  • Eduardo Diaz

    Hola, interesante el material, no tendrás un ejemplo con ASP.NET y SQL Server?.
    Saludos.

    • Gracias Eduardo. Lastimosamente no, sin embargo espero crear tutoriales para ellos en el futuro. Saludos!

    • Gracias Eduardo. Lastimosamente no, sin embargo espero crear tutoriales para ellos en el futuro. Saludos!

  • Eduardo Diaz

    Hola, no tendrás un ejemplo en .NET?, es decir que consuma un servicio web de asp.net, por cierto muy buenos tus ejemplos. Saludos.

  • Eduardo Diaz

    Hola, no tendrás un ejemplo en .NET?, es decir que consuma un servicio web de asp.net, por cierto muy buenos tus ejemplos. Saludos.

  • Jose

    Primeramente te felicito por tus excelentes tutoriales, estoy siguiente este tutorial descargue tu proyecto pero me sale varios errores soy nuevo en desarrollo android, la api mas baja que tengo es la 16 pero utilizo la 18 con genymotion dejo unas capturas de antemano muchas gracias.

  • Jose

    Primeramente te felicito por tus excelentes tutoriales, estoy siguiente este tutorial descargue tu proyecto pero me sale varios errores soy nuevo en desarrollo android, la api mas baja que tengo es la 16 pero utilizo la 18 con genymotion dejo unas capturas de antemano muchas gracias.

  • Lash Azem

    Hola, Primero que todo muchísimas gracias por todos estos tutoriales que has hecho, me han ayudado bastante en mi proceso de aprender Android. Tengo una pequeña consulta, y es que me gustaría llamar unas imagen para cada ítem que se sincroniza, como podria hacer esto?

  • Lash Azem

    Hola, Primero que todo muchísimas gracias por todos estos tutoriales que has hecho, me han ayudado bastante en mi proceso de aprender Android. Tengo una pequeña consulta, y es que me gustaría llamar unas imagen para cada ítem que se sincroniza, como podria hacer esto?

  • Edgar Mejia

    Amigos, como puedo probar la creacion de un gasto desde una herramienta Rest cliente? por ejemplo yo uso esta url http://localhost/webservices/web/obtener_gastos.php metodo GET desde Advanced Rest Client de google chrome y me devuelve un valor JSON como este

    [{“idGasto”:”1″,”monto”:”12″,”etiqueta”:”Transporte”,…

    Pero para insertar un gasto intento asi http://localhost/webservices/web/insertar_gasto.php?monto=111&etiqueta=Transporte2&fecha=2016-08-02&descripcion=viajes metodo POST, pero no me agrega ningun registro a la base de datos ni me retorna una respuesta positiva. Como debo armar el url o que mas debo colocar en la herramienta Rest Cliente?

  • Edgar Mejia

    Amigos, como puedo probar la creacion de un gasto desde una herramienta Rest cliente? por ejemplo yo uso esta url http://localhost/webservices/web/obtener_gastos.php metodo GET desde Advanced Rest Client de google chrome y me devuelve un valor JSON como este

    [{“idGasto”:”1″,”monto”:”12″,”etiqueta”:”Transporte”,…

    Pero para insertar un gasto intento asi http://localhost/webservices/web/insertar_gasto.php?monto=111&etiqueta=Transporte2&fecha=2016-08-02&descripcion=viajes metodo POST, pero no me agrega ningun registro a la base de datos ni me retorna una respuesta positiva. Como debo armar el url o que mas debo colocar en la herramienta Rest Cliente?

  • Edgar Mejia

    hola que buen tutorial. Pero no he podido bajar el codigo el dia de hoy. Pueden por favor enviarmelo a omejiasq@gmail.com ?

    Alguien sabe como se podria diseñar una tabla para control de informacion mas avanzada? por ejemplo he leido algo que uno puede colocar en la tabla de la base de datos un id unico universal alfanumerico y ese valor nunca se repetira, si no esta se crea nuevo en la nube, si esta se actualiza. Ademas de esto he leido tambien usar un campo tipo UTC con una hora estandar universal, el usuario que crea y el registo y usuario que actualiza. Esto esta bien? falta algun campo? Ademas de esto tienen alguna logica como la del grafico aca sugerido, para hacer sincronizacion y no se duplique la informacion o se dañe la informacion sincronizada?

    Muchas gracias

  • Edgar Mejia

    hola que buen tutorial. Pero no he podido bajar el codigo el dia de hoy. Pueden por favor enviarmelo a omejiasq@gmail.com ?

    Alguien sabe como se podria diseñar una tabla para control de informacion mas avanzada? por ejemplo he leido algo que uno puede colocar en la tabla de la base de datos un id unico universal alfanumerico y ese valor nunca se repetira, si no esta se crea nuevo en la nube, si esta se actualiza. Ademas de esto he leido tambien usar un campo tipo UTC con una hora estandar universal, el usuario que crea y el registo y usuario que actualiza. Esto esta bien? falta algun campo? Ademas de esto tienen alguna logica como la del grafico aca sugerido, para hacer sincronizacion y no se duplique la informacion o se dañe la informacion sincronizada?

    Muchas gracias

    • Hola Edgar, estás intentando descargarlo de la caja de descarga?

      • Edgar Mejia

        Hola James gracias. Habian dos cajas de descarga, una al inicio y otra al final. La del final no sirve, pero intente con la caja que hay al inicio y esta si me descargo el codigo. James si entendi bien este modelo de sincronizacion no permite saber cual es el ultimo registro agregado o actualizado en la nube si hay multiples usuarios y multiples tablas. Yo he visto mucho en desarrollos en la nube que usan otros campos como id universal, un campo UTC, y otros como proveedores de desarrollos automtizados en la nube como telerik. Tienes alguna idea si con este solo codigo puedo crear o actualizar por ejemplo a lo ultimo hecho en un mismo registro cuando lo usan diferentes usuarios?

      • Edgar Mejia

        Hola James gracias. Habian dos cajas de descarga, una al inicio y otra al final. La del final no sirve, pero intente con la caja que hay al inicio y esta si me descargo el codigo. James si entendi bien este modelo de sincronizacion no permite saber cual es el ultimo registro agregado o actualizado en la nube si hay multiples usuarios y multiples tablas. Yo he visto mucho en desarrollos en la nube que usan otros campos como id universal, un campo UTC, y otros como proveedores de desarrollos automtizados en la nube como telerik. Tienes alguna idea si con este solo codigo puedo crear o actualizar por ejemplo a lo ultimo hecho en un mismo registro cuando lo usan diferentes usuarios?

    • Hola Edgar, estás intentando descargarlo de la caja de descarga?

  • Viridiana Volantin

    Hola, el link del proyecto no funciona podrías arreglarlo por favor o mandarmelo a mi correo electronico el cual es viris.890801@gmail.com.

  • Viridiana Volantin

    Hola, el link del proyecto no funciona podrías arreglarlo por favor o mandarmelo a mi correo electronico el cual es viris.890801@gmail.com.

    • Hola, que error sale?, acabo de probarlo y funciona correctamente

      • Viridiana Volantin

        Hola ya pude descargar el proyecto pero al momento de que ejecute la aplicación en un enmulador me muestra el siguiente error:

      • Viridiana Volantin

        Hola ya pude descargar el proyecto pero al momento de que ejecute la aplicación en un enmulador me muestra el siguiente error:

  • Nicolas Mahecha

    Hola, esta muy bueno el tutorial pero faltan las Utilidades y Constantes.

    Alguien sería tan amable de enviarlas a mi correo?
    mi correo es: nicolasmelectronic@gmail.com

    Gracias
    Saludos.

  • Rocky Velasco

    No funciona el link del proyecto pudieras arreglarlo por favor o alguien que lo tenga me lo podrían mandar a mi correo electrónico que es rockymaxel@gmail.com gracias.

  • Rocky Velasco

    No funciona el link del proyecto pudieras arreglarlo por favor o alguien que lo tenga me lo podrían mandar a mi correo electrónico que es rockymaxel@gmail.com gracias.

    • Hola Rocky, acabo de probar el link y está bueno. Prueba recargando con F5 la página para ver si la caché te está jugando una mala pasada.

      Saludos!

    • Hola Rocky, acabo de probar el link y está bueno. Prueba recargando con F5 la página para ver si la caché te está jugando una mala pasada.

      Saludos!

  • Miguel

    Hola James, muchas gracias por el aporte. No he podido descifrar muy bien las constantes y utilidades. Podrías compartirlas por favor, te lo agradezco.

  • Carlos Adan Mollapaza Cutipa

    Esta bien pero no podria ser algo MAS SIMPLE hay muchas que hacer

  • Frank

    Increible tutorial. Excelente explicacion. me ha servido de mucho. El link no me ha funcionado. esta muerto.

  • Alexander

    Hola buena tarde, seguí el proyecto pero faltan las Utilidades y Constantes.

    Alguien sería tan amable de enviarlas a mi correo?
    mi correo es: isc_alexander@hotmail.com

    Gracias de antemano.
    Saludos.

  • Anabel Figueroa

    Hola a todos!! me podrían decir en que carpeta van los Strings de los layouts

  • Manuel Serrano

    ¿Por qué no se usa una columna de fecha de modificación en la base de datos como si se hace en la publicación de consumir un servicio rest desde android?

  • Guillermo Ochoa

    Buenas tardes, en el caso de que el dispositivo no tenga una conexion de red, ¿que alternativas o sugerencias puedo tener para controlar dicha sincronizacion?. muy buen tuto.

  • victor

    Hola James, ¿Qué tal todo? Espero que bien. Quería preguntarte la fiabilidad de escoger datos según la fecha de creación, es decir, tengo en servidor unos datos con una fecha de creación y en el móvil otros datos con otra fecha de creación, y quería preguntar, si es fiable o es mala praxis por ejemplo, quedarse con los de la fecha más reciente, ya que la del móvil puede ser cambiada. O si conoces alguna otra estrategia? Ya que el móvil puede crear datos sin tener conexión a internet… Era saber la fiabilidad o si existe algo para asegurarlo o ns…. (obligar a tener la fecha bien al usuario)…

    Muchas gracias!!

  • Winston Jr Rodriguez Stand

    Saludos, buen tutorial, me gustaría que me ayudasen con el siguiente error, espero prontas respuestas

    • IngriRodr

      Yo lo pude solucionar con esto:
      Log.d(TAG,”Error en sincronizacion del servidor al cliente local: “+error.getMessage());

  • c3rv30

    Hola, Este tutorial sirve con la version 4.1 jelly bean??? alguien que sepa….. Gracias

  • Jhoann Posada

    Buen Dìa
    Necesito implementar la accion para que cuando se le de click a el registro guardado vaya a otro activity, alguien puede ayudarme ?….

  • Xana Eds

    Me gusta mucho tu explicación, muchas gracias. No funciona el link del proyecto ojalá puedas compartirlo para poder probarlo =)
    Saludos!

  • Xana Eds

    Me gusta mucho tu explicación, muchas gracias. No funciona el link del proyecto ojalá puedas compartirlo para poder probarlo =)
    Saludos!

  • victor

    Hola James, ¿Qué tal todo?
    Llevo un rato leyendo comentarios y veo que para utilizar syncAdapter hay que utilizar content provider, pero lo que no me gusta de content provider es que hay que hacer pasar todas la tablas por el tubo y tengo más de 20 tablas y se convierte en algo poco escalable y poco legible. Lo del content provider mola porque accede en segundo plano etc, pero por lo que necesito, no accedo a BD constantemente ni mucho menos, sólo puntualmente para hacer persistencia de los datos, que es la inserción de unas pocas filas y no lo veo necesario. Y las query asi un poco grandecitas las hago en un punto donde no interfiere la acción del usuario, sólo al arrancar la app.

    Y he encontrado cosas de estas: http://stackoverflow.com/questions/4649808/syncadapter-without-a-contentprovider para SyncAdapter sin content provider, pero no sé yo…Quería tu opinión, ya que hay la opción de crear varios content provider, pero no me gusta… Ahora tengo varias clases con sus respectivos CRUD a pelo accediendo a la BD y cerrando la bd, y funciona todo perfectamente fluido.

    Muchas gracias por todo!

  • Jhoann Posada

    Hola… Gracias por tu tutorial, descargue el código pero no logro que los datos desde mi android sean subidos al servidor….. me pueden ayudar ?

  • Ricardo Jimenez

    alguien sabe por que sale este error
    02-20 19:51:40.631 20005-20005/com.herprogramacion.crunch_expenses D/SyncAdapter: Error Volley: java.net.ConnectException: failed to connect to /127.0.0.1 (port 80) after 2500ms: isConnected failed: ECONNREFUSED (Connection refused)

  • Marcelo Pickelny

    Ademas me olvide de comentar que no veo dentro de Android Studio el archivo R.java
    Estoy trabajando con la version 1.5.1
    Gracias

  • Marcelo Pickelny

    Hola James, gracias por los tutoriales..los estoy siguiendo a todos y tengo un problema en este tutorial de Sincronizar base de datos… En el archivo InsertActivity me sale que no puede resolver el symbol R.
    Me da error la siguiente linea de código

    import com.example.marcelo.sincrosqlitemysql.R;

    Ya intente haciendo clean y rebuild del proyecto y no logro nada..Sigo con el error
    Espero me puedan ayudar

    Gracias

    • Hola Marcelo, gracias por leer mis tutoriales. Si estás intentando cambiar el dominio de la app, debes cambiarlo en el AndroidManifest.xml para que pueda surgir efecto a la hora del build

      • Marcelo Pickelny

        Gracias James por la pronta respuesta. Estuve controlando el Manifiest y no le encuentro problemas. Te paso las capturas de pantallas para que veas si podes encontrar el error. Desde ya gracias

        • Hubieses dejado el paquete original que tenía o haber creado un nuevo proyecto desde cero con tu dominio.

          Intenta borrar el contenido de la carpeta build y luego compila de nuevo. Es lo único que se me ocurre

      • jg

        doc haber si enviar la fuente jgsistem@gmail.com saludos

        • Hola, puedes descargarlo desde la caja de descargas que aparece debajo del video

        • Hola, puedes descargarlo desde la caja de descargas que aparece debajo del video

  • Adrian

    Increible explicación, falta probarlo. Por cierto el link del proyecto no funciona.

  • Daniel Carnicer

    El enlace al codigo fuente ha caido, asi que el contenido de Utilies y Constantes no se puede saber, ruego por favor resubalo para asi poder implementar los metodos (como el metodo deCursorAJSONObect).

  • Daniel Carnicer

    El enlace al codigo fuente ha caido, asi que el contenido de Utilies y Constantes no se puede saber, ruego ppr favor resubalo para asi poder implementar los metodos (como el metodo deCursorAJSONObect).

  • Lucas Emanuel Ortiz

    Hola quisiera hacer una consulta. En el InsertActivity, luego de insertar el registro en la base local, se realiza una llamada al método “sincronizarAhora()”, aqui el fragmento del codigo:

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

    Esto quiere decir que realizamos la sincronizacion manualmente. Yo intenté cambiar en el método “insert()” del content provider cambiar la bandera de notifyChange a true para que dispare la sincronización el content provider automáticamente
    Cambie ésto:
    resolver.notifyChange(uri_gasto, null, false);
    Por ésto:
    resolver.notifyChange(uri_gasto, null, true);

    Quite la línea de InsertActivity
    SyncAdapter.sincronizarAhora(this, true);

    pero no sincroniza. En que puedo estar fallando??

  • Marco Antonio Pérez Hernández

    Tengo una duda muy grande en el metodo realizarSincronizacionLocal me marca error,

    enprivate 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() {
    @Override
    public void onResponse(JSONObject response) {
    procesarRespuestaGet(response, syncResult);
    }
    },
    new Response.ErrorListener() {
    @Override
    public void onErrorResponse(VolleyError error) {
    Log.d(TAG, “HAY UN Error: ” + error <<<<<<<<<<<>” + error.networkResponse.statusCode
    + “>>” + error.networkResponse.data
    + “>>” + error.getCause()
    + “>>” + error.getMessage());
    }

    al parecer creo es en la clase constantes con la variable GET_URL y creo q no puede acceder a obtener_gastos.php. ala pagina q contiene lo datos lo tengo en un host http://pruebasmarco.webcindario.com/web/obtener_gastos.php del navegador si puedo acceder bien pero en el dispositivo no ayuda porfavor

    mis variables estan asi

    private static final String IP =”http://pruebasmarco.webcindario.com/web/”;
    public static final String GET_URL = IP + “obtener_gastos.php”;

  • Marco Antonio Pérez Hernández

    Tengo un ultimo problema, resulta que en el metodo obtenerCuentaASincronizar nome puede crear la cuenta local, por lo tanto no puedo inicializar el SyncAdapter ¿Que puedo hacer??? al iniciar la app se cierra inmediato AYUDA PORFAVOR

  • Marco Antonio Pérez Hernández

    HOLA buen dia gracias por este tutorial tengo una duda en la clase constantes en la direccion IP mencionan algo de direccion genymotion o avd yo voy a utilizar una terminal un MOTO G quisiera saber q IP poner gracias

    • Hola que tal. Pensaría que la de tu pc local compañero

      • Marco Antonio Pérez Hernández

        POR FAVOR AYUDA y por ejemplo si voy a subir la bd a un host como hostinger??.
        private static final String IP = me refiero a esta linea, tendria q poner la IP del host ??

  • Luis Ernesto Alcantara

    Hola muy buen tutorial, quisiera saber como logro hacerlo con varias tablas, a mi me da un error, al hacer lo siguiente:

    clasetabla1.realizarsincronizacionlocal…
    calsetabla2.realizarsincronizacionloca….

    cada clase implementa toda la lógica de sincronización esto lo hago en el onperfomesync….

  • Marco Antonio Pérez Hernández

    hola me puden ayudar porfavor necesito si alguien me puede pasar la clase ExpenseContract en en paquete Provider ya qno viene en el archivo rar :)

    • Hola compañero, en el rar se llama ContractParaGastos.java. Es la misma clase, solo que al parecer refactoricé luego de haber escrito el artículo, por lo que debo corregir ese nombre. Sin embargo es lo mismo.

      Saludos!

  • Joseph R

    Hola!! disculpa cual es el archivo en concreto que sube la base de datos actualizada con nuevos datos de la aplicacion al servidor?? te estoy siguiendo desde hace poco pero por motivos laborales necesito ver como sube los datos de la app al servidor :/ estoy nervioso con esto jeje, gracias! la verdad me ha gustado mucho tu pagina, no dudo en compartirla.

    Saludos

  • Giancarlo

    Hola James, quiero felicitarte por esta gran contribución, en verdad que hay pocos sitios en español que son realmente buenos en cuanto a programación en Android. Quisiera manifestarte que he estando probando la aplicación y funciona bien, salvo que he notado que si registro gastos cuando la aplicación no tiene conexión al servidor se guardan en la base de datos local (sqlite) y que después al tener conexión y realizar la sincronización los datos locales no se registran en la base de datos myql, lo que genera inconsistencia de datos (porque en sqlite tengo 9 registros pero en mysql tengo solo 7). ¿Cómo lo soluciono?

  • Giancarlo

    Hola James, quiero felicitarte por esta gran contribución, en verdad que hay pocos sitios en español que son realmente buenos en cuanto a programación en Android. Quisiera manifestarte que he estando probando la aplicación y funciona bien, salvo que he notado que si registro gastos cuando la aplicación no tiene conexión al servidor se guardan en la base de datos local (sqlite) y que después al tener conexión y realizar la sincronización los datos locales no se registran en la base de datos myql, lo que genera inconsistencia de datos (porque en sqlite tengo 9 registros pero en mysql tengo solo 7). Cómo lo soluciono?

  • marco davalos

    Buenas, querría saber si es necesario el uso de un Content Provider si no necesitara compartir mi base de datos sqlite con el resto de aplicaciones, o seria mas correcto utilizar un Content Provider ?

    • Hola Marcos. El content provider es obligatorio en el framework de sync adapters. Pero si no lo vas a usar, entonces solo crea un content provider auxiliar, donde solo sobrescribes los metodos sin nada de contenido.

      Mira aquí la forma de hacerlo y la explicación del porque es necesario: http://developer.android.com/intl/es/training/sync-adapters/creating-stub-provider.html

      • marco davalos

        muchas gracias, otra duda que me asalta es el si se puede trabajar con este framework sin haber montado un web service, es decir por ejemplo haciendo consultas directamente al una base de datos a través de funciones de su jdbc

  • Carlos Marquez

    Hola, muchas gracias por el tutorial. Estaba tratando de descargar el archivo de dropbox pero me sale error 404. Por favor, subelo nuevamente cuando tengas tiempo. Un abrazo.

  • Fernando Escalante

    Hola James.

    Primero quería decirte que excelente tutorial… no conocía esta página pero ahora ya tengo con que entretenerme durante las vacaciones!

    Segundo queria mostrarte este error:
    Fatal error: Class ‘PDO’ not found in C:AppServwwwresultadosDatabaseConnection.php on line 55

    Me dice que no encuentra la Clase PDO.

    54 | self::$pdo = new PDO( —> Esa es la línea que me da error.

    Estoy creando la clase PDO pero en ningún lugar la creo, ni le creo el constructor.

  • Leandro Curra

    Hola.. Desde ya muchas gracias por todo el trabajo que hacen.
    Consulta: El link del Dropbox esta caído,podría alguien pasarme el proyecto, o las clases constantes y utilidades de android?
    Desde ya muchas gracias

    • Hola Leandro, si ya estoy arreglando los links, resulta que cambie el nombre de la carpeta en dropbox y todo se trastocó.

      • Leandro Curra

        Gracias . vuelvo agradecerte todo el trabajo que realizan en los tutoriales y la excelente calidad de los mismos

      • Pablo

        Hola. Muchas gracias por el trabajo que te has tomado al realizar esta explicación. Mientras te consulto por qué el link a las clases Constantes y Utilidad. No las podemos crear nosotros? o son muy extensas. Te pregunto porque tampoco me funcionó el link. Muchas gracias.

      • Marco Antonio Pérez Hernández

        hola me puden ayudar porfavor necesito si alguien me puede pasar la clase ExpenseContract en en paquete Provider ya qno viene en el archivo rar

  • Matias Vetach

    Me da este error al momento de sincronizar BasicNetwork.performRequest: Unexpected response code 403 for http://192.168.0.104:80/Crunch%20Expenses/web/obtener_gastos.php A alguien más le pasa?

  • Federico Heiland

    Hola, me anduvo perfecto. Pero tengo una duda, que creo es bastante general. Cómo proseguir con N tablas, la peticion al servidor, y los metodos del sync adapter. Debo crear adapters para cada tabla?
    Desde ya muchas gracias;

  • Adam

    Hola James, primero que todo felicitarte por el sitio y por los increíbles tutoriales. He estado probando este ejemplo , incluso descargue tu código para no dejar ningún detalle, todo bien en la sincronización hasta que llego a este método realizarSincronizacionLocal() en el SynAdapter, me da este Error:(114, 39) error: incompatible types: int cannot be converted to String, te adjunto una captura, ya he revisado todo el proyecto y no se que puede estar pasando, gracias, un saludo

  • Santiago Orozs

    Buenas
    Una pregunta ?

    Como hago para autenticar con la cuenta de google.
    Que debo hacer

  • Eduardo

    Buen tutorial James me ha servido mucho, pero eh estado ya muchas horas tratando de llenar un spinner con los datos almacenas en la db de la aplicacion, me explico: cada vez que se inserte un pedido que este mismo salga en un spinner al final del formulario , solo habria que rescatarlo de la base de datos me puedes orientar un poco? , saludos

  • Christián

    Hola james, como podría hacerlo para sincronizar una foto?, saludos excelentes tutoriales :D

    • Hola. Te refieres a un sync adapter para solo fotos, o la foto viene mezclada con otros campos ?

      • Christián

        Mesclada con campos, asi como que en este mismo formulario tenga la opcion de sacar una foto y posteriormente sincronizarla con el servidor con todos los datos

        • Mm pues podrías usar un campo BLOB para subir la imagen a través de una petición POST.

  • Christián

    Disculpen mi ignorancia, pero al hacer una sincronización la aplicación se detiene, que estoy haciendo mal? , estoy utilizando xampp y android estudio, agradesco su ayuda, saludos

    • Hola compañero. Podrias pegar un pantallazo del logcat para ver el error?

      • Christián

        ese error me sale al sincronizar con mysql , en si la app al iniciar no sincroniza los datos, solo muestra los que tiene almacenados en SqlLite, gracias james por responder a mi consulta :D

        • Christián

          Ya lo solucione, gracias por este tutorial era la direccion IP la cambie por 10.0.2.2, saludos James

        • Camilo Cortes

          Hola Cristian a mi me esta saliendo este mismo error, y ya modifique el ip pero sigue. Me podrías decir si le hiciste algo mas?

          • Christián

            no, no hice nada mas compañero solo cambie eso. y le quite el puerto.

  • Carolina Choque Coronel

    hola buenas una pregunta no me reconoce import com.herprogramacion.crunch_expenses.provider.ExpenseContract

  • saori

    Hola, tengo una pregunta.. Si tuviera que sincronizar N tablas en lugar de una sola como en el ejemplo, debería crear una clase SyncAdapter por cada una de ellas o basta una? Gracias por el tutorial, está muy bueno!

    • Hola Saori. Solo un syncadapter. Recuerda que este hace caso del contenido del content provider, el cual contiene todas las tablas relacionadas del esquema de BD.

  • sebastian ls

    Como se hace para modificar y eliminar?? soy novato en estos temas….

  • SinergiaMLM Multinivel

    Quiero aprender a modificar y eliminar tendrás alguna referencia donde pueda aprenderlo, de ante mano muchas gracias por el tuto esta buenisimo…

    • Gracias compañero.

      No conozco de muchas referencias sobre ello. Sin embargo podría planificar un articulo que contenga todo el CRUD completo. Solo que no se cuando :D, ajajja aún faltan muchos temas que cubrir.

  • Diego multinivel

    Hola James! Primeramente felicidades por tan buenos tutos. Una duda, si sabes como hacer para borrar y modificar los registros seria un buen aporte…

    • Hola Diego.

      Quizás en el futuro pueda crear un artículo para cubrir una sincronización más funcional.

  • German

    Hola James, te felicito por el sitio muy bueno , tenes pensado dar algun ejemplo con Google Cloud Messaging ?? Gracias

    • Hola German.

      Claro que sí, sin embargo puede que demore un poco amigo. Pero tenlo por seguro.

  • San

    Muchisisimas gracias por las guias!! Ando acoplandolo todo a mi app y necesito tan solo hacer un POST. Me funciona todo, el content provider con su SQLite y se inserta bien, y luego a la hora de sincronizar, lee el registro sucio y lo inserta bien, pero me sale un error que dice “org.json.JSONException: End of input at character 0 of” y se queda el registro sucio aunque se haya isnertado en mysql, creo que devuelve null el método onResponse(JSONObject)… porfa alguna sugerencia? tan solo quiero hacer dos posts seguidos jeje por ahora. Pero aunque añada muchos registros sucios de POSTS a la cola, como el primero queda sucio pues la cola tan solo se agranda…. y al mysql solo le llegó el primero y ninguno mas =S gracias de antemano

    • San

      Rectifico, va directamente al metodo onErrorListener y por eso me sale “Error Volley: org.json.JSONException: End of input at character 0 of”. Está todo tal cual. He hecho una clase propia, “MyJsonObjectRequest” para sobreescribir el metodo “parseNetworkResponse”, y con esto consigue entrar en el método onResponse, pero llega nulo =S

      • Hola San.

        ¿Estás probando la base de datos del articulo o una propia?

        • San

          Muy buenas James, he conseguido hacer una propia sobre la del artículo jeje. Ya me funciona, solo que ando en un pequeño problema ahora…
          Si por ejemplo, cualquier usuario borra los datos de la aplicación (con su respectivo provider y base de datos interna), y en un futuro vuelve a insertar esos datos, me ocurre el error de que al insertar nuevamente en el provider esos datos, justo luego intenta sincronizar… pero PLOP!, esos datos ya existen en la externa, y queda el registro sucio en la cola =S Supongo que cuando sincroniza, debo checkear si ya existe en la externa, y si es asi, borrarlo de la cola de provider. Pero aun no lo tengo todo muy claro para la mejor practica en el tema de la sincronización =S, debería crear metodo propio en SyncAdapter?
          PD: A la hora de insertar lo puse asi =S:

          getActivity().getContentResolver().insert(GreenContracts.CONTENT_URI, values);”

          y justo luego:

          “SyncAdapter.sincronizarAhora(getActivity(), true);”

          algun consejo?

          Mil gracias por todo, me sirve de mucho!

          • Johan Ls

            Hola san, se nota que sabes del tema no se si me puedes ayudar… veo que sabes como borrar un registro la duda q tengo es q si borro desde los base de datos de la aplicación (con su respectivo provider y base de datos interna) al syncronizar tambien se borraran de la bd externa ? o es necesario modificar algo en el syn?…
            yo intente desde el mismo AdaptadorDeGastos con este codigo pero no me sale:

            Uri uri = ContentUris.withAppendedId(ContractParaGastos.CONTENT_URI, cursor.getLong(cursor.getColumnIndex(ContractParaGastos.Columnas._ID)));

            context.getContentResolver().delete(uri,null,null);

          • San

            Bueno amigo aún no tengo todo clarisimo el tema de la sincro. Yo voy aprendiendo conforme voy programando al paso jejeje. Pero si te ayuda te dire como acabé haciéndolo.
            Lo único que hice fué modificar el archivo “Gastos.php”, en el que incluí un método nuevo que sirve para buscar en la bd externa si ya existe el registro, lo hago mediante un campo unico que en mi caso se llama IDENTIFICADOR, el método es este:

            public static function getById($id) {
            try {
            // Creo sentencia para buscar si existe el mismo
            // campo IDENTIFICADOR que se desea insertar
            $query = “SELECT COUNT(*) FROM ” . self::TABLE_NAME . ” WHERE IDENTIFICADOR = :id”;
            // obtengo bd externa
            $basedatos = DatabaseConnection::getInstance()->getDb();
            // Preparo sentencia
            $comando = $basedatos->prepare($query);
            // parametro
            $comando->bindValue(‘:id’, $id);
            // Ejecutar sentencia preparada
            $comando->execute();

            // retorno resultado
            return $comando;

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

            Y luego en su método insertRow($object) lo modifiqué también, lo único que hice fué invocar a ese nuevo método, para que compruebe si existe antes de insertar el nuevo registro en la externa. Entonces me quedó así:

            public static function insertRow($object) {

            // compruebo si ya existe el registro
            try {
            // obtengo identificador del objeto a insertar
            $IDEN= $object[self::identificador];
            // invoco al método para comprobar si existe en la externa
            $usuario = self::getById($IDEN);
            // compruebo el resultado
            if($usuario->fetchColumn() > 0) {
            // si tiene algún resultado significa que ya existe, retorno 1
            return 1;
            }
            } catch (Exception $e) {
            // si obtuvo error retorno 2
            return 2;
            }

            try {

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

            // Sentencia INSERT
            $comando = “INSERT INTO ” . self::TABLE_NAME . ” ( ” .
            (…)
            . . . ETC . . . . . (lo demas igual que en el tuto)

            Y por último, esos valores retornados, los recoge el archivo “insertar_gastos.php”, y es ahí donde depuro el resultado, de tal manera:

            (…)
            if ($_SERVER[‘REQUEST_METHOD’] == ‘POST’) {

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

            // Insertar usuario, pongo (int) para asegurarme de
            // obtener un valor entero como resultado.
            $idUsuario = (int) Usuarios::insertRow($body);

            $bien = array (“estado” => 1, “mensaje” => ‘Creacion exitosa’, “idUsuario” => $idUsuario);
            $mal = array (“estado” => 2, “mensaje” => ‘Creacion fallida’, “valor retornado: ” => $idUsuario);
            $existe1 = array (“estado” => 3, “mensaje” => ‘Ya existe’, “idUsuario” => 1);
            $existe2 = array (“estado” => 4, “mensaje” => ‘Ya existe2’, “idUsuario” => 2);

            // si la respuesta fué 1 significa que ya existe
            if ($idUsuario == 1) {
            // Código existe
            print json_encode($existe1);
            }
            // si la respuesta fué 2 significa que hubo error
            if ($idUsuario == 2) {
            // Código existe
            print json_encode($mal);
            }

            // si la respuesta no fué 1 ni 2, pero es mayor que 0, significa que tuvo éxito
            if ($idUsuario > 0) {
            // Código de éxito
            print json_encode($bien);
            // sino es que hubo algún error
            } else {
            // Código de falla
            print json_encode($mal);
            }
            }
            ?>

            Y ya para finalizar, tratamos ese resultado que tuvo la inserción, desde la clase SyncAdapter, dentro de su método “procesarRespuestaInsert (JSONObject response, int idLocal)””, donde a la hora de comprobar el resultado del campo “mensaje”, incluí un tercer checkeo, para verificar si el resultado es un 3, y en ese caso significa que ya existe el registro y por eso no se intentó siquiera isnertar en la externa, por lo que acto seguido finalizamos el proceso cambiando el campo estado por OK para que no intente sincronizar en el futuro. esto queda así (en el metodo para chekear “estado” dentro de “procesarRespuestaInsert” dentro de la clase “SyncAdapter”):

            switch (estado) {
            case Constantes.SUCCESS:
            Log.i(TAG, mensaje);
            int num = 0;
            num = finalizarActualizacion(idRemota, idLocal);
            if (num > 0) {
            Log.i(“UPDATE: “, “” + num);
            } else {
            Log.i(“NO UPDATE: “, “fallo” + num);
            }
            break;

            case Constantes.FAILED:
            Log.i(TAG, mensaje);
            break;
            case “3”:
            Log.i(TAG, “caso 3”);
            int num2 = 0;
            num2 = finalizarActualizacion(idRemota, idLocal);
            if (num2 > 0) {
            Log.i(“UPDATE: “, “” + num2);
            } else {
            Log.i(“NO UPDATE: “, “fallo” + num2);
            }
            }

            FIN.

            Con esto lo que he hecho en realidad no es sincronizar como tal, sino que verifico sie xiste el registro en la externa, y si es así le pongo su estado OK apra que no lo inserte o sincronice en el futuro (no borro nada).
            Espero te sirva de algo al menos. Saludos.

          • San

            en lo de “insertar_gastos.php” puedes olvidar la linea que creé que dice:

            $existe2 = array (“estado” => 4, “mensaje” => ‘Ya existe2’, “idUsuario” => 2);

            fué mientras probaba pero no es invocado ni sirve de nada ya =P nada mas.

          • Johan Ls

            Wuao genial lo que realizaste… pero mi duda y espero q puedas ayudarme era referente a como poder borrar un registro desde la bd sqlite y luego poder sincronizar con la bd externa…
            Seria genial una manito con eso

          • San

            Entiendo, para borrar un registro ciertamente debes invocar el método “delete” del provider, pero debes indicar sobré qué registro actuar, en este caso, debes indicar qué registro quieres borrar.

            Si te fijas, el constructor que construye un delete tiene 3 parámetros de entrada que debes indicarle:

            “delete (Uri url, String where, String[] SelectionArgs)”.

            Por lo que imagino que en vez de null en el segundo parámetro, deberás indicar qué columna borrar. Supongo que en tu caso sería algo así:

            —–
            Uri uri = ContractParaGastos.CONTENT_URI;
            String where = “ID = X”
            context.getContentResolver().delete(uri,where,null);
            ——
            (Donde X es el número de la columna a borrar).

          • San

            Bueno el Uri si que debe contener la ruta completa de la columna concreta sobre la que va a actuar, imagino que está bien como tú la tienes.

            Y luego automáticamente, el mismo método “delete” tiene su línea al final que dice:

            resolver.notifyChange(uri, null, false);

            La cual se encarga de notificar que hubo cambios en el provider, para que sincronize automáticamente.

            Pero sino, pues creo que puedes invocar al método SyncAdapter.sincronizarAhora(getContext, true) para que despues del delete haga la sincronizacion. Espero te sirva saludos.

          • San

            Y como dato final me aseguré de que no existe el campo _ID 1 ni 2 ni 3 en la bd externa, ya que uso esos valores para tratar el resultado de regreso. un saludo.

        • San

          en realidad a groso modo, lo que pretendo es que al insertar datos que ya existen en la externa, que me actualize la local, en vez de salirme el error de “imposible insertar” =P

        • san

          Ya lo tengo, creé el método getById() en mi usuarios.php (en el tuto sería Gastos.php), de esta manera:

          public static function getById($id)
          {
          $consulta = “SELECT ” . self::identificador . ” FROM ” . self::TABLE_NAME . ” WHERE ” . self::identificador . ” = ” . $id;
          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;
          }
          }

          Y luego dentro de su método insertRow($object) he colocado las siguientes líneas justo al principio del método:

          $IDEN= $object[self::identificador];
          $usuario = self::getById($IDEN);
          if ($usuario==0) {} else {return 1;}

          Con esto consigo verificar si ya existe el ID en la externa justo antes de disponerse a insertar, y luego sie xiste lo borro de la cola del content. SOLVED =D He aprendido y aprendo mucho con tus tutoriales, gracias.

    • San

      Para quien sirva, ya corregí mi error. Estaba en el archivo “insertar_usuario.php” que es como se llama en mi caso, en el tutorial es el “insertar_gastos.php”. Lo único que he modificado fue el final, el resultado que es una rray en formato json_encode, no entiendo mucho de php pero me costó localizarlo y arreglaro, por lo visto no cogía bien las constantes declaradas, por lo que las isnerté manualmente, tal que así:

      1, “mensaje” => ‘Creacion exitosa’, “idUsuario” => $idUsuario);
      $mal = array (“estado” => 2, “mensaje” => ‘Creacion fallida’, “valor retornado: ” => $idUsuario);

      if ($idUsuario > 0) {
      // Código de éxito
      print json_encode($bien);
      } else {
      // Código de falla
      print json_encode($mal);
      }
      }
      ?>

      Me funciona a la perfección =D mil gracias.

      • San

        Salió mal, pruebo de nuevo:

        if ($_SERVER[‘REQUEST_METHOD’] == ‘POST’) {

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

        // Insertar usuario
        $idUsuario = (int) Usuarios::insertRow($body);

        $bien = array (“estado” => 1, “mensaje” => ‘Creacion exitosa’, “idUsuario” => $idUsuario);
        $mal = array (“estado” => 2, “mensaje” => ‘Creacion fallida’, “valor retornado: ” => $idUsuario);

        if ($idUsuario > 0) {
        // Código de éxito
        print json_encode($bien);
        } else {
        // Código de falla
        print json_encode($mal);
        }
        }

        ahora.

  • Johan Ls

    Que buen Tutorial amigo James te felicito por tu ayuda, soy novato en estos temas tengo una pregunta como hago para borrar un registro, pensé hacerlo dándole un onclick desde ViewHolder de esta manera:
    @Override
    public void onClick(View v) {
    Uri uri = ContentUris.withAppendedId(ContractParaGastos.CONTENT_URI, id);
    context.getContentResolver().delete(
    uri,
    null,
    null
    );
    pero no se como obtener el “id”, ayudaaa…

    • Johan Ls

      long id = cursor.getLong(cursor.getColumnIndex(ContractParaGastos.Columnas._ID));
      El problema es que cuando lo elimina no se actualiza el recyclerview y se vuelve a posicionar con un nuevo id…
      si alguien sabe como hacer para borrar y modificar seria un buen aporte…

  • Mikemir

    Hola James! Primeramente felicidades por tan buenos tutos. Una duda, te explico tengo una app diseñada para trabajar con o sin conexión a internet el app trabaja con datos que descarga una ves por medio de un servicio mobile de azure (Tres lista de una entidad creada por mi) y se guardan mediante serialización las listas. Ya al haber descargado las listas el usuario puede editar los items, moverlas de un lista a otra, entre otras cosas… si al editar el item o moverlo hay conexion a internet consumo un servicio azure si no cambio o edito el elemento en lista y lo serializo guardandolo con una propiedad booleana para saber q no se sincronizó en la nube.

    Ahora bien necesito usar un servicio o algo para que en segundo plano los item no sincronizados sean sincronizados consumiendo el servicio de azure y luego de hacerlo actualice el item cambiandole la propiedad para que no se actualice de nuevo. Ya intente usando TimerTask pero luego de actualizar no puedo tener acceso a la lista para actualizar el valor booleano (o al menos no se como) luego de leer tus articulos se me ocurre usar un servicio pero no se si usar un Service o un IntentService. ¿Que me recomiendas usar? ¿Conoces un metodo más facil para poder hacer lo que necesito?

    • Usa IntentService para separar en otro hilo la sincronización.

      ¿Estas usando sync adapters?

  • cristian eugenio ferrazzano

    Hola que tal? Quisiera saber si para hacer la sincronización con la BD puede utilizarse GCM en el caso de tener muchos clientes. Pregunto esto ya que Le desconfío un poco a GCM para el manejo de una sincronización exhaustiva de tipo push. Estoy dudando que arquitectura usar, quisiera me recomienden un poco.
    La idea en definitiva de la actualización de la base de datos es para lo siguiente:
    Tener una BD SQLite en el celular para no tener que conectarme siempre al servidor. El tema es que la información que esta en el servidor (en este caso seria para las sucursales de un local) puede ser cambiada y en ese caso debería sincronizarla con la del cliente.
    Además el servidor debe enviar por evento, cada aproximandamente 2 minutos información al cliente sobre el estado de su transacción. Para esto ultimo, tampoco se si usar GCM o el metodo aquí nombrado.
    Desde ya muchas gracias y espero puedan ayudarme

    • Hola Cristian.

      GCM permite notificar a los clientes cuando el servidor ha cambiado los datos. Creo que si te podría funcionar. Esto ahorra bateria del dispositivo, ya que no monitorea para saber que sucede en el servidor.

      • cristian eugenio ferrazzano

        Muchas gracias por tu respuesta James. Valoro mucho poder aprender de vos a traves de esta página y además también poder recibir respuestas puntales. De verdad muchas gracias

        • Gracias por tu apreciación Cristian. Me gusta que la página pueda ayudar a varias personas.

          Saludos!

  • Leonel

    Como puedo saber si hay conexión con el servidor?

    • Hola compañero.

      ¿Que tal si pruebas con el método getResponseCode() del cliente?

      Con ello puedes determinar que está sucediendo con la conexión.

  • Salomon Quinteros

    si estoy trabajando en eclipse cambia en alguna parte

  • Salomon Quinteros

    hola, en caso de que mi app tenga que login debo crear otra tabla en la base de datos

    • Hola Salomon.

      Eclipse exige que implementes las librerías de otra forma. Tal vez eso te de problemas.

      El login puedes manejarlo con el account manager. La tabla la necesitas es en tu servicio web, eso ya depende del estilo de login que uses para autenticar.

  • David Sepúlveda

    Hola, tengo una pregunta.. Si tuviera que sincronizar N tablas en lugar de una sola como en el ejemplo, debería crear una clase SyncAdapter por cada una de ellas o basta una? Gracias por el tutorial, está muy bueno!

    • Hola David.

      Solo debes crear un solos SyncAdapter. El ContentProvider debe contener todo el esquema de la base de datos, es decir, todas las tablas.

      • San

        Por favor, alguien podría explicar al menos brevemente como hacerlo? Por ejemplo al tener 2 tablas en el mismo SyncAdapter.

        No sé como desviar los métodos del SyncAdapter dependiendo de cuál tabla se trate. Y entonces siempre me actúa sobre la primera tabla, los método actualización() u obtenerRegistros() actúan sobre la primera tabla siempre… GRACIAS.

        • Hola San.

          Estoy escribiendo un ebook para explicar más a fondo sobre la sincronización. Espero esto pueda dar mas luces.

          • San

            MIL GRACIAS!! =) estaré atento, je.

          • Harold Rengifo

            Estoy siguiendo el tutorial, todo va muy bien hasta que necesitamos sincronizar varias tablas al tiempo, por favor danos una pequeña explicación de como hacerlo.
            Muchas gracias.

          • Hola Harold.

            Quiero hacer un ejemplo de esto, sin embargo me gustaría explicar varios temas prerequisitos antes.

          • Alejandro Guzmán

            Hola James, tendrás algún ejemplo de múltiples tablas, he avanzado algo pero creo que estoy bloqueado

          • Hola Alejandro. La sincronización de múltiples tablas aún no esta, pero te dejo este artículo para crear el content provider con varias tablas:

            http://www.hermosaprogramacion.com/2016/01/contentprovider-multiples-tablas-android/

            A lo mejor descubras algunas luz con él

        • Juan Enrique

          Hola podria sincronizar varias tablas con la misma estructura, solo seria necesario agregar las clases en el provider? o como? alguien podria explicarme como?

          SALUDOS

    • Diego Tobar

      Hace un tiempo creamos un sistema para una empresa financiera que requeria trabajar ambientes desconectectado asi que utilizamos Sync Adapter, la base de datos tenia mas de 200 tablas asi que optamos por omititr el uso del Content Provider y trabajamos directamente con la base de datos y un solo Sync Adapter

      • Julian

        Buenas noches Diego, disculpa me interesa un poco más conocer tu metódo ya que el usar el contentProvider lo veo un poco tedioso al usar varias tablas por lo que te pido de la manera más atenta tu apoyo ya que comprendo bien el uso de la bd pero no el del sync adapter al realizar los procesos del servidor al cliente y viceversa de antemano gracias

      • Julian

        Buenas noches Diego, disculpa me interesa un poco más conocer tu metódo ya que el usar el contentProvider lo veo un poco tedioso al usar varias tablas por lo que te pido de la manera más atenta tu apoyo ya que comprendo bien el uso de la bd pero no el del sync adapter al realizar los procesos del servidor al cliente y viceversa de antemano gracias

    • Luis Ernesto Alcantara

      Ya encontre una solicion sencilla para sincronizar varias tablas, create una clase abstracta donde implementes los metodos generales y para cada subclase implementas el algorimo….. lugo en el onPerfomeSy… llamas a cada una de la funciones…

      synTablaParadas.realizarSincronizacionLocal(syncResult, getContext());
      syncTablaRuta.realizarSincronizacionLocal(syncResult, getContext());
      synTablaAsistente.realizarSincronizacionLocal(syncResult, getContext());
      synA_usuario.realizarSincronizacionLocal(syncResult, getContext());

      • Alejandro Guzmán

        Que tal Luis, tendrás un ejemplo de la solución que comentas, creo que llevo bastante avance pero estoy atorado.

        Agradezco tu apoyo

        • Luis Ernesto Alcantara

          Create una clase abstracta donde pongas las operaciones de peticion get al servidor etc… asi no hace falta que este implementando metodos que hacen lo mismo una vez hecho create una subclase para cada tabla donde el implementes el algoritmo…

    • Luis Ernesto Alcantara

      Ya encontre una solicion sencilla para sincronizar varias tablas, create una clase abstracta donde implementes los metodos generales y para cada subclase implementas el algorimo….. lugo en el onPerfomeSy… llamas a cada una de la funciones…

      synTablaParadas.realizarSincronizacionLocal(syncResult, getContext());
      syncTablaRuta.realizarSincronizacionLocal(syncResult, getContext());
      synTablaAsistente.realizarSincronizacionLocal(syncResult, getContext());
      synA_usuario.realizarSincronizacionLocal(syncResult, getContext());

  • Luis Muñoz Munguia

    Muy bueno el tutorial y gracias por compartirlo, una pregunta, como haría para subir la base de datos a internet y poder conectarme a ella?. Saludos.

    • Hola Luis.

      ¿Te refieres alojar la base de datos en un servidor?

      Puedes contratar algún servicio de almacenamiento como Google Cloud

  • Daniel Lara

    Hola a todos, estoy empezando a programar u viendo este tutorial me peegunto en donde debo de poner los archivos php
    Gracias

    • Hola Daniel.

      Si ya instalaste XAMPP, debes ponerlos en la carpeta htdocs.

  • Gabriel

    Amigo buen tutorial, pero no se si me puedes dar un consejo o algo por el estilo, pero quiero hacer una aplicacion de ingles y quiero crear la base de datos desde sql que es desde java porsupuesto, pero despues mandar a traer todos esos registros como le podria hacer.
    Gracias

  • Kenny Baltazar Alanoca

    Saludos, buen ejemplo de sincronización de registros de ambas bases de datos, pero ¿no cree que al retornar un JSON con un “SELECT * FROM” consumira demasiados recursos (Transferencia de Datos) de parte del cliente ? . Ahora se ve que es rápido porque solo hay unos cuantos registros, pero cuando hablemos de miles de registros no creo que sea tan optimo.

    • Hola Kenny.

      Claro, tienes razón, la tarea de optimizar ya queda en manos de cada programador. Como ves, este es un ejemplo corto para explicar de los sync adapters.

      Saludos!

    • Gilberto Ibarra

      Por seguridad, nunca se usa select * en una consulta. …

  • Enmanuel

    Exelente tutorial Amigo ,como lo puedo implementar utilizando (GCM) , seria mucho mas facil asi que hacerlo manual

    • Gracias Enmanuel.

      Claro que es mucho más fácil, sin embargo requiere una explicación a parte. Espero poder escribir un artículo sobre ello pronto

      • Enmanuel

        Gracias James

  • Gon Her

    Que tutorial! Si señor!. Muchas gracias por esta exelencia James amigo. Estoy a la espera de algo y ya me pongo manos a la aobra en este articulo. Abrazo.

    • Gracias Gon.

      Espero sea de utilidad. Si encuentras algún error me lo haces saber.

  • Muchas Gracias por el Material, tengo que hacer algo como esto para androides de 2.1 en adelante, lo veo dficil ya que no soy un experto. Gracias nuevamente por cumplir con los acordado en el post anterior de Sqlite y MySQL.

    • Bueno, antes de iniciar la explicación pongo una lista de links que debes tener claros antes de intentar comprender todos los pasos.