Añadiendo fotografías en Mis Lugares

En este apartado seguiremos trabajando con el uso de intenciones aplicándolas a la aplicación Mis Lugares. En concreto permitiremos que el usuario pueda asignar fotografías a cada lugar utilizando ficheros almacenados en su dispositivo o la cámara.

Ejercicio: Añadiendo fotografías desde la galería

1.     En la clase VistaLugarActivity añade las siguientes constantes y atributos:

final static int RESULTADO_GALERIA = 2;
final static int RESULTADO_FOTO = 3;
private ImageView imageView;  
val RESULTADO_GALERIA = 2  //poner antes de la clase
val RESULTADO_FOTO = 3 

Desde la actividad VistaLugarActivity llamamos a diferentes actividades y algunas de ellas nos tienen que devolver información. En estos casos llamamos a la actividad con startActivityForResult() pasándole un código que identifica la llamada. Cuanto esta actividad termine se llamará al método  onActivityResult(), que nos indicará el mismo código usado en la llamada. Como vamos a hacerlo con  tres actividades diferentes, hemos creado tres constantes, con los respectivos códigos de respuesta. Actuando de esta forma conseguimos un código más legible.

2.     En Java, en el método onCreate() añade antes de actualizarVistas():

imageView = (ImageView) findViewById(R.id.foto);

3.     Busca en vista_lugar.xml el ImageView con descripción “logo galería” y añade el atributo:
 

android:onClick="ponerDeGaleria" 

4.     Añade la siguiente función en la actividad:

public void ponerDeGaleria(View view) {
   usoLugar.ponerDeGaleria(RESULTADO_GALERIA);
} 
fun ponerDeGaleria(view: View)= usoLugar.ponerDeGaleria(RESULTADO_GALERIA) 

5.     Añade el siguiente caso de uso a la clase CasosUsoLugares:

// FOTOGRAFÍAS
public void galeria(View view) {
    String action;
    if (android.os.Build.VERSION.SDK_INT >= 19) { // API 19 - Kitkat
        action = Intent.ACTION_OPEN_DOCUMENT;
    } else {
        action = Intent.ACTION_PICK;
    }
    Intent intent = new Intent(action, 
                           MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    intent.setType("image/*");
    actividad.startActivityForResult(intent, RESULTADO_GALERIA);
} 
// FOTOGRAFÍAS
fun galeria(view: View) {
   val action= if (android.os.Build.VERSION.SDK_INT >= 19) { // API 19 - Kitkat
      Intent.ACTION_OPEN_DOCUMENT
   } else {
      Intent.ACTION_PICK
   }
   val i = Intent(action,MediaStore.Images.Media.EXTERNAL_CONTENT_URI).apply {
      addCategory(Intent.CATEGORY_OPENABLE)
      type = "image/*"
   }
   startActivityForResult(i, RESULTADO_GALERIA)
} 

Este método crea una intención indicando que queremos seleccionar contenido. El contendo será proporcionado por el Content Provider MediaStore, además le indicamos que nos interesan imágenes del almacenamiento externo. Tipicamente se abrirá la aplicación galería de fotos (u otra similar).  Observa, que se usan dos acciones diferentes: ACTION_OPEN_DOCUMENT solo está disponible a partir del API 19, tiene la ventaja que no requiere que la aplicación pida permiso de lectura. Cuando el usuario selecciona un fichero el Content Provider, dará a nuestra aplicación permiso de lectura (o incluso de escritura) pero solo para el archivo solicitado. No se considera una acción peligrosa dado que es el usuario quien selecciona el archivo a compartir con la aplicación. Si se ejecuta en un API anterior al 19, tendremos que usar ACTION_PICK, que sí que requiere dar permisos de lectura en la memoria externa. Una vez concedido este permiso la aplicación podría aprovechar y leer otros ficheros sin la intervención directa del usuario. Como necesitamos una respuesta usamos startActivityForResult() con el código adecuado.

6.    Si la versión  del dispositivo es anterior al API 19, vamos a tener que pedir permiso para leer ficheros de la memoria externa. En AndroidManifest.xml añade dentro de la etiqueta <manifest …> </manifest> el siguiente código:

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

7.     En el método onActivityResult() añade la sección else if que se muestra:

if (requestCode == RESULTADO_EDITAR) {
   …
} else if (requestCode == RESULTADO_GALERIA) {
   if (resultCode == Activity.RESULT_OK) {
      usoLugar.ponerFoto(pos, data.getDataString(), foto);
   } else {
      Toast.makeText(this, "Foto no cargada",Toast.LENGTH_LONG).show();
   }
} 
if (requestCode == RESULTADO_EDITAR) {
   …
} else if (requestCode == RESULTADO_GALERIA) {
   if (resultCode == RESULT_OK) {
      usoLugar.ponerFoto(pos, data?.dataString ?: "", foto)
   } else {
      Toast.makeText(this, "Foto no cargada", Toast.LENGTH_LONG).show();
   }
}  

Comenzamos verificando que volvemos de la actividad lanzada por la intención anterior. Comprobamos que el usuario no ha cancelado la operación. En este caso, nos tiene que haber pasado en la intención de respuesta, data, una URI con la foto seleccionada. Esta URI puede ser del tipo file://…, content://…o http://… dependiendo de que aplicación haya resuelto esta intención. El siguiente paso consiste en modificar el contenido de la vista que muestra la foto, imageView, con esta URI. Lo hacemos en el método ponerFoto():

8.     Añade el siguiente CasosUsoLugar:

public void ponerFoto(int pos, String uri, ImageView imageView) {
   Lugar lugar = lugares.elemento(pos);  
   lugar.setFoto(uri);
   visualizarFoto(lugar, imageView);
}

public void visualizarFoto(Lugar lugar, ImageView imageView) {
   if (lugar.getFoto() != null && !lugar.getFoto().isEmpty()) {
      imageView.setImageURI(Uri.parse(lugar.getFoto()));
   } else {
      imageView.setImageBitmap(null);
   }
}  
fun ponerFoto(pos: Int, uri: String?, imageView: ImageView) {
   val lugar = lugares.elemento(pos)
   lugar.foto = uri ?: ""
   visualizarFoto(lugar, imageView)
}

fun visualizarFoto(lugar: Lugar, imageView: ImageView) {
   if (!(lugar.foto == null || lugar.foto.isEmpty())) {
      imageView.setImageURI(Uri.parse(uri))
   } else {
      imageView.setImageBitmap(null)
   }
} 

El primer método comienza obteniendo el lugar que corresponde al id para modificar la URI de su foto. Luego llama a visualizarFoto(). Este verifica que la URI que acabamos de asignar no está vacía. Si es así, la asigna al ImageView que nos han pasado para que la imagen se represente en pantalla. En caso contrario, se le asigna un Bitmap igual a null, que es equivalente a que no se represente ninguna imagen.

9.     Ya puedes ejecutar la aplicación. Si añades una fotografía a un lugar, esta se visualizará. Sin embargo, si vuelve a la lista de lugares y seleccionas el mismo lugar al que asignaste la fotografía, ésta ya no se representa. La razón es que no hemos visualizado la foto al crear la actividad.

10.  En el método actualizarVistas() añade la siguiente línea al final:

usoLugar.visualizarFoto(lugar, foto); 
11.  Verifica de nuevo el funcionamiento de la aplicación.
 

Ejercicio paso a paso: Añadiendo fotografías desde la cámara

1.     Añade los siguientes atributos a la clase VistaLugarActivity:

private Uri uriUltimaFoto; 
private lateinit var uriUltimaFoto: Uri 

Como veremos, necesitamos esta variable en dos métodos diferentes. Por lo tanto, la declaramos de forma global.

2.     Añade el siguiente método a la clase CasosUsoLugar:

public Uri tomarFoto(int codidoSolicitud) {
   try {
      Uri uriUltimaFoto;
      File file = File.createTempFile(
           "img_" + (System.currentTimeMillis()/ 1000), ".jpg" ,
           actividad.getExternalFilesDir(Environment.DIRECTORY_PICTURES));
      if (Build.VERSION.SDK_INT >= 24) {
         uriUltimaFoto = FileProvider.getUriForFile(
               actividad, "es.upv.jtomas.mislugares.fileProvider", file); 
      } else {
         uriUltimaFoto = Uri.fromFile(file);
      }
      Intent intent   = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
      intent.putExtra (MediaStore.EXTRA_OUTPUT, uriUltimaFoto);
      actividad.startActivityForResult(intent, codidoSolicitud);
      return uriUltimaFoto;
   } catch (IOException ex) {
      Toast.makeText(actividad, "Error al crear fichero de imagen", 
            Toast.LENGTH_LONG).show();
      return null;
   }
}  
fun tomarFoto(codidoSolicitud: Int): Uri? {
   try {
      val file = File.createTempFile(
         "img_" + System.currentTimeMillis() / 1000, ".jpg", 
         actividad.getExternalFilesDir(Environment.DIRECTORY_PICTURES) )
      val uriUltimaFoto = if (Build.VERSION.SDK_INT >= 24)
         FileProvider.getUriForFile(
            actividad, "es.upv.jtomas.mislugares.fileProvider", file )
      else Uri.fromFile(file)
      val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
      intent.putExtra(MediaStore.EXTRA_OUTPUT, uriUltimaFoto)
      actividad.startActivityForResult(intent, codidoSolicitud)
      return uriUltimaFoto
   } catch (ex: IOException) {
      Toast.makeText(actividad, "Error al crear fichero de imagen",
                     Toast.LENGTH_LONG).show()
      return null
   }
}  

Este método crea  una intención indicando que queremos capturar una imagen desde el dispositivo. Típicamente se abrirá la aplicación cámara de fotos. A esta intención vamos a añadirle un extra con una URI al fichero donde queremos que se almacene la fotografía. Para crear el fichero, se utiliza createTempFile() indicando nombre, extensión y directorio. El método currentTimeMillis() nos da el número de milisegundos transcurridos desde 1970. Al dividir entre 1000, tenemos el número de segundos. El objetivo que se persigue es que, al crear un nuevo fichero, su nombre nunca coincida con uno anterior. El directorio del fichero será el utilizado para almacenar fotos privadas en la memoria externa. Estos ficheros serán de uso exclusivo para tu aplicación. Además, si desístalas la aplicación este directorio será borrado. Si quieres que los ficheros sean de acceso público utiliza  getExternalStoragePublicDirectory().
 Una vez tenemos el fichero, hay dos alternativas para crear la URI. Si el API de Android donde se ejecuta la aplicación es 24 o superior, podemos crear el fichero asociado a un Content Provider nuestro. Esta acción no requiere solicitar permiso de escritura. Si el API es anterior al 24, no se dispone de esta acción, y el fichero será creado de forma convencional en la memoria externa. El inconveniente es que para realizar esta acción tendremos que pedir al usuario permiso de escritura en la memoria externa. Al concedernos este permiso, también podremos borrar o sobreescribir cualquier fichero que el usuario tenga en esta memoria. Por lo que muchos usuarios no querrán darnos este permiso. Finalmente se añade la extensión del fichero. Al final llamamos a startActivityForResult() con el código que nos han pasado.

3.     Si la versión mínima de API es anterior a 24, vamos a tener que pedir permiso para leer ficheros de la memoria externa. En AndroidManifest.xml añade dentro de la etiqueta <manifest …> </manifest> el siguiente código:

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

NOTA: Observa como en el ejercicio anterior hemos tenido que solicitar permiso para acceder a la memoria externa, pero en este no es necesario solicitar permiso para tomar una fotografía. La razón es que realmente nuestra aplicación no toma la fotografía directamente, si no que por medio de una intención lanzamos otra aplicación, que si que tiene este permiso.

4.     En un punto anterior hemos utilizado un Content Provider para almacenar ficheros. Para crearlo añade en AndroidManifest.xml dentro de la etiqueta <application …> </application> el siguiente código:

<provider
   android:name="android.support.v4.content.FileProvider"
   android:authorities="es.upv.jtomas.mislugares.fileProvider"
   android:exported="false"
   android:grantUriPermissions="true">
   <meta-data  android:name="android.support.FILE_PROVIDER_PATHS"
               android:resource="@xml/file_paths" />
</provider>  

5.     Crea un nuevo recurso con nombre "file_paths.xml" en la carpeta res/ xml:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
   <external-path 
      name="my_images"
      path="Android/data/com.example.package.name/files/Pictures" />
</paths> 

Has de cambiar es.upv.jtomas… por un identificador unido que incluya tu nombre o empresa y la aplicación. Ha de coincidir con el valor indicado en la función tomarFoto(). Cambia com.example.package.name por el paquete de la aplicación.

6.     Busca en vista_lugar.xml el ImageView con descripción “logo cámara” y añade el atributo:
android:onClick="tomarFoto"

7.     En VistaLugarActivity añade:

void tomarFoto(View view) {
   uriUltimaFoto = usoLugar.tomarFoto(RESULTADO_FOTO) 
} 
fun tomarFoto(view: View) {
   uriUltimaFoto = usoLugar.tomarFoto(RESULTADO_FOTO) 
} 
8.     En el método onActivityResult() añade la sección else if que se muestra:
} else if (requestCode == RESULTADO_GALERIA
   …
} else if (requestCode == RESULTADO_FOTO) {
   if (resultCode == Activity.RESULT_OK && uriUltimaFoto!=null) {
      lugar.setFoto(uriUltimaFoto.toString());
      usoLugar.ponerFoto(pos, lugar.getFoto(), foto);
   } else {
      Toast.makeText(this, "Error en captura", Toast.LENGTH_LONG).show();
   }
} 
} else if (requestCode == RESULTADO_GALERIA) {
   … 
} else if (requestCode == RESULTADO_FOTO) {
   if (resultCode == Activity.RESULT_OK && uriUltimaFoto!=null) {
      lugar.foto = uriUltimaFoto.toString()
      usoLugar.ponerFoto(pos, lugar.foto, foto);
   } else {
      Toast.makeText(this, "Error en captura", Toast.LENGTH_LONG).show()
   }
} 

Comenzamos verificando que volvemos de la actividad lanzada por la intención anterior, que el usuario no ha cancelado la operación y que uriUltimaFoto ha sido inicializado. En este caso, se nos pasa información en la intención de respuesta, pero sabemos que en uriUltimaFoto está almacenada la URI con el fichero donde se ha almacenado la foto. Guardamos esta URI en el campo adecuado de lugar e indicamos que se guarde y se represente en la vista foto.
 

9.     Verifica de nuevo el funcionamiento de la aplicación.

NOTA: En algunos dispositivos puede aparecer un error de memoria si la cámara está configurada con mucha resolución. En estos casos puedes probar con la cámara delantera.
 

Ejercicio paso a paso: Añadiendo un botón para eliminar fotografías

1.     En el layout vista_lugar.xml añade el siguiente botón dentro del LinearLayout donde están los botones para la cámara y para galería:

<ImageView
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:contentDescription="Eliminar foto"
    android:onClick="eliminarFoto"
    android:src="@android:drawable/ic_menu_close_clear_cancel" /> 
2.     Añade el siguiente método a la clase VistaLugarActivity:
public void eliminarFoto(View view) {
   usoLugar.ponerFoto(pos, "", foto); 
}  
fun eliminarFoto(view: View) = usoLugar.ponerFoto(pos, "", foto)  

3.     Verifica el funcionamiento del nuevo botón.

NOTA: Si lo deseas puedes poner un cuadro de dialogo para confirmar la eliminación. Véase la práctica «Un cuadro de diálogo para confirmar el borrado». No obstante, es reversible; se puede volver a asignar la fotografía buscándola en la galería.

Cargar fotografías grandes de forma eficiente

Las fotografías introducidas por el usuario pueden tener muy diversas procedencias, pudiendo llegar a los 16 Mpx en dispositivos de altas prestaciones. Cuando trabajas con fotografías es muy importante que tengas en cuenta que la memoria es un recurso limitado. Por lo tanto, es muy probable que cuando trates de cargar una imagen de gran tamaño, tu aplicación se detenga, mostrando en el LogCat el siguiente error:

Otro posible error cuando intentas cargar una imagen de ancho o alto mayor de 4096 px, es que la imagen no se muestre, aunque la aplicación continua ejecutándose. En este caso abre el LogCat y busca si aparece el siguiente warning:

Trabajar con una fotografía de muy alta definición, no tiene mucho sentido si la vamos a representar en una la pantalla de móvil, donde en raras ocasiones se supera 1 Mpx. Este problema se agrava cuando queremos mostrar una miniatura de la fotografía (thumbnail). ¿Para que cargar en memoria una fotografía en alta resolución, cuando solo la mostramos a baja resolución?

Una estrategia para evitar los problemas que hemos enumerado, consiste en cargar en memoria, no la fotografía original, sino una versión con una resolución adaptada a nuestras necesidades. La clase BitmapFactory nos ofrece herramientas para trabajar de forma adecuada[1]. Esta clase permite reescalar una imagen a una resolución menor, cargando en memoria un Bitmap de tamaño adecuado a nuestras necesidades. BitmapFactory solo admite factores de reducción que sean potencias de dos (1, 2, 4, 8, 16, etc.). Este factor de reducción se aplica al ancho y alto de la imagen. Por ejemplo, una imagen con resolución 2048x1536 con un factor de reducción 4, produce un Bitmap de aproximadamente 512x384. En el siguiente ejercicio se indica los pasos que tenemos que seguir para carga imágenes con una resolución adecuada.

Ejercicio: Evitando errores de memoria con fotografías grandes

1.     Trata de cargar imágenes de gran resolución en la aplicación Mis Lugares y observa si que producen alguno de los errores comentados.

2.     En el método VisualizaFoto() de la clase CasosUsoLugar reemplaza:

imageView.setImageURI(Uri.parse(uri));

por:

imageView.setImageBitmap(reduceBitmap(this, uri, 1024,   1024));

Antes del cambio, poníamos la URI que nos indicaba el usuario con la foto, directamente en el ImageView. Esto suponía que  lo que se cargaba en memoria todos los pixeles de la imagen. En una fotografía de 16 Mpx, codificando cada pixel con 4 bytes, supone 64 Mb de memoria, lo que podría ocasionar graves problemas de memoria.

En el nuevo código propuesto, vamos a crear un  objeto Bitmap para asignarlo al ImageView. Este Bitmap es creado por el método reduceBitmap() a partir de la URI indicada. Pero ahora, reescalado a una resolución que no ha de superar al ancho y alto indicado. En Mis Lugares se ha decidido usar un ancho y alto inferior o igual a 1024. De esta forma, la imagen tendrá una resolución inferior a 1 Mpx y un consumo de memoria inferior a 4 Mb.  

3.     Añade el siguiente método a la clase CasosUsoLugar:En Kotlin pega el texto e indica que quieres convertir desde Java.

private Bitmap reduceBitmap(Context contexto, String uri,
                                  int maxAncho, int maxAlto) {
   try {
      InputStream input = null;
      Uri u = Uri.parse(uri);
      if (u.getScheme().equals("http") || u.getScheme().equals("https")) {
         input = new URL(uri).openStream();
      } else {
         input = contexto.getContentResolver().openInputStream(u);
      }
      final BitmapFactory.Options options = new BitmapFactory.Options();
      options.inJustDecodeBounds = true;
      options.inSampleSize = (int) Math.max(
               Math.ceil(options.outWidth / maxAncho),
               Math.ceil(options.outHeight / maxAlto));
      options.inJustDecodeBounds = false;
      return BitmapFactory.decodeStream(input, null, options);
   } catch (FileNotFoundException e) {
      Toast.makeText(contexto, "Fichero/recurso de imagen no encontrado",
                Toast.LENGTH_LONG).show();
      e.printStackTrace();
      return null;
   } catch (IOException e) {
      Toast.makeText(contexto, "Error accediendo a imagen",
                Toast.LENGTH_LONG).show();
      e.printStackTrace();
      return null;
   }
}  

El propósito de este método es ontener un Bitmap a partir de la URI indicada pero reescalandolo a una resolución que no supere un ancho y un alto. El nuevo tamaño siempre se obtendrá dividiento ancho y alto por una potencia de dos. Es decir, el nuevo alto y ancho será la mitad, o una cuarta parte, o un octavo, … de las dimensiones originales.

La clase BitmapFactory nos ofrece varias alternativas para decodificar un Bitmap desde distintas fuentes (decodeByteArray(), decodeFile(), decodeResource(), etc.). En Mis Lugares la fotografía puede provenir de un fichero, cuando se toma de la cámara o desde un ContentProvider cuando se selacciona de la galería. Para cubrir las procedencias consideramos que nos han parado un identificador de URI (Uri.parse(uri)). Así, podemos abrir el Stream asociado a la URI (openInputStream()) y este Stream puede ser decodificado utilizando el método  decodeStream().

En una primera decodificación no nos interesa cargar toda la fotografía en memoria, si no solo estamos interesados en obtener sus dimensiones originales. Para conseguir esto creamos un objeto de la clase BitmapFactory.Options y activamos la opción inJustDecodeBounds. De esta forma, la siguiente llamada a decodeStream() no decodificará toda la fotografía y la cargará en memoria, si no que se limitará obtener sus dimensiones y almacenarlas en options.outWidth y options.outHeight.

La siguiente línea calcula el factor de reducción deseado y lo almacena en inSampleSize. Para ello, se divide el ancho de la foto entre el ancho máximo redondeando hacia arriba. Se realiza la misma acción con el alto y se selecciona el mayor de los dos factores.

A continuación desactivamos la opción inJustDecodeBounds, dado que ahora si queremos que se decodifique la image, y hacemos la llamada a decodeStream(). En esta decodificación la opción inSampleSize actuará como factor de reducción.

Como hemos indicado, BitmapFactory solo trabaja con valores de reducción que coincidan con potencias de dos. Si se indica en inSampleSize un valor que no coincida con 1, 2, 4, 8, etc. se tomará la potencia de dos inferior. Un valor inferior a 1 se considerará como 1.

4.     Ejecuta de nuevo la aplicación y verifica que han desaparecido los problemas con la memoria. También es posible que con esta modificación las imágenes pierdan nitidez. Recuerda que ahora el ancho/alto mayor puede haberse reducido a un valor entre 512 y 1024. Si consideras que la resolución es insuficiente reemplaza los valores 1024 por algo mayor, como 2048.

 

[1] http://developer.android.com/training/displaying-bitmaps/load-bitmap.html