Préstamos de libros: Interacción con API Contactos

Nuestro objetivo

Vamos a integrar la lista de contactos de la aplicación con la API ContentProvider que permite utilizar la lista de contactos del dispositivo.
Es importante
  • ver los contactos que hay
  • saber si un contacto existe
  • crear nuevos contactos
  • eliminar los contactos
mmm... una interfaz típica de un home. Definimos una interfaz RepoContactos con una implementación PhoneBasedRepoContactos.

APIs de Android

Para manejar los contactos necesitamos conocer los servicios que ofrece Android en su VM

ContentProvider

El ContentProvider es el equivalente a un Home/Repo con operaciones atómicas (insert, update, delete y query). La buena noticia es que no hay que liberar recursos porque la conexión es local a la actividad. Tenemos varios tipos de ContentProviders, para diferentes usos
  • contactos, la que vamos a usar
  • el diccionario de palabras que usa el corrector ortográfico,
  • el calendario de eventos,
  • la configuración,
  • el repo donde están los archivos multimedia como videos o fotos, etc.

ContentResolver

Es el cliente que accede a ese ContentProvider, con el que podemos disparar consultas y actualizaciones.

Vista

En la vista principal MainActivity llamamos a PrestamosBootstrap que le pide a un repo que incorpore contactos si no existen...

  public static void initialize(MainActivity activity) {
        /**
         * inicializamos la información de la aplicación
         */
        RepoContactos repoContactos = new PhoneBasedContactos(activity);
        repoContactos.addContactoSiNoExiste(
                new Contacto("1", "46425829", "Chiara Dodino", "kiki.dodain@gmail.com", 
                     ImageUtil.convertToImage(activity, "kiarush.png")));

Estructura de un Contact
Antes de pasar al Home es importante entender cómo es la estructura de los contactos de Android:
Un contact identifica a una persona, que puede tener diferentes formas de ser contactada. Cada una de estas formas es lo que se guarda en el "Raw Contact" (amigo, compañero de facultad, compañero de trabajo, etc.) Esto permite agrupar a una persona en diferentes roles, sobre todo cuando sincronizamos los contactos desde diferentes orígenes:
  • Outlook del trabajo
  • Gmail
  • otro teléfono
  • etc.
A su vez cada raw contact tiene n datos (representado por la entidad Data), donde el formato varía en base a la información que se almacena:
  • un número de teléfono de la casa o móvil
  • un mail corporativo de trabajo o de contacto
  • una foto
  • una cuenta de facebook
  • una cuenta de twitter
Cada uno de estos datos representa un registro en la entidad Data, incluso podemos tener muchos teléfonos, o cuentas de twitter asociadas al mismo raw contact.

Creando un contacto

Ahora que sabemos cómo es la estructura de los contactos vamos al Home, en el método addContacto recibimos un objeto Contacto (del dominio de nuestra aplicación) y creamos un Contact de Android.

public class PhoneBasedContactos implements RepoContactos {

    /**
     * actividad (página) madre que permite hacer consultas sobre los contactos
     */
    Activity parentActivity;

    public PhoneBasedContactos(Activity parentActivity) {
        this.parentActivity = parentActivity;
    }

El repositorio no es del todo “transparente”, ya que necesita una Activity para tener una referencia al content resolver que le permite consumir la API de contactos. Entonces hay un alto acoplamiento entre los concerns de UI y estos orígenes de datos.

Vemos cómo se crea un contacto, generando un raw contact y varios data, por cada tipo de información que tiene el contacto:

    @Override
    public void addContacto(Contacto contacto) {
        String tipoCuenta = null;
        String nombreCuenta = null;

        /** CON BUILDERS */
        ArrayList<ContentProviderOperation> comandosAgregar = new ArrayList<ContentProviderOperation>();
        // Malisimo que obligue a definirlo como ArrayList
        comandosAgregar.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
                .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, tipoCuenta)
                .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, nombreCuenta)
                .build()
        );
        comandosAgregar.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
                .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
                .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, contacto.getNombre())
                .build()
        );
        comandosAgregar.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
                .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
                .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, contacto.getNumero())
                .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_HOME)
                .build()
        );
        comandosAgregar.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
                .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
                .withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, contacto.getEmail())
                .build()
        );
        comandosAgregar.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
                .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
                .withValue(ContactsContract.CommonDataKinds.Photo.PHOTO, contacto.getFoto())
                .build()
        );
        try {
            parentActivity.getContentResolver().applyBatch(ContactsContract.AUTHORITY, comandosAgregar);
        } catch (Exception e) {
            throw new ProgramException("No se pudo generar la información del contacto " + contacto.getNombre(), e);
        }
    }

Como curiosidad para el lector, se trabaja con builders agrupando una serie de comandos que se ejecutan todos en una misma transacción. Otra opción es agregar la información de cada contacto en forma individual, pero tiene cierta degradación de performance.

Contact vs. Contacto

Nuestro objeto de dominio Contacto puede o no incorporar la información del contacto que provee Android. Esto nos permite que también funcione como Adapter, no necesitamos conocer internamente la API para saber cómo se accede a la estructura interna de un Contact (que dicho sea de paso es compleja). Por otro lado hay una idea duplicada al leer el Contact y convertirlo a nuestra clase Contacto. Es una decisión de diseño que hay que tomar.

Para más información puede verse

Buscando un contacto

Queremos hacer un search by example, por número de teléfono o bien por nombre, aquí vemos cómo esa complejidad se esconde en el home a favor de la Activity que envía el mensaje getContacto utilizando como parámetro la abstracción Contacto. Si quiere buscar por número, pasa un contacto con un número específico, si quiere buscar por nombre, guarda en la propiedad nombre del contacto el valor a buscar:

    @Override
    public Contacto getContacto(Contacto contactoOrigen) {
        Uri lookupUri = null;
        if (contactoOrigen.getNumero() != null) {
            lookupUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, 
                  Uri.encode(contactoOrigen.getNumero()));
        } else {
            lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_FILTER_URI, contactoOrigen.getNombre());
        }

        Cursor cursorContactos = parentActivity.getContentResolver().query(lookupUri, null, null, null, null);
        if (cursorContactos == null || cursorContactos.getCount() < 1) {
            return null;
        }

        cursorContactos.moveToFirst();
        Contacto contacto = this.crearContacto(cursorContactos);
        cursorContactos.close();
        cursorContactos = null;
        return contacto;
    }

La búsqueda por número o nombre se hace a distintos paths dentro de una Uri de contactos. Lo que devuelve esa búsqueda es un Cursor, cuya interfaz podemos estudiar en http://developer.android.com/reference/android/database/Cursor.html

Vemos el método que adapta un Contact a un objeto Contacto:

    private Contacto crearContacto(Cursor cursorContactos) {
        String contactId = getDataAsString(cursorContactos, ContactsContract.Contacts._ID);
        String contactName = getDataAsString(cursorContactos, ContactsContract.Contacts.DISPLAY_NAME);
        String contactNumber = null;
        byte[] foto = null;
        String email = ""; // TODO: Agregarlo

        final ContentResolver contentResolver = parentActivity.getContentResolver();
        Cursor cursorTelefono = contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?", new String[]{contactId}, null);
        if (getDataAsString(cursorContactos, ContactsContract.Contacts.HAS_PHONE_NUMBER).equals("1")) {
            if (cursorTelefono.moveToNext()) {
                contactNumber = getDataAsString(cursorTelefono, ContactsContract.CommonDataKinds.Phone.NUMBER);
            }
        }
        Cursor cursorMail = contentResolver.query(ContactsContract.CommonDataKinds.Email.CONTENT_URI, null, ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = ?", new String[]{contactId}, null);
        if (cursorMail.moveToNext()) {
            email = getDataAsString(cursorMail, ContactsContract.CommonDataKinds.Email.ADDRESS);
        }
        Uri uriContacto = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, Long.parseLong(contactId));
        foto = ImageUtil.convertToImage(parentActivity, uriContacto);
        return new Contacto(contactId, contactNumber, contactName, email, foto);
    }

Algunas cosas que podemos ver:
  • mientras que la mayoría de los cursores tiene una interfaz secuencial para moverse entre los elementos (que denota una lista simple o doblemente enlazada, con métodos moveNext / movePrevious), la interfaz del cursor de Android es similar a una lista común, donde podemos acceder incluso por posición
abstract booleanmoveToFirst()
Move the cursor to the first row.
abstract booleanmoveToLast()
Move the cursor to the last row.
abstract booleanmoveToNext()
Move the cursor to the next row.
abstract booleanmoveToPosition(int position)
Move the cursor to an absolute position.
abstract booleanmoveToPrevious()
Move the cursor to the previous row
Por otra parte para obtener una determinada columna debemos tipar el resultado (con getString() llamando previamente al getColumnIndex). Evitamos repetir esta llamada una y otra vez mediante un método interno getDataAsString. 

No todos los contactos tienen teléfono, lo extraño es que para preguntar si el contacto tiene el teléfono cargado (atributo HAS_PHONE) en lugar de un boolean tenemos un string "0" false y "1" true, lo importante es que esta decisión esté encapsulada en el home y no esté diseminada por toda la aplicación

Llenar un dato de un contacto implica acceder a varios registros de la entidad DATA, igualmente el ContactsContract funciona de proxy o recepcionista y nunca sabemos que del otro lado estamos utilizando esa tabla DATA en SQLite, o de qué manera se accede a esas tablas (si son 3 queries por separado o es un único query que baja toda la información). Así como el Home devuelve un Contacto, también ContactsContract funciona como un mecanismo de abstracción.

Nosotros trabajamos contra el ContentProvider, y la librería SQLLite permanece oculta para nosotros.

Vemos un diagrama de clases de la solución:

La carga de fotos

Las fotos de los contactos están almacenadas en el directorio assets del proyecto, para que puedan ser importadas a los contactos del dispositivo, en la clase ImageUtil generamos un método convertToImage que el lector podrá ver bajándose el ejemplo.

Recordemos que en los proyectos Android en assets ubicamos imágenes, videos, pdfs, archivos en general que van a permanecer estables durante toda la aplicación (no vamos a alterar su contenido, cosa que sí sucede con los archivos ubicados en res, como los menúes, layouts, actividades y fragments).

Estrategia de carga inicial

La estrategia al iniciar la aplicación puede ser
  • borrar los contactos y volverlos a generar cada vez: el lector puede investigar el método eliminarContactos() en la clase PhoneBasedContactos para poder resolverlo de esta manera. Esta técnica sólo es útil si estamos trabajando con un emulador, en las primeras etapas, si estamos generando datos de prueba. Por supuesto que no es una opción si estamos desplegando la app en nuestro dispositivo móvil.
  • sólo crear los contactos que no existan la primera vez, que es la opción que elegimos.
Para implementar la creación del contacto sólo si no existe, mostramos el método addContactoSiNoExiste en PhoneBasedContactos:

    @Override
    public void addContactoSiNoExiste(Contacto contacto) {
        if (this.getContacto(contacto) == null) {
            this.addContacto(contacto);
        }
    }


Este método es el que invoca el PrestamosBootstrap para inicializar los datos.

Luego, generamos los préstamos buscando los contactos que ya sabemos que existen, a partir de objetos prototípicos (haciendo búsquedas by-example):

        Contacto fede = new Contacto(null, "47067261", null, null, null);
        Contacto orne = new Contacto(null, null, "Ornella Bordino", null, null);


        RepoPrestamos repoPrestamos = PrestamosConfig.getRepoPrestamos(activity);
        if (repoPrestamos.getPrestamosPendientes().isEmpty()) {
            repoPrestamos.addPrestamo(new Prestamo(1L, repoContactos.getContacto(fede), elAleph));
            repoPrestamos.addPrestamo(new Prestamo(3L, repoContactos.getContacto(orne), cartasMarcadas));
        }


Probando la aplicación


En versiones del SDK anteriores a la 23 en el AndroidManifest.xml del root de nuestro proyecto necesitábamos incorporar estas líneas:

<uses-permission android:name="android.permission.READ_CONTACTS" />

<uses-permission android:name="android.permission.WRITE_CONTACTS" />


Eso disparaba la pregunta al usuario para permitir que leamos y escribamos la base de contactos. A partir de la versión 23 tenemos que ahcerlo en forma programática: el lector puede ver este link para mayor información.

Ahora sí, levantamos la aplicación y vemos la lista de préstamos cuyo contacto está asociado al dispositivo:
 
Para verificar que los contactos efectivamente son los del dispositivo, vamos a presionar el botón Home del emulador... y abrimos la aplicación de contactos:
el ícono puede variar en base al dispositivo y versión del sistema operativo que se elija

... vemos los contactos que acabamos de generar. Incluso podemos generar un nuevo contacto y prestarle un libro desde nuestra app.

La ventaja: aprovechamos la aplicación de contactos...

Video recomendado

http://www.youtube.com/watch?v=1iTCBEY5d6c