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