Problemas Guava + Proguard

Para los que vieron los post sobre usar Google App Engine Endpoints para un App en Android (sí, yo sé que debo la parte final) a la hora de generar un APK firmado, puede que se topen con una excepción con Proguard. Esto pasa por culpa de una de las librerías, en este caso Guava.

Los warnings que tira Proguard son respecto a dos clases: sun.misc.Unsafe y com.google.common.collect.MinMaxPriorityQueue

Esto se arregla agregando las siguientes directivas en su archivo de proguard:

-dontwarn sun.misc.Unsafe
-dontwarn com.google.common.collect.MinMaxPriorityQueue

Parte 2: Android + AppEngine + Cloud Datastore

En la parte 1 se preparó una aplicación Android básica. Además se generó una aplicación web como backend con un endpoint para usuario. En esta segunda parte se terminará el endpoint de salvar notas (de manera muy breve) y se pasará a integra la aplicación Android con los dos endpoints.

La Parte 1 esta aqui.

Endpoint de Notas

Siguiendo los pasos de la creación del endpoint User, se creará el endpoint para salvar notas. Así, se creara la clase Note

public class Note {

    private Long mId;
    private String mContent;
    private Long mUserId;
    private Date mDate;

    public Long getId() {
        return mId;
    }

    public void setId(Long id) {
        mId = id;
    }

    public String getContent() {
        return mContent;
    }

    public void setContent(String content) {
        mContent = content;
    }

    public Long getUserId() {
        return mUserId;
    }

    public void setUserId(Long userId) {
        mUserId = userId;
    }

    public Date getDate() {
        return mDate;
    }

    public void setDate(Date date) {
        mDate = date;
    }
}

Además se creará la clase NoteEndpoint que recibirá las solicitudes de manipulación de notas, similar a usuario, obtener una nota, insertar nota y listar notas.

/** An endpoint class we are exposing */
@Api(name = "noteEndpoint", version = "v1", namespace = @ApiNamespace(ownerDomain = "savenotes.backend.fr4gus.com", ownerName = "savenotes.backend.fr4gus.com", packagePath=""))
public class NoteEndpoint {

    // TODO in memory implementation to test endpoints
    static Map<Long, Note> mLocalNotes = new HashMap<Long, Note>();
    static long mLastId = 0;

    private static final Logger LOG = Logger.getLogger(NoteEndpoint.class.getName());

    /**
     * This method gets the <code>Note</code> object associated with the specified <code>id</code>.
     * @param id The id of the object to be returned.
     * @return The <code>Note</code> associated with <code>id</code>.
     */
    @ApiMethod(name = "getNote")
    public Note getNote(@Named("id") Long id) {
        LOG.info("Calling getNote method");
        return mLocalNotes.get(id);
    }

    /**
     * This inserts a new <code>Note</code> object.
     * @param note The object to be added.
     * @return The object to be added.
     */
    @ApiMethod(name = "insertNote")
    public Note insertNote(Note note) {
        LOG.info("Calling insertNote method");

        synchronized (mLocalNotes) {
            mLastId++;
            mLocalNotes.put(mLastId,note);
            note.setId(mLastId);
        }

        return note;
    }

    /**
     * This returns all stored <code>Note</code> objects stored in the service
     * @return a list of all notes
     */
    @ApiMethod(name = "listNotes", path = "note/list")
    public List<Note> listNotes() {
        ArrayList<Note> notes = new ArrayList<Note>();
        notes.addAll(mLocalNotes.values());
        return notes;
    }
}

Se actualiza web.xml para agregar la clase de NoteEnpoint

   <servlet>
        <servlet-name>SystemServiceServlet</servlet-name>
        <servlet-class>com.google.api.server.spi.SystemServiceServlet</servlet-class>
        <init-param>
            <param-name>services</param-name>
            <param-value>com.fr4gus.backend.savenotes.UserEndpoint, com.fr4gus.backend.savenotes.NoteEndpoint</param-value>
        </init-param>
    </servlet>

Integración Android + GAE

Antes de poder utilizar las clases de la librería cliente para los endpoints. Es necesario primero agregar dependencias a nuestro proyecto. En este link, se muestra los pasos necesarios para agregar las dependencias.

También es posible obtener los pasos, al crear las librerías cliente, ya que vienen con una documentación. Aquí tienen un resumen de los cambios:

En build.gradle

repositories {
    mavenCentral()
    mavenLocal() // Agregar el repo local de maven
}

dependencies {
    compile ([group: 'com.google.api-client', name: 'google-api-client-android', version: '1.18.0-rc'])
    // Si quieren usar Gson pongan este
    compile ([group: 'com.google.api-client', name: 'google-http-client-gson', version: '1.18.0-rc'])

    // Si quieren usar Jackson pongan este
    compile ([group: 'com.google.api-client', name: 'google-http-client-jackson2', version: '1.18.0-rc'])

}

Si no quieren usar ni Gson ni Jackson, pueden importar AndroidJsonFactory si su proyecto tienen un API level mínimo de 11, importando: import com.google.api.client.extensions.android.json.AndroidJsonFactory;

Se recomienda ver la documentación, ya que esta cuenta con información de como agregar esas dependencias de manera más óptima, eliminando ciertos paquetes que no se ocupan.

Se creó una clase Application para poder guardar el usuario actual (usando el patrón Singleton)

public class SaveNotesApplication extends Application {
    static SaveNotesApplication sInstance;

    User mCurrentUser;

    @Override
    public void onCreate() {
        super.onCreate();
        sInstance = this;
    }

    public static SaveNotesApplication getInstance() {
        return sInstance;
    }

    public User getCurrentUser() {
        return mCurrentUser;
    }

    public void setCurrentUser(User currentUser) {
        mCurrentUser = currentUser;
    }
}

Con esto podemos acceder al usuario actual desde cualquier parte de la aplicación. Importante recordar que se ocupa actualizar el archivo AndroidManifest.xml en el tag <application> y agregar el atributo android:name con el valor .SaveNotesApplication.

Antes de poder enviar la nota se ocupa obtener el usuario primero. Para ello se creará una actividad padre, para que todas las actividades que hereden de ella puedan acceder al usuario o en su defecto, solicitarlo al servicio getUser si ya está registrado o insertUser si no ha sido registrado aún.

public class BaseActivity extends ActionBarActivity {
    public static final String TAG = "SaveNotes";

    public static final String APP_NAME = SaveNotesApplication.getInstance().getString(R.string.app_name);
    public static final String TEST_ROOT_URL = "http://192.168.1.140:8080/_ah/api";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // DeviceUtil.getDeviceID simplemente llama a Settings.Secure.getString(context.getContentResolver(),Settings.Secure.ANDROID_ID) 
        final String deviceId = DeviceUtil.getDeviceId(this);

        User currentUser = SaveNotesApplication.getInstance().getCurrentUser();
        if (currentUser == null) {
            new AsyncTask<String,Void,Boolean>(){
                @Override
                protected Boolean doInBackground(String... params) {
                    UserEndpoint.Builder userBuilder = new UserEndpoint.Builder(AndroidHttp.newCompatibleTransport(), new AndroidJsonFactory(),null);
                    userBuilder.setApplicationName(APP_NAME);
                    userBuilder.setRootUrl(TEST_ROOT_URL);
                    userBuilder..setGoogleClientRequestInitializer(new GoogleClientRequestInitializer() {
                        @Override
                        public void initialize(AbstractGoogleClientRequest<?> abstractGoogleClientRequest) throws IOException {
                            abstractGoogleClientRequest.setDisableGZipContent(true);
                        }
                    });
                    UserEndpoint userService = userBuilder.build();

                    try {
                        User user = userService.findUser(deviceId).execute();
                        SaveNotesApplication.getInstance().setCurrentUser(user);
                    } catch (IOException e) {
                        Log.e(TAG,e.getMessage(),e);
                        return false;
                    }

                    return true;
                }
            }.execute(deviceId);
        }
    }
}

En la línea 18 se nota la utilización del patrón Builder, con el cual se “configura” la instancia para acceder al endpoint de usuario.

En la línea 19 se establece el nombre de la aplicación. Este valor va como una cabecera en el request http, y permite mantener estadisticas de que tipo de cliente está utilizando los endpoints. Esto es importante cuando se tiene una version web u en otra plataforma como iOS del mismo cliente.

En la línea 20 se configura, para efectos de pruebas locales, la dirección del equipo donde se correrá una version local del servicio de App Engine. Esto es útil para las pruebas locales, pues por defecto intentará contactar a los servicios hospedados en la infraestructura de Google. Es de suma importancia aclarar que por defecto, el servidor se levanta utilizando “localhost” (127.0.0.1) por lo que solo escuchará request del mismo loop. Como vamos a querer acceder al servicio local desde otros equipos desde la misma red, es necesario modificar el build.gradle del módulo de backend para que escuche solicitudes de otros equipos. Esto se hace de manera sencilla agregando un valor nuevo al enclosure “appengine”. Se debe agregar httpAddress = ‘0.0.0.0’ por lo que el enclosure se verá similar a este:

appengine {
  httpAddress = '0.0.0.0'
  downloadSdk = true
  appcfg {
    oauth2 = true
  }
  endpoints {
    getClientLibsOnBuild = true
    getDiscoveryDocsOnBuild = true
  }
}

De la línea 21 a la 26, solo para pruebas locales, se configura el GoogleClientRequest, para deshabilitar la compresión de las solicitudes usando GZip. Esto por que la instancia local no maneja GZip y si ejecutan el cliente sin eso, les va a dar un EOFException en el servidor.

Si notaron, hay un método nuevo llamado UserEndpoint.findUser(String s). Este metodo se agregó para poder buscar el usuario con el deviceId, y así ver si el dispositivo está registrado. Si no está registrado, lo registra inmediatamente, ahorrando un llamado extra al endpoint.

Ya con el usuario disponible, se guarda en la clase Application y se puede continuar con el flujo normal. Si el usuario ya se encontraba asignado en la aplicación, simplemente no se llama al servicio.

A la clase NoteActivity hay que actualizarle el método OnClickListener para que llame al servicio de insertar notas. Nótese que se configura el Builder del endpoint de manera similar al de Usuario.

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.action_save) {
            String noteContent = mNoteContent.getText().toString();

            // Do nothing if content is empty
            if (TextUtils.isEmpty(noteContent))
                return;

            new AsyncTask<String, Void, Boolean>() {
                @Override
                protected Boolean doInBackground(String... params) {
                    String content = params[0];

                    NoteEndpoint.Builder noteBuilder = new NoteEndpoint.Builder(AndroidHttp.newCompatibleTransport(), new AndroidJsonFactory(), null);

                    noteBuilder.setApplicationName(APP_NAME);

                    //TODO Use only for local test. Remove for production
                    noteBuilder.setRootUrl(TEST_ROOT_URL);
                    noteBuilder.setGoogleClientRequestInitializer(new GoogleClientRequestInitializer() {
                        @Override
                        public void initialize(AbstractGoogleClientRequest<?> abstractGoogleClientRequest) throws IOException {
                            abstractGoogleClientRequest.setDisableGZipContent(true);
                        }
                    });

                    NoteEndpoint noteService = noteBuilder.build();

                    Note note = new Note();
                    note.setContent(content);

                    User currentUser = SaveNotesApplication.getInstance().getCurrentUser();
                    note.setUserId(currentUser.getId());
                    try {
                        noteService.insertNote(note).execute();
                    } catch (IOException e) {
                        Log.e(TAG, e.getMessage(), e);
                        return false;
                    }
                    return true;
                }

                @Override
                protected void onPostExecute(Boolean result) {
                    if (result){
                        mNoteContent.setText("");
                    } else {
                        Toast.makeText(NotesActivity.this, R.string.error_send_note, Toast.LENGTH_SHORT).show();
                    }
                }
            }.execute(noteContent);

        }
    }

Así de simple se llaman a los endpoints de un API implementado con Google App Engine. Simplemente se utiliza el Builder para crear la instancia del cliete del endpoint y luego se llama al método, pasándole los parámetros necesarios y luego llamando al método execute. Esto último se debe hacer en un hilo aparte al del UI por que lo bloquearía, razón por la cual en este ejemplo se utilzó un AsyncTask.

Siguientes pasos

Si bien con esto se completaría la integración de una aplicación Android con un API implementado en Google App Engine, creo conveniente explicar en la tercera y última parte de éste tutorial, como utilizar Objectify para poder guardar los datos en el Datastore. Principalmente por que me parece interesante la librería que facilita enormemente el acceso al Datastore, y así cerrar el ejemplo con algo funcional.

Pueden acceder al código fuente del ejemplo completo en Github: https://github.com/fr4gus/SaveNotes

Parte 1: Android + AppEngine + Cloud Datastore

Introducción

La aplicaciones móviles van evolucionando y ahora más que nunca deben ofrecer valor agregado con la utilización de la nube o inclusive delegar a servicios externos tareas más complejas. Actualmente existen diversas opciones para proveer a las aplicaciones de poder computacional en la nube y en este tutorial se utilizarán las herramientas que Google facilita: Google App Engine y Cloud Datastore.

Google App Engine

Antes del boom de los servicios en “la nube”  los desarrolladores o las empresas requerían mantener su propia infraestructura. Ahora existen muchas compañias que ofrecen su propia infraestrucutra y proveen de diferentes frameworks para poder instalar aplicaciones sin preocuparse del como.

Google ofrece su infraestrucura a través de App Engine que es una plataforma como servicio (PaaS del inglés Platform as a Serviced) que permite construir aplicaciones y correrlas en dicha infraestrucutra. GAE se encuentra disponible en los sabores: Java, Python, PHP y Go. El servicio es gratuito hasta cierta “cuota”. Las cuotas se cuentan por “solicitudes por día”, y en el momento que su aplicación sobrepase dicha cuota, deberá pagar. Para más informacion sobre GAE puede consultar la documentación oficial (1).

Cloud Data

Google ofrece 3 servicios para almacenamiendo de datos. La primera es Cloud SQL que es prácticamente un MySQL pero hospedado en la infraestructura de Google. La segunda Google Storage que es más bien para almacenamiento de archivos (tipo Drive) y finalmente Cloud Datastore para almacenar datos no relacionales (NoSQL).

El propósito de este tutorial es condensar la información de diferentes fuentes y aglomerar los pasos necesarios para poder proveer de backed a una aplicación Android utilizando Google App Engine (GAE).

Requerimientos:

  • Conocimientos básicos de desarrollo de aplicaciones Android
  • Conocimientos básicos de desarrollo de aplicaciones JavaEE (Servlets, JSP).

Parte 1. Aplicación Android

Si ya cuenta con una aplicación Android y desea integrarla con GAE puede pasar a la parte 2 (si no tiene una aplicación web en GAE). Se creará una aplicación sencilla para ayudar con el ejemplo.

En este caso se hará una aplicación para guardar “notas” que no es más que un simple texto que luego podemos visualizar en la Web.

La actividad para guardar notas tendrá el siguiente layout:

<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"
                android:paddingLeft="@dimen/activity_horizontal_margin"
                android:paddingRight="@dimen/activity_horizontal_margin"
                android:paddingTop="@dimen/activity_vertical_margin"
                android:paddingBottom="@dimen/activity_vertical_margin"
                tools:context=".NotesActivity">

    <TextView
        android:id="@+id/add_note_description"
        android:text="@string/add_note_description"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <EditText
        android:id="@+id/note_content"
        android:layout_below="@+id/add_note_description"
        android:layout_width="match_parent"
        android:layout_above="@+id/action_save"
        android:layout_height="match_parent"/>

    <Button
        android:id="@+id/action_save"
        android:text="@string/add_note"
        android:layout_alignParentBottom="true"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
</RelativeLayout>

Y se verá de la siguiente manera:

Save Notes Layout

El código de la actividad se verá de momento de la siguiente manera:

import android.os.Bundle;
import android.support.v7.app.ActionBarActivity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;

public class NotesActivity extends ActionBarActivity implements View.OnClickListener {
    EditText mNoteContent;
    Button mSaveNote;

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

        mNoteContent = (EditText) findViewById(R.id.note_content);
        mSaveNote = (Button) findViewById(R.id.action_save);
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.action_save) {
            String noteContent = mNoteContent.getText().toString();

            // TODO call save on backend

            // TODO when save is finished clear the textview and show a toast message
        }
    }
}

Parte 2. Servicios en GAE

En Android Studio se debe agregar un nuevo módulo, que contendrá la implementación del backend de la aplicación. Simplemente vaya a File -> New Module y seleccione el módulo “App Engine Java Endpoints Module”. Si desea agregar Google Cloud Messaging, seleccione la opción anterior.

new_backend_module

Esto va a crear un nuevo módulo en el proyecto. Otro de los efectos es que el build.gradle del módulo de la aplicación de Android va a ser modificado con la siguiente línea:

 dependencies {
    ...
    compile project(path: ':backend', configuration: 'android-endpoints')
}

Esto significa que el módulo de Android (app en el ejemplo) va a poder utlizar al módulo de GAE (backend en el ejemplo) como si tuviera las librerías de cliente. Si estuvieran los módulos en proyectos separados o utilizando otra herramienta distinta de Android Studio o más bien sin gradle, se deben compilar las librerias clientes. Esto genera unos archivos zip, uno por cada endpoint registrado y el codigo fuente hay que agregarlo a nuestro proyecto de Android. ¿Tedioso verdad? Por eso es mejor tenerlo así en Android Studio.

A la fecha que se escribió este tutorial, había un pequeño problema con la plantilla de módulos de GAE en Android Studio. Yo tuve un problema luego de crear el módulo y es por un problema con el plugin de appengine. Si se fija en el build.gradle del módulo, vera un comentario con una nota sobre esto. Para resolverlo (bajar el sdk y las respectivas dependencias) simplemente deben correr en una terminal el siguiente comando en la raíz del proyecto: ./gradlew [modulename]:appengineRun. En este caso sería ./gradlew backend:appengineRun. Si no tienen el sdk de App Engine o las librarías, con este comando lo podrán obtenerlo.

El módulo GAE viene con un codigo tipo plantilla, para ejemplificar el uso del módulo. Vamos a crear nuestro ejemplo y explicar las anotaciones en conjunto para ahorra tiempo.

Agregar y listar

Vamos a hacer dos endpoints. Un endpoint para poder salvar notas y listarlas. Otro endpoint para registrar usuarios. Para simplificarlo un poco, vamos a hacerlo anónimo, y de auto registro utilizando un identificador único por dispositivo.

Para no hacer muy largo el ejemplo vamos a utilizar en nuestro App solo el de insertar, pero podemos usar el de listar para corroborar que los datos están siendo guardados utilizando el dashboard admin de la instancia en GAE.

Primero hay que crear las clases del modelo que van a ser los DTOs (Data Transfer Objects) y que representan la información que vamos a almacenar. En nuestro caso ocupamos dos. User y Note. User va a tener un id único en el sistema y el device_id. Note va a tener un string con el contenido de la nota, un id único, la fecha de creación y el id interno del usuario.

Para hacer las cosas fáciles Android Studio cuenta con un comando para crear endpoints. Nada más seleccionamos la clase a la cual queremos hacer el endpoint y le damos “Generate Endpoints“:

generate_endpoint

Esto generará una clase llamada <nombre-clase>Endpoint. Por ejemplo si lo hacemos para User, será UserEndpoint. Y se verá similar a esto:

/** An endpoint class we are exposing */
@Api(name = "userEndpoint", version = "v1", namespace = @ApiNamespace(ownerDomain = "savenotes.backend.fr4gus.com", ownerName = "savenotes.backend.fr4gus.com", packagePath=""))
public class UserEndpoint {

    // Make sure to add this endpoint to your web.xml file if this is a web application.

    private static final Logger LOG = Logger.getLogger(UserEndpoint.class.getName());

    /**
     * This method gets the <code>User</code> object associated with the specified <code>id</code>.
     * @param id The id of the object to be returned.
     * @return The <code>User</code> associated with <code>id</code>.
     */
    @ApiMethod(name = "getUser")
    public User getUser(@Named("id") Long id) {
        // Implement this function

        LOG.info("Calling getUser method");
        return null;
    }

    /**
     * This inserts a new <code>User</code> object.
     * @param user The object to be added.
     * @return The object to be added.
     */
    @ApiMethod(name = "insertUser")
    public User insertUser(User user) {
        // Implement this function

        LOG.info("Calling insertUser method");
        return user;
    }
}

Es importante verificar que el endpoint se encuentre registrado en el archivo web.xml que se encuentra por lo general aqui:

verify_web_xml

Se debe verificar que el endpoint se registró. Si no es así, agregarlo manualmente para hacerlo visible.

verify_endpoint

Para probar rápidamente el endpoint, vamos primero a crear un almacenamiento en memoria con un HashMap. En getUser vamos a hacer un get del map y en insertUser vamos a hacer put. Además vamos a agregar un nuevo método en el endpoint, para poder listar los uuarios registrados. La clase UserEndpoint tendrá las siguiente modificaciones.

1. Agregar el map para almacenamiento en memoria. Propiedades de la clase mLocalUsers y mLastId. La variable mLastId nos servira como id autoincremental de los usuarios.

    static Map<Long, User> mLocalUsers = new HashMap<Long, User>();
    static long mLastId = 0;

2. Modificar gerUser para obtener el usuario del map.

        User user = mLocalUsers.get(id);
        LOG.info("Returning user with id " + user.getDeviceId());

3. Modificar insertUser para guardar el user en el map.

        synchronized (mLocalUsers) {
            mLastId++;
            user.setId(mLastId);
            mLocalUsers.put(mLastId, user);

        }

4. Agregar el método listUser para listar los usuarios del map.

    @ApiMethod(name = "listUsers", path = "user/list")
    public List<User> listUser(){
        ArrayList<User> users = new ArrayList<User>();
        users.addAll( mLocalUsers.values());
        return users;
    }

Como notarán, listUser tiene un parámetro extra en la anotación. “path” esta redifiniendo la ruta por defecto para acceder este endpoint. Esto se debe a que sin él, getUser y listUser van a tener la misma firma a nivel de servicio. Van a ser endpoints con la misma ruta “user/” que por medio de GET van a traer objetos tipo User, por lo que va a crear un conflicto. Es por ello que es necesario modificar la ruta del list.

Si recuerdan la nota sobre el issue con el plugin de GAE y Android Studio, ejecutamos el commando ./gradlew backend:appengineRun. Este comando hace levantar la instancia local de GAE. Si acceden a http://localhost:8080/ les aparecera una página como la siguiente:

 gae_landing

Dentro de las opciones en el menú superior, hay uno que nos interesa que es el Google Cloud Endpoints API Explorer. Con este explorador pueden hacer llamados a los endpoints y probarlos directamente. Por ejemplo podemos meter un usuario:

add_user

Que corresponde al siguiente request y response.

add_user_request_response

Como ven, desarrollar para Google App Engine es relativamente sencillo. Recuerden que hay disponibles otros lenguajes si Java no es de su preferencia, pero como este tutorial está orientado para Android Devs, parece adecuado hacer la implementación en Java también.

Para la segunda parte se hará la integración con el app de Android y posteriormente como extra, como utilizar Google Objectify para acceder al Cloud Datastore de una manera sencilla.