Guía de estudio 10 · Práctica 10

Imágenes, cámara y transferencia de archivos en Android

Coil, Photo Picker, FileProvider, compresión, EXIF, multipart y estado guardado

30 min de lectura Tema de la Práctica 10 Independiente del ejemplo de la práctica

Introducción

Una aplicación puede mostrar imágenes recibidas por red, permitir que el usuario seleccione una fotografía existente o capturar una imagen nueva. Aunque las tres operaciones parecen similares desde la interfaz, involucran recursos y responsabilidades diferentes: comunicación HTTP, memoria, permisos temporales, procesos de otras aplicaciones, almacenamiento y privacidad.

Esta guía estudia el recorrido completo de una imagen en Android con Kotlin y Jetpack Compose: obtener una imagen remota, seleccionarla o capturarla, prepararla para transferencia, subirla a un servidor y conservar las referencias necesarias ante la recreación de la actividad o el proceso. Los ejemplos emplean un catálogo de reportes con fotografías, pero los conceptos son aplicables a perfiles, productos, evidencias o publicaciones.

Al terminar el estudio, deberías poder explicar por qué una imagen comprimida ocupa mucha más memoria al decodificarse, cómo funcionan las URI content://, cuándo se necesita FileProvider, qué información contiene EXIF y qué estado se debe restaurar si Android termina un proceso.

Lo imprescindible para el examen

Explicar la URI y permisos temporales; seleccionar y capturar fotos; estimar memoria de bitmaps; procesar EXIF, multipart y estado guardado; analizar privacidad y caché.

Se evalúa la capacidad de explicar, interpretar fragmentos y justificar decisiones. Las versiones puntuales de librerías, URLs, nombres de proyecto y comandos accesorios quedan como contexto de consulta.

Criterios transversales para interpretar esta guía

Principio transferible. Conviene distinguir la responsabilidad de una tecnología de la forma concreta en que se utilizó en la práctica. Un ejemplo de ViewModel, Retrofit, Hilt o WebSocket ilustra una solución posible; su configuración exacta no es una regla universal.

Decisión del proyecto. Los nombres, rutas, versiones, intervalos, tamaños y elecciones de librerías del caso original sirven para interpretar el código. Salvo indicación expresa, no se exige memorizarlos para el examen.

Alcance del código. Los fragmentos son recursos didácticos; pueden omitir imports, dependencias, permisos, detalles de manejo de errores o infraestructura. Comprender un fragmento no equivale a comprobar que compila como proyecto independiente.

Referencias cruzadas. La síntesis transversal de la colección reúne las distinciones entre estado y evento, persistencia, reintentos, idempotencia y límites de las pruebas.

1. El ciclo de vida de una imagen en una aplicación

No todas las imágenes tienen el mismo origen ni requieren el mismo tratamiento. Considera un sistema de reportes en el que los usuarios adjuntan una fotografía de un desperfecto:

  1. Visualización: la lista descarga y presenta miniaturas remotas.
  2. Selección: una persona elige una fotografía desde el selector de imágenes.
  3. Captura: alternativamente, abre una aplicación de cámara que guarda una fotografía en una URI preparada por la app.
  4. Preparación: se decodifica de forma controlada, se ajusta la orientación, se reduce el tamaño y se genera un archivo nuevo.
  5. Transferencia: el cliente envía un cuerpo HTTP multipart; el servidor devuelve un identificador.
  6. Publicación: se crea el reporte con ese identificador, sin enviar de nuevo los bytes de la fotografía.

Es importante distinguir tres representaciones:

Representación Ejemplo Qué representa
URI local content://.../foto/17 Acceso a contenido administrado por un proveedor
Bytes JPEG generado después de comprimir Contenido binario que se enviará
Identificador remoto d8f2...jpg Referencia que devuelve el almacén de imágenes

Un error de diseño frecuente es utilizar indistintamente una ruta local, una URL y el identificador que devuelve el servidor. Cada dato cumple una función distinta.

2. Mostrar imágenes remotas con Coil

Una imagen remota no debería descargarse y decodificarse manualmente desde cada Composable. Una biblioteca como Coil organiza la descarga, la decodificación, el redimensionamiento, la caché y la presentación en Compose.

2.1. Un componente de imagen con espacio reservado

@Composable
fun FotoReporte(
    imagen: Any?,
    modifier: Modifier = Modifier
) {
    if (imagen != null) {
        AsyncImage(
            model = imagen,
            contentDescription = "Fotografía del reporte",
            contentScale = ContentScale.Crop,
            modifier = modifier
                .fillMaxWidth()
                .aspectRatio(16f / 9f)
        )
    }
}

model puede recibir una URL remota o una URI local admitida por los componentes de Coil. ContentScale.Crop llena el área disponible, aunque podría recortar parte de la foto. aspectRatio(16f / 9f) reserva dimensiones antes de que termine la descarga, evitando que las tarjetas cambien bruscamente de tamaño.

Una imagen decorativa puede usar contentDescription = null. Una imagen que aporta información necesita una descripción útil, acorde con lo que se conoce sobre ella; un texto genérico no siempre ofrece suficiente accesibilidad.

2.2. Descargar contenido protegido

Si las imágenes requieren autenticación, el cargador debe enviar los mismos encabezados que las demás peticiones protegidas. En el proyecto original se configuró Coil con el cliente OkHttp que ya usaban Retrofit y el mecanismo de renovación del token:

@Module
@InstallIn(SingletonComponent::class)
object ImagenesModule {

    @Provides
    @Singleton
    fun proporcionarImageLoader(
        @ApplicationContext context: Context,
        cliente: OkHttpClient
    ): ImageLoader = ImageLoader.Builder(context)
        .components {
            add(OkHttpNetworkFetcherFactory(callFactory = { cliente }))
        }
        .crossfade(true)
        .build()
}

Este fragmento ilustra la configuración de un ImageLoader mediante Hilt. Para que toda la interfaz lo utilice, debe integrarse como el cargador compartido de la aplicación, mediante la configuración correspondiente de Coil.

Compartir un OkHttpClient configurado permite reutilizar interceptores, políticas de autenticación, registros y conexiones. No significa que cada solicitud tenga que utilizar idénticas opciones: una conexión de larga duración puede necesitar otra configuración de tiempo de espera.

2.3. La caché puede esconder errores

Supongamos que una miniatura protegida se descargó mientras la sesión era válida. Después se elimina, por error, el interceptor que adjunta el token. La miniatura podría seguir apareciendo porque Coil la recupera de su caché y no hace una petición nueva.

Conclusión para el diagnóstico: ver una imagen en pantalla no demuestra que la comunicación HTTP esté funcionando. Para comprobar una falla de autenticación, utiliza una imagen no descargada previamente, revisa los registros adecuados o invalida de forma controlada la caché.

La caché también exige una decisión de privacidad. Si se almacenan imágenes de acceso restringido, hay que evaluar su persistencia, la separación entre usuarios y la invalidación cuando cambian los permisos. La cabecera Cache-Control: immutable solo resulta apropiada para recursos cuyo contenido verdaderamente no cambia bajo la misma dirección.

3. Comprender las URI de contenido

Una URI es un identificador de recurso. En Android, una referencia content:// identifica contenido ofrecido a través de un ContentProvider. No debe tratarse como una ruta tradicional de archivo.

content://com.ejemplo.proveedor/imagenes/17

Una aplicación recibe la URI y accede al contenido mediante ContentResolver:

fun leerBytes(context: Context, uri: Uri): ByteArray? =
    context.contentResolver.openInputStream(uri)?.use { it.readBytes() }

Este ejemplo es apropiado para un recurso pequeño y controlado. No conviene usar readBytes() indiscriminadamente sobre fotografías grandes, porque carga el archivo completo en memoria. En el procesamiento de imágenes se prefieren flujos y decodificación redimensionada.

El acceso a la URI depende de los permisos concedidos por el proveedor y por el mecanismo de selección. Tener el texto de una URI guardado no garantiza poder leer el recurso indefinidamente.

3.1. Photo Picker

Para permitir que el usuario seleccione una imagen, Android proporciona un selector que concede acceso a los elementos elegidos sin otorgar acceso general a toda la galería.

@Composable
fun SelectorDeFoto(onSeleccionar: (Uri) -> Unit) {
    val selector = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        if (uri != null) onSeleccionar(uri)
    }

    Button(onClick = {
        selector.launch(
            PickVisualMediaRequest(
                ActivityResultContracts.PickVisualMedia.ImageOnly
            )
        )
    }) {
        Text("Elegir fotografía")
    }
}

Este mecanismo evita solicitar permisos amplios de lectura de imágenes para la selección realizada por el usuario. Si se cancela la operación, el callback recibe null.

Precisión: la duración del acceso no debe suponerse universalmente igual a la vida de una Activity. Depende del contrato, del proveedor y de la posibilidad de conservar permisos cuando corresponda. Si la imagen se usará mucho después, una estrategia fiable consiste en copiarla a almacenamiento privado cuando todavía se tiene acceso o gestionar formalmente la persistencia del permiso, según el mecanismo utilizado.

4. Capturar una fotografía mediante otra aplicación

El contrato ActivityResultContracts.TakePicture() permite solicitar una fotografía a una aplicación de cámara. La app que solicita la captura proporciona primero la URI donde se escribirá el resultado.

4.1. Por qué hace falta FileProvider

La cámara es otro proceso y no puede acceder libremente a los archivos privados de nuestra aplicación. FileProvider permite representar un archivo autorizado mediante una URI content:// y conceder a la cámara acceso limitado a ese recurso.

Un esquema conceptual:

Aplicación A
  └─ crea un archivo privado para la foto
  └─ obtiene URI con FileProvider
  └─ lanza TakePicture(uri)
                │
                ▼
Aplicación de cámara
  └─ escribe el contenido autorizado
                │
                ▼
Aplicación A
  └─ recibe true / false
  └─ procesa la foto desde la misma URI

En el manifiesto se declara el proveedor con su autoridad, sin exportarlo públicamente y con concesión de permisos URI:

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

El XML file_paths limita qué directorios se pueden compartir. No se debería configurar una ruta más amplia de la necesaria.

4.2. Un lanzador de cámara y su resultado

@Composable
fun CapturarFoto(
    crearUri: () -> Uri,
    onFotoTomada: (Uri) -> Unit
) {
    var uriPendiente by rememberSaveable {
        mutableStateOf<Uri?>(null)
    }

    val camara = rememberLauncherForActivityResult(
        ActivityResultContracts.TakePicture()
    ) { exito ->
        val uri = uriPendiente
        if (exito && uri != null) onFotoTomada(uri)
        uriPendiente = null
    }

    Button(onClick = {
        val uri = crearUri()
        uriPendiente = uri
        camara.launch(uri)
    }) {
        Text("Tomar fotografía")
    }
}

Este ejemplo supone que crearUri() crea previamente un archivo válido y lo expone con FileProvider. Su implementación y la configuración del proveedor forman parte de la infraestructura de archivos de la aplicación.

Con TakePicture(), cuando la captura se delega a otra aplicación, normalmente no hace falta solicitar el permiso CAMERA para ese flujo. Si la propia app controla el hardware de cámara, por ejemplo usando CameraX, la situación es diferente y sí requiere gestionar ese permiso.

4.3. ¿Dónde guardar el archivo temporal?

  • cacheDir contiene archivos que el sistema puede eliminar para recuperar espacio; no es garantía de persistencia.
  • filesDir conserva archivos privados hasta que la app los elimine, el usuario borre sus datos o desinstale la aplicación.

Si la fotografía debe permanecer disponible durante el proceso de edición y publicación, filesDir ofrece mayor estabilidad. A cambio, la app debe eliminar los archivos que ya no necesita. Elegir almacenamiento estable no elimina la obligación de administrar espacio.

5. Memoria: tamaño del archivo frente a tamaño del bitmap

Un JPEG contiene píxeles comprimidos. Al decodificarlo, la imagen ocupa una cantidad de memoria vinculada principalmente a sus dimensiones y al formato de píxel, no al tamaño del archivo comprimido.

Para un bitmap típico de cuatro bytes por píxel:

[ \text{memoria aproximada} = \text{ancho} \times \text{alto} \times 4 ]

Dimensiones Píxeles Memoria aproximada
4 000 × 3 000 12 000 000 48 MB
2 000 × 1 500 3 000 000 12 MB
1 000 × 750 750 000 3 MB

Son valores aproximados y dependen de la configuración interna del bitmap. La enseñanza principal es que un JPEG de pocos megabytes puede convertirse en decenas de megabytes de memoria al abrirlo.

5.1. Consultar dimensiones sin decodificar todos los píxeles

val opciones = BitmapFactory.Options().apply {
    inJustDecodeBounds = true
}

context.contentResolver.openInputStream(uri)?.use { stream ->
    BitmapFactory.decodeStream(stream, null, opciones)
}

val ancho = opciones.outWidth
val alto = opciones.outHeight

inJustDecodeBounds obtiene información de dimensiones sin crear el bitmap de tamaño completo. A continuación se decide cuánto reducir antes de decodificar.

5.2. Reducir desde el inicio con inSampleSize

var muestra = 1
val ladoMayor = max(ancho, alto)

while (ladoMayor / (muestra * 2) >= 1280) {
    muestra *= 2
}

val opcionesLectura = BitmapFactory.Options().apply {
    inSampleSize = muestra
}

val bitmap = context.contentResolver.openInputStream(uri)?.use {
    BitmapFactory.decodeStream(it, null, opcionesLectura)
} ?: error("No se pudo decodificar la imagen")

Cuando inSampleSize = 2, el decodificador reduce aproximadamente cada dimensión a la mitad y el número de píxeles a una cuarta parte. Con inSampleSize = 4, el consumo de memoria correspondiente a los píxeles suele acercarse a una dieciseisava parte.

Para cumplir un tamaño máximo exacto, después se aplica una escala adicional si hace falta. Esta operación puede crear otro bitmap, de modo que el pico de memoria puede superar el tamaño de la imagen final.

5.3. Hilos y corrutinas

La lectura, la decodificación y la escritura de archivos pueden tardar. La práctica sitúa el procesamiento fuera del hilo principal:

suspend fun prepararImagen(uri: Uri): ByteArray =
    withContext(Dispatchers.IO) {
        // Abrir el contenido, decodificar reducido,
        // corregir la orientación y generar un JPEG.
        TODO("Implementar procesamiento")
    }

Dispatchers.IO se emplea aquí para las operaciones de entrada/salida. Si una transformación intensiva de píxeles domina el trabajo, también puede justificarse Dispatchers.Default para el tramo estrictamente computacional. Una función suspend por sí sola no garantiza que el procesamiento sea ligero para el hilo que la ejecuta.

6. EXIF: orientación y privacidad

EXIF es un conjunto de metadatos que puede acompañar a una fotografía. Según el dispositivo y la configuración, puede incluir orientación, fecha, modelo de cámara y coordenadas GPS.

Orientación. Algunos dispositivos guardan los píxeles con una orientación distinta de la visual y registran una instrucción para girarlos. Por eso una fotografía puede verse correctamente en la galería y, tras un procesamiento que ignore EXIF, aparecer rotada.

Privacidad. Publicar el archivo original podría revelar metadatos que el usuario no esperaba compartir, en particular información de ubicación.

Un procesamiento coherente:

  1. Leer la orientación con ExifInterface.
  2. Decodificar con el tamaño apropiado.
  3. Aplicar la transformación necesaria a los píxeles.
  4. Crear un JPEG nuevo con Bitmap.compress(...).
  5. Comprobar el resultado y, cuando sea necesario, verificar que no se exportaron metadatos sensibles.

La práctica utiliza el hecho de que Bitmap.compress no copia automáticamente el bloque EXIF original. Esto permite generar una nueva imagen sin los metadatos previos. Precisión técnica: las orientaciones EXIF incluyen casos de reflejo o transposición, además de los giros de 90°, 180° y 270°. Una implementación completa debe contemplarlos o usar una transformación que los resuelva.

val orientacion = context.contentResolver.openInputStream(uri)?.use {
    ExifInterface(it).getAttributeInt(
        ExifInterface.TAG_ORIENTATION,
        ExifInterface.ORIENTATION_NORMAL
    )
} ?: ExifInterface.ORIENTATION_NORMAL

Además de la privacidad, reducir y recomprimir una imagen puede cambiar su calidad visual. La calidad JPEG elegida debe equilibrar detalle útil, tamaño y requisitos del servidor. Recomprimir al 100 % no implica conservar la calidad original ni producir el archivo más pequeño.

7. Subida de imágenes con Retrofit y multipart

En el ejemplo de la práctica, el servidor ofrece dos operaciones distintas:

POST /imagenes       → recibe bytes y devuelve una clave
POST /reportes        → recibe datos del reporte y la clave

El servidor puede almacenar los bytes en un sistema de objetos y guardar únicamente la referencia en una base relacional. Es una opción arquitectónica frecuente; no es una imposibilidad técnica almacenar imágenes en una base de datos. La elección depende de tamaño, volumen, consistencia, costos y políticas de acceso.

7.1. Declarar el endpoint

interface ReportesApi {
    @Multipart
    @POST("imagenes")
    suspend fun subirImagen(
        @Part archivo: MultipartBody.Part
    ): ImagenSubidaDto

    @POST("reportes")
    suspend fun crearReporte(
        @Body reporte: NuevoReporteDto
    ): ReporteDto
}

@Serializable
data class ImagenSubidaDto(
    val id: String,
    val tipo: String,
    val bytes: Int
)

Las anotaciones @Multipart y @Part indican que el cuerpo de la solicitud contiene partes con nombre y contenido binario. El nombre del campo debe coincidir con el contrato del servidor.

7.2. Construir la parte

suspend fun subir(uri: Uri): String {
    val jpeg: ByteArray = compresor.comprimir(uri)

    val parte = MultipartBody.Part.createFormData(
        "archivo",                      // nombre exigido por la API
        "evidencia.jpg",                 // nombre del archivo enviado
        jpeg.toRequestBody("image/jpeg".toMediaType())
    )

    return api.subirImagen(parte).id
}

Este ejemplo mantiene los bytes en memoria. Para archivos grandes puede ser más conveniente un RequestBody que transmita desde un flujo o archivo temporal sin construir un gran ByteArray.

7.3. Coordinar las dos peticiones

La publicación no es una sola operación de red:

Seleccionar/capturar
        ↓
Preparar JPEG
        ↓
POST /imagenes ──> recibe clave
        ↓
POST /reportes con esa clave
        ↓
Confirmación y navegación

Si falla la primera petición, el reporte no debe enviarse con una imagen que no existe. Si la imagen se sube correctamente, pero falla la publicación del reporte, puede quedar un objeto huérfano en el almacenamiento remoto. Una aplicación real necesita decidir si lo elimina, si caduca automáticamente o si conserva el identificador para reintentar sin subir otra vez.

También debe contemplarse que un reintento después de una interrupción puede duplicar operaciones. Cuando el servicio lo admita, los identificadores de operación o claves de idempotencia permiten reducir ese riesgo.

8. Estado de publicación y errores

Un envío con imagen tiene varias etapas que conviene comunicar al usuario:

data class PublicarUiState(
    val titulo: String = "",
    val descripcion: String = "",
    val imagen: Uri? = null,
    val etapa: String? = null,
    val error: String? = null
) {
    val enviando: Boolean get() = etapa != null
    val puedePublicar: Boolean get() =
        titulo.isNotBlank() && descripcion.isNotBlank() && !enviando
}

etapa puede representar «Preparando imagen», «Subiendo imagen» o «Publicando reporte». Mientras existe, se evita iniciar otra publicación mediante el botón. El ViewModel coordina la operación dentro de viewModelScope y navega solo después de recibir confirmación del servidor.

fun publicar(alConfirmar: () -> Unit) {
    if (!uiState.puedePublicar) return

    viewModelScope.launch {
        try {
            uiState = uiState.copy(etapa = "Preparando imagen", error = null)
            val clave = uiState.imagen?.let { imagenes.subir(it) }

            uiState = uiState.copy(etapa = "Publicando reporte")
            reportes.publicar(uiState.titulo, uiState.descripcion, clave)

            uiState = uiState.copy(etapa = null)
            alConfirmar()
        } catch (e: ImagenIlegible) {
            uiState = uiState.copy(etapa = null, error = "No se pudo leer la imagen")
        } catch (e: IOException) {
            uiState = uiState.copy(etapa = null, error = "No se completó la comunicación")
        } catch (e: HttpException) {
            uiState = uiState.copy(etapa = null, error = "El servidor respondió ${e.code()}")
        }
    }
}

Este ejemplo supone que ImagenIlegible deriva de IOException; de ahí la importancia de atraparla antes de IOException. HttpException se trata por separado porque representa una respuesta HTTP no exitosa, aunque debe distinguirse entre códigos permanentes y recuperables. Si se añade un catch general, hay que propagar CancellationException, ya que la cancelación de una corrutina no debe convertirse en un error ordinario de interfaz.

En el contrato usado en la práctica, un archivo que supera el límite puede producir HTTP 413 y un campo multipart mal nombrado puede provocar HTTP 422. Son respuestas de ejemplo del servidor, no reglas sobre todas las API.

9. Recuperar el estado cuando Android termina el proceso

Abrir la cámara implica pasar a otra aplicación. La nuestra puede quedar en segundo plano y Android puede terminar su proceso para recuperar memoria. Al regresar, la actividad y el ViewModel pueden recrearse.

Hay dos problemas distintos: recuperar la URI de salida de la cámara y recuperar los campos que el usuario ya escribió.

Mecanismo Recomposición Cambio de configuración Recreación tras muerte del proceso*
remember Sí No No
ViewModel Sí Sí No
rememberSaveable Sí Sí Sí, en las condiciones del estado guardado
SavedStateHandle Sí Sí Sí, en las condiciones del estado guardado
Archivo privado persistente Sí Sí Sí, mientras no sea eliminado

El estado guardado no equivale a persistencia permanente ni cubre todas las formas de cierre, eliminación o terminación. Su restauración depende de que Android haya conservado el estado de la tarea o actividad.

9.1. Qué guardar

rememberSaveable puede conservar la URI pendiente de la cámara; SavedStateHandle puede conservar el título, la descripción o una referencia pequeña a la imagen seleccionada. No se debe guardar un Bitmap grande: el mecanismo utiliza estado serializable limitado y puede causar errores como TransactionTooLargeException.

@HiltViewModel
class EditarReporteViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    var titulo: String
        get() = savedStateHandle["titulo"] ?: ""
        set(value) { savedStateHandle["titulo"] = value }
}

El estado «Subiendo…» no debería restaurarse sin comprobar la operación, porque una corrutina interrumpida no continúa automáticamente después de que el proceso se reinicia. Si la tarea debe sobrevivir a la muerte del proceso, requiere una estrategia explícita de persistencia y ejecución diferida, no únicamente SavedStateHandle.

9.2. Referencia guardada frente a acceso real

Restaurar una Uri no garantiza que el contenido continúe disponible. Para fotos creadas por la app y alojadas en su almacenamiento privado, debe conservarse el archivo. Para fotos obtenidas del selector, debe conservarse el acceso de la manera apropiada o copiar la imagen mientras exista permiso para leerla.

10. Seguridad, privacidad y entorno de desarrollo

Hay varias medidas que se relacionan con el tratamiento de imágenes:

  • Minimización: no enviar una imagen de resolución mucho mayor de la necesaria y eliminar metadatos que no sean requeridos.
  • Acceso: si una imagen pertenece a una cuenta, el servidor debe comprobar quién puede descargarla; ocultar una URL en la UI no la protege.
  • Registros: evitar escribir tokens, rutas privadas y contenido sensible en los logs de producción.
  • Validación del servidor: comprobar tipos y tamaños aceptados; no confiar únicamente en el tipo MIME o extensión que afirma el cliente.
  • Archivos temporales: disponer de una política de limpieza y evitar conservar copias innecesarias.

Para conectar un emulador Android con un servidor que corre en la computadora anfitriona se utiliza habitualmente 10.0.2.2. En la práctica, el acceso HTTP sin cifrado se permite solo para desarrollo, mientras que una versión distribuida debe utilizar HTTPS y la configuración adecuada de seguridad de red.

Conexión transversal: alcance y decisiones

Persistencia, seguridad y secuencias de subida

rememberSaveable y SavedStateHandle conservan cantidades pequeñas de estado bajo determinadas condiciones de restauración; no son almacenamiento permanente de archivos ni una garantía tras cualquier tipo de cierre. Una Uri guardada puede perder el permiso de lectura si no se obtuvo persistencia de acceso válida o se copió el contenido a almacenamiento controlado por la app.

Subir una imagen y luego crear un registro son dos operaciones, no una transacción única: si la segunda falla, puede quedar un objeto huérfano; si la respuesta de creación se pierde, repetirla puede duplicar registros. Es necesario definir limpieza/expiración de objetos sin referencia y, cuando proceda, idempotencia del servidor (guía 04). Una imagen privada en la caché de Coil puede persistir después del logout si no se configura una política adecuada (guía 06).

11. Diagnóstico de problemas frecuentes

Síntoma Posible causa Qué revisar
La imagen protegida aparece vacía y el servidor registra 401 Coil utiliza un cliente sin autenticación Configuración de ImageLoader, OkHttp e interceptores
La imagen sigue apareciendo tras romper la red Se recupera desde caché Repetir con una imagen nueva o invalidar caché
@Part parameters can only be used with multipart encoding Falta @Multipart Definición del endpoint de Retrofit
Couldn't find meta-data for provider FileProvider no está bien declarado Autoridad, manifiesto y XML de rutas
La cámara regresa sin una foto accesible URI incorrecta o archivo eliminado Preparación de la URI y directorio empleado
Foto rotada después de subirla La compresión ignoró EXIF Lectura y aplicación de orientación
OutOfMemoryError al preparar una fotografía Se decodificó con dimensiones completas inJustDecodeBounds, inSampleSize, escalado
Se pierde el texto al volver de la cámara El ViewModel anterior se destruyó SavedStateHandle y restauración real del proceso
La URI se restaura, pero no abre No se conservó archivo o permiso Ciclo de vida y estrategia de acceso al contenido
El servidor responde 413 El cuerpo excede su límite Tamaño final y reglas de subida
El servidor responde 422 El cuerpo no cumple el contrato Nombre de parte, campos y validaciones

12. Vocabulario esencial

Término Significado
Coil Biblioteca para cargar, decodificar, almacenar en caché y presentar imágenes
ImageLoader Componente que configura cómo se cargan las imágenes
URI content:// Referencia a contenido expuesto mediante un proveedor
ContentResolver Interfaz para acceder al contenido de las URI
Photo Picker Selector de medios que otorga acceso a los elementos elegidos
FileProvider Proveedor que comparte archivos autorizados mediante URI
TakePicture Contrato que pide a otra aplicación capturar una foto en una URI
Bitmap Representación de píxeles en memoria
inJustDecodeBounds Consulta dimensiones sin crear el bitmap completo
inSampleSize Solicita una decodificación con dimensiones reducidas
EXIF Metadatos que pueden acompañar una fotografía
Multipart Tipo de cuerpo HTTP dividido en partes con nombre
Almacén de objetos Servicio que guarda y recupera objetos mediante identificadores
SavedStateHandle Estado pequeño asociado a un ViewModel que puede restaurarse
rememberSaveable Estado de Compose que participa en los mecanismos de estado guardado

13. Preguntas de autoevaluación

Comprensión conceptual

  1. ¿Por qué una app suele utilizar Coil para presentar imágenes de red en lugar de hacer la descarga dentro de cada Composable?
  2. ¿Qué problema aparece si Coil usa un cliente HTTP distinto del que tiene el interceptor de autenticación?
  3. ¿Por qué una imagen almacenada en caché puede ocultar un error de red?
  4. ¿Qué diferencia existe entre una URI content://, una ruta de archivo y una clave de imagen remota?
  5. ¿Por qué Photo Picker permite seleccionar una fotografía sin conceder acceso general a la galería?
  6. ¿Qué función cumple FileProvider cuando la captura se realiza desde otra aplicación de cámara?
  7. ¿Por qué el archivo JPEG y el bitmap decodificado tienen tamaños de memoria muy diferentes?
  8. ¿Qué dos riesgos distintos pueden surgir del EXIF y cómo se atienden?

Interpretación de código

  1. En AsyncImage, ¿qué aportan aspectRatio(16f / 9f) y ContentScale.Crop? ¿Qué inconveniente puede tener el segundo?
  2. ¿Qué efecto tiene inJustDecodeBounds = true al decodificar una fotografía?
  3. Una imagen de 4000 × 3000 se decodifica con inSampleSize = 4. ¿Cuánto ocuparán aproximadamente sus píxeles si se usan cuatro bytes por píxel?
  4. ¿Qué indican @Multipart y @Part en una función de Retrofit?
  5. ¿Por qué importa el valor "archivo" en MultipartBody.Part.createFormData?
  6. ¿Por qué ImagenIlegible debe aparecer antes de IOException si la primera hereda de la segunda?
  7. ¿Por qué puede ser necesario conservar la URI pendiente de la cámara con rememberSaveable?
  8. ¿Por qué no debe almacenarse un bitmap completo en SavedStateHandle?

Resolución de situaciones

  1. Una foto se ve derecha en la vista previa, pero al subirla aparece acostada. ¿Dónde investigarías primero?
  2. La imagen se subió correctamente, pero falló la creación del registro que debía contenerla. ¿Qué problema puede quedar en el servidor?
  3. Después de que Android terminó la aplicación mientras se usaba la cámara, volvió el título, pero ya no se puede abrir la URI de la galería. ¿Cómo explicas esta diferencia?
  4. Un estudiante propone enviar siempre la imagen original, conservar todos sus metadatos y reducirla solo en el servidor. ¿Qué costos y riesgos habría que discutir?

14. Respuestas posibles

  1. Coil centraliza la descarga, la decodificación, la caché y la integración con Compose. Evita repetir infraestructura y administrar manualmente solicitudes al recomponer o desplazar una lista.
  2. Las imágenes protegidas pueden solicitarse sin Authorization; el servidor puede devolver 401. También se pierden interceptores y configuración de renovación de tokens si no se comparten o configuran explícitamente.
  3. La biblioteca puede mostrar una copia descargada previamente sin pedir el recurso otra vez. La pantalla parece correcta aunque las nuevas peticiones fallen.
  4. La URI identifica contenido administrado por un proveedor y puede exigir permisos; una ruta se refiere a un archivo en un sistema de archivos; una clave remota identifica un objeto del servidor.
  5. Porque el usuario elige archivos concretos y el sistema concede acceso limitado a lo seleccionado. La app no necesita recorrer toda la biblioteca de imágenes.
  6. Traduce el acceso a un archivo privado a una URI compartible y permite que la cámara escriba en ese archivo con permisos delimitados.
  7. JPEG es un formato comprimido; el bitmap almacena píxeles decodificados. Aproximadamente, cada píxel puede ocupar cuatro bytes, independientemente del tamaño del archivo JPEG.
  8. EXIF puede determinar cómo debe orientarse la imagen y puede revelar datos privados, como coordenadas. Se interpreta la orientación y se crea una salida que no arrastre metadatos no necesarios.
  9. aspectRatio reserva un rectángulo estable. Crop lo llena, pero puede recortar contenido significativo de la imagen.
  10. Permite conocer dimensiones sin construir el bitmap completo, para elegir previamente una escala de decodificación.
  11. Aproximadamente 1000 × 750 × 4 = 3 MB, sin contar otras estructuras o copias temporales.
  12. Declaran una solicitud HTTP multipart y la parte que contiene el archivo. Sin @Multipart, Retrofit no interpreta adecuadamente un parámetro @Part.
  13. El servidor identifica cada parte por su nombre. Si espera archivo y recibe otro, puede rechazar el cuerpo por no cumplir el contrato.
  14. Porque un catch (IOException) anterior atraparía también ImagenIlegible, impidiendo mostrar un mensaje específico sobre el archivo ilegible.
  15. Al abrir la cámara, Android puede destruir el proceso de la aplicación que la llamó. Si se restaura el estado de la actividad, la URI conservada permite identificar dónde se guardó la foto.
  16. Porque el estado guardado debe ser pequeño; un bitmap puede ocupar megabytes y exceder los límites de transferencia del estado. Se guarda una referencia accesible, no todos los píxeles.
  17. Primero comprobaría la lectura y aplicación de la orientación EXIF durante la preparación. El visor puede respetar EXIF aunque el compresor no lo haga.
  18. Puede quedar una imagen sin referencia, llamada objeto huérfano. Se requiere una política de limpieza, expiración o reutilización en reintentos.
  19. El título se restauró desde estado guardado, pero una URI no garantiza que el permiso de lectura siga vigente. Se necesita un mecanismo apropiado para conservar acceso o una copia privada.
  20. Aumenta la transferencia de datos, la latencia y el consumo de memoria; puede revelar ubicación y otros metadatos. Reducir en el servidor puede ser útil en algunos sistemas, pero no elimina los costos de subir el original.

15. Cierre y preguntas de reflexión

La gestión de imágenes combina interfaz, almacenamiento, comunicación de red y seguridad. El resultado visible suele ser una fotografía dentro de una tarjeta, pero su implementación depende de decisiones que deben mantenerse correctas incluso cuando falla la red, cambia el ciclo de vida o el sistema termina el proceso.

  1. ¿Cómo cambia tu estrategia cuando la imagen es pública frente a cuando solo la puede consultar una persona autenticada?
  2. ¿Qué debería ocurrir si el usuario abandona la pantalla después de capturar una fotografía pero antes de publicarla?
  3. ¿Qué datos y referencias deben sobrevivir a la recreación del proceso y cuáles deben reconstruirse?
  4. ¿Cómo comprobarías que una compresión conserva detalle suficiente y elimina los metadatos que no deben publicarse?
  5. ¿Qué solución diseñarías para evitar imágenes huérfanas o publicaciones duplicadas después de un fallo parcial?