Tema 7. Multimedia: cámara, galería, audio y vídeo

  • Bloque: B4 — Multimedia en Android
  • Duración aproximada: 9 horas
  • Aplicación de referencia: AppDummy biblioteca personal — proyecto vertebrador.
  • RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA3-a Se han analizado entornos de desarrollo multimedia.
RA3-b Se han reconocido las clases que permiten la captura, procesamiento y almacenamiento de datos multimedia.
RA3-c Se han utilizado clases para la conversión de datos multimedia de un formato a otro.
RA3-e Se han utilizado clases para el control de eventos, tipos de media y excepciones, entre otros.
RA3-g Se han utilizado clases para construir reproductores de contenidos multimedia.
RA3-h Se han depurado y documentado los programas desarrollados.

Dependencias necesarias#

Se continúa desde la última versión de AppDummy, si no la tienes, puedes descargarla desde v8. Ejemplo práctico Tema 6 .

Este es el primer tema del curso que añade bibliotecas nuevas desde T5. Se incorporan dos familias: CameraX, para el acceso a la cámara, y Media3, para la reproducción de vídeo. El audio se resuelve con MediaPlayer, que forma parte del propio framework de Android y no necesita ninguna dependencia.

Como en el resto del proyecto, las versiones se declaran en el catálogo gradle/libs.versions.toml y nunca directamente en el build.gradle.kts:

 1[versions]
 2cameraX = "1.6.1"      # última versión estable de CameraX
 3media3 = "1.11.0"     # última versión estable de Media3/ExoPlayer
 4
 5[libraries]
 6# ─── CameraX ────────────────────────────────────────────────────────────────
 7# Todos los artefactos de CameraX deben compartir versión, igual que ocurría con Room.
 8androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "cameraX" }
 9androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" }
10androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" }
11androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraX" }
12androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "cameraX" }
13
14# ─── Media3 (ExoPlayer) ─────────────────────────────────────────────────────
15androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
16androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
 1dependencies {
 2    // ... dependencias de T1 a T6 ...
 3
 4    // CameraX — vista previa, captura de imagen y grabación de vídeo
 5    implementation(libs.androidx.camera.core)
 6    implementation(libs.androidx.camera.camera2)     // implementación sobre Camera2
 7    implementation(libs.androidx.camera.lifecycle)   // vinculación con el Lifecycle
 8    implementation(libs.androidx.camera.view)        // PreviewView
 9    implementation(libs.androidx.camera.video)       // VideoCapture (sección 9)
10
11    // Media3 — reproducción de vídeo
12    implementation(libs.androidx.media3.exoplayer)
13    implementation(libs.androidx.media3.ui)          // PlayerView
14}

¿Por qué cinco artefactos de CameraX? La biblioteca está muy modularizada para que cada aplicación incluya solo lo que usa. camera-core define los casos de uso, camera-camera2 es la implementación real sobre la API Camera2 del sistema, camera-lifecycle permite ligar la cámara a un LifecycleOwner, camera-view aporta la vista PreviewView y camera-video el caso de uso de grabación. Si se olvida camera-camera2 el proyecto compila, pero la cámara nunca se inicializa en tiempo de ejecución.

Permisos en el manifiesto#

AppDummy ya declara el permiso de cámara desde la v3 (pantalla de gestión de permisos del Bloque 1). Solo hay que añadir el de grabación de audio, necesario en la sección 9:

 1<!-- Permisos normales, concedidos automáticamente (T5 y T6) -->
 2<uses-permission android:name="android.permission.INTERNET" />
 3<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 4
 5<!-- Permisos peligrosos: requieren aprobación explícita del usuario -->
 6<uses-permission android:name="android.permission.CAMERA" />
 7<uses-permission android:name="android.permission.RECORD_AUDIO" />   <!-- NUEVO en T7 -->
 8
 9<!-- La cámara es una característica deseable, no obligatoria: required="false"
10     permite instalar la app en dispositivos sin cámara (algunas tablets). -->
11<uses-feature
12    android:name="android.hardware.camera"
13    android:required="false" />

No hace falta ningún permiso de almacenamiento. AppDummy guarda las portadas capturadas en el almacenamiento privado de la aplicación (sección 3), que nunca requiere permisos, y accede a la galería mediante el selector de medios del sistema (sección 6), que tampoco los requiere. READ_MEDIA_IMAGES y READ_EXTERNAL_STORAGE no aparecen en ningún punto del proyecto. Además, minSdk = 30 elimina de una todas las ramas de compatibilidad anteriores a Android 11.


1. Punto de partida: qué aporta añadir multimedia a AppDummy#

Al terminar el Bloque 3 la aplicación ya es funcional: guarda una biblioteca en Room, la sincroniza con Open Library y muestra las portadas que descarga de la red con Coil. Pero todas las imágenes de AppDummy son ajenas: vienen de un servidor y, si Open Library no tiene portada de un libro, la pantalla muestra nocover.jpg.

Este tema añade la primera aportación de contenido del propio usuario:

  • Portada propia. El usuario fotografía la cubierta de su ejemplar con la cámara, o elige una imagen de su galería, y esa imagen sustituye a la de Open Library en el listado y en el detalle.
  • Muestra de audio. Una pantalla reproduce un fragmento de audiolibro con MediaPlayer, con controles de reproducción y barra de progreso.
  • Vídeo. Un reproductor basado en ExoPlayer muestra un vídeo de presentación, con la gestión correcta del ciclo de vida.

El hilo conductor del tema no son las APIs multimedia en sí, sino dónde encaja cada una en la arquitectura que ya existe. Una foto no es solo un Bitmap: es un fichero que hay que almacenar, una ruta que hay que persistir en Room y un estado que la interfaz observa. La cámara es un origen de datos más, y por tanto entra por la capa de datos, no por la interfaz.

┌──────────────────────────────────────────────────────────────────────┐
│  UI (PantallaDetalle, PantallaCamara)                                │
│  lanza la captura y observa el estado                                │
└───────────────────────────────┬──────────────────────────────────────┘
                                │ Uri de la imagen capturada
                                ▼
┌──────────────────────────────────────────────────────────────────────┐
│  DetalleViewModel                                                    │
│  asignarPortadaLocal(uri) → copia el fichero y persiste la ruta      │
└───────────────────────────────┬──────────────────────────────────────┘
                                │
                                ▼
┌──────────────────────────────────────────────────────────────────────┐
│  LibrosRepository ──► LocalDataSource ──► LibrosDao                  │
│                        (columna portada_local de la tabla libros)    │
└──────────────────────────────────────────────────────────────────────┘

Este es el recorrido completo del tema. Las secciones siguientes lo construyen de abajo arriba.


2. Permisos peligrosos en tiempo de ejecución#

Android clasifica los permisos en normales y peligrosos. Los normales (INTERNET, ACCESS_NETWORK_STATE) se conceden en el momento de la instalación con solo declararlos. Los peligrosos (CAMERA, RECORD_AUDIO) afectan a la privacidad del usuario y, desde Android 6.0 (API 23), deben solicitarse en tiempo de ejecución y pueden revocarse en cualquier momento desde los ajustes del sistema.

En el Bloque 1 se construyó PantallaGestionPermisos, que solicitaba el permiso de cámara con un estado modelado como sealed class. Ahora se generalizará ese trabajo en un componente reutilizable que envolverá cualquier contenido que necesite un permiso.

El contrato RequestPermission#

Compose no accede directamente al sistema de permisos: usa la Activity Result API. rememberLauncherForActivityResult() devuelve un lanzador asociado a un contrato, y el resultado llega al callback cuando el usuario responde al diálogo del sistema.

Recuerda, un callback es una función que se pasa como argumento a otra función o método, para que esta la invoque (la “llame de vuelta”) en un momento determinado, normalmente cuando finaliza una tarea o se produce un evento. Se usa mucho en programación asíncrona y en el manejo de eventos, permitiendo definir qué código debe ejecutarse una vez que otra operación ha terminado, sin bloquear el flujo principal del programa.

  1import android.app.Activity
  2import android.content.Intent
  3import android.content.pm.PackageManager
  4import android.net.Uri
  5import android.provider.Settings
  6import androidx.activity.compose.rememberLauncherForActivityResult
  7import androidx.activity.result.contract.ActivityResultContracts
  8import androidx.compose.foundation.layout.*
  9import androidx.compose.material.icons.Icons
 10import androidx.compose.material.icons.filled.Lock
 11import androidx.compose.material3.*
 12import androidx.compose.runtime.*
 13import androidx.compose.ui.Alignment
 14import androidx.compose.ui.Modifier
 15import androidx.compose.ui.platform.LocalContext
 16import androidx.compose.ui.text.style.TextAlign
 17import androidx.compose.ui.unit.dp
 18import androidx.core.app.ActivityCompat
 19import androidx.core.content.ContextCompat
 20
 21// ─── screens/componentes/SolicitudPermiso.kt ─────────────────────────────────────────────────────
 22
 23/**
 24 * Envuelve un contenido que necesita un permiso peligroso.
 25 *
 26 * Muestra [contenido] únicamente cuando el permiso está concedido; en caso
 27 * contrario presenta una explicación y el botón adecuado a cada situación.
 28 *
 29 * @param permiso constante de [android.Manifest.permission].
 30 * @param explicacion texto que justifica ante el usuario por qué se necesita.
 31 * @param contenido interfaz protegida por el permiso.
 32 */
 33@Composable
 34fun SolicitudPermiso(
 35    permiso: String,
 36    explicacion: String,
 37    contenido: @Composable () -> Unit
 38) {
 39    val context = LocalContext.current
 40
 41    // Estado inicial: ¿el permiso ya estaba concedido de una sesión anterior?
 42    var concedido by remember {
 43        mutableStateOf(
 44            ContextCompat.checkSelfPermission(context, permiso) ==
 45                PackageManager.PERMISSION_GRANTED
 46        )
 47    }
 48
 49    // Controla si ya se ha mostrado el diálogo del sistema en esta pantalla.
 50    var solicitado by remember { mutableStateOf(false) }
 51
 52    // Lanzador del diálogo del sistema.
 53    val lanzador = rememberLauncherForActivityResult(
 54        contract = ActivityResultContracts.RequestPermission()
 55    ) { resultado ->
 56        concedido = resultado
 57        solicitado = true
 58    }
 59
 60    when {
 61        // 1. Permiso concedido: se muestra la interfaz real.
 62        concedido -> contenido()
 63
 64        // 2. Primera vez: se pide directamente, sin pantallas intermedias.
 65        !solicitado -> LaunchedEffect(permiso) { lanzador.launch(permiso) }
 66
 67        // 3. Denegado: hay que distinguir dos casos muy distintos.
 68        else -> {
 69            // shouldShowRequestPermissionRationale devuelve true si el usuario
 70            // ha denegado el permiso pero el sistema todavía permite volver a
 71            // pedirlo. Si devuelve false tras una denegación, el usuario ha
 72            // marcado "no volver a preguntar": el diálogo ya no aparecerá y la
 73            // única salida son los ajustes del sistema.
 74            val puedeReintentar = ActivityCompat.shouldShowRequestPermissionRationale(
 75                context as Activity, permiso
 76            )
 77
 78            Column(
 79                modifier = Modifier.fillMaxSize().padding(32.dp),
 80                horizontalAlignment = Alignment.CenterHorizontally,
 81                verticalArrangement = Arrangement.Center
 82            ) {
 83                Icon(
 84                    Icons.Default.Lock,
 85                    contentDescription = null,
 86                    modifier = Modifier.size(64.dp),
 87                    tint = MaterialTheme.colorScheme.onSurfaceVariant
 88                )
 89                Spacer(Modifier.height(16.dp))
 90                Text(
 91                    text = explicacion,
 92                    style = MaterialTheme.typography.bodyLarge,
 93                    textAlign = TextAlign.Center
 94                )
 95                Spacer(Modifier.height(24.dp))
 96
 97                if (puedeReintentar) {
 98                    Button(onClick = { lanzador.launch(permiso) }) {
 99                        Text("Conceder permiso")
100                    }
101                } else {
102                    OutlinedButton(onClick = {
103                        // Intent implícito hacia la ficha de la app en Ajustes (T1D).
104                        context.startActivity(
105                            Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
106                                data = Uri.fromParts("package", context.packageName, null)
107                            }
108                        )
109                    }) {
110                        Text("Abrir Ajustes del sistema")
111                    }
112                }
113            }
114        }
115    }
116}

Los tres estados importan. Un error habitual es tratar “denegado” como un único caso. Si el usuario ha marcado no volver a preguntar, volver a llamar a lanzador.launch() no muestra nada: el sistema devuelve false de inmediato y la aplicación parecerá rota. Por eso se consulta shouldShowRequestPermissionRationale() y se ofrece un camino alternativo hacia los ajustes.

Para solicitar varios permisos a la vez, el caso de la grabación de vídeo, que necesita cámara y micrófono, existe el contrato RequestMultiplePermissions, que devuelve un Map<String, Boolean>:

 1val lanzadorMultiple = rememberLauncherForActivityResult(
 2    contract = ActivityResultContracts.RequestMultiplePermissions()
 3) { resultados: Map<String, Boolean> ->
 4    val camaraOk = resultados[Manifest.permission.CAMERA] == true
 5    val audioOk  = resultados[Manifest.permission.RECORD_AUDIO] == true
 6    // El usuario puede conceder uno y denegar el otro: hay que comprobar ambos.
 7}
 8
 9// El lanzamiento se hace siempre desde un manejador de eventos, nunca en la composición.
10Button(onClick = {
11    lanzadorMultiple.launch(
12        arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
13    )
14}) { Text("Grabar vídeo") }

3. ¿Dónde se guarda una foto? Decisión de almacenamiento#

Antes de escribir una sola línea de CameraX hay que responder a una pregunta de diseño: la portada que captura el usuario, ¿es una foto suya, que debe aparecer en la galería del móvil, o es un dato interno de AppDummy?

Almacenamiento privado (filesDir) MediaStore (almacenamiento compartido)
Permisos Ninguno, nunca Ninguno para escribir contenido propio
Visible en la galería No Sí
Al desinstalar la app Se borra Permanece
Otras apps pueden leerlo No (salvo FileProvider) Sí
Uso típico Datos internos: cachés, adjuntos, miniaturas Fotos y vídeos del usuario

En AppDummy la portada es un dato de la aplicación, equivalente a la URL cover que devuelve Open Library. Interesa que se borre al desinstalar y que no ensucie la galería del usuario. Por tanto, se guarda en context.filesDir, en una subcarpeta portadas/, con un nombre derivado del identificador del libro.

 1import android.content.Context
 2import android.net.Uri
 3import kotlinx.coroutines.Dispatchers
 4import kotlinx.coroutines.withContext
 5import java.io.File
 6
 7// ─── utils/AlmacenPortadas.kt ────────────────────────────────────────────────────────────────────
 8
 9/**
10 * Gestiona los ficheros de portada dentro del almacenamiento privado de la app.
11 * No necesita permisos: `filesDir` pertenece a la propia aplicación.
12 */
13object AlmacenPortadas {
14
15    private const val CARPETA = "portadas"
16
17    private fun carpeta(context: Context): File =
18        File(context.filesDir, CARPETA).apply { mkdirs() }
19
20    private fun prefijo(libroId: String): String = libroId.substringAfterLast('/')
21
22    /** Devuelve un fichero NUEVO en cada llamada: el nombre lleva marca de tiempo. */
23    fun nuevoFicheroPara(context: Context, libroId: String): File =
24        File(carpeta(context), "${prefijo(libroId)}-${System.currentTimeMillis()}.jpg")
25
26    /** Borra las portadas anteriores del libro, conservando la indicada. */
27    fun limpiarAnteriores(context: Context, libroId: String, conservar: File? = null) {
28        carpeta(context).listFiles()
29            ?.filter { it.name.startsWith("${prefijo(libroId)}-") && it != conservar }
30            ?.forEach { it.delete() }
31    }
32
33    suspend fun copiarDesdeUri(
34        context: Context,
35        origen: Uri,
36        libroId: String
37    ): File? = withContext(Dispatchers.IO) {
38        runCatching {
39            // Viene de la cámara: el fichero ya está escrito en nuestra carpeta.
40            if (origen.scheme == "file") {
41                val yaEsNuestro = origen.path?.let { File(it) }
42                if (yaEsNuestro != null &&
43                    yaEsNuestro.parentFile == carpeta(context) &&
44                    yaEsNuestro.length() > 0
45                ) return@runCatching yaEsNuestro
46            }
47
48            val destino = nuevoFicheroPara(context, libroId)
49            context.contentResolver.openInputStream(origen)?.use { entrada ->
50                destino.outputStream().use { salida -> entrada.copyTo(salida) }
51            } ?: return@runCatching null
52            destino
53        }.getOrNull()
54    }
55}

use cierra siempre. openInputStream() y outputStream() devuelven flujos que deben cerrarse pase lo que pase. La función de extensión use { } de Kotlin cierra el recurso al salir del bloque, incluso si se lanza una excepción: es el equivalente al try-with-resources de Java visto en PSP.


4. Persistir la portada: Room, DAO y Repository#

Con el fichero resuelto, falta la parte que hace que la portada sobreviva al cierre de la aplicación: su ruta debe guardarse en la base de datos, junto al resto de datos del libro.

4.1 La entidad#

 1@Entity(
 2    tableName = "libros",
 3    indices = [
 4        Index(value = ["titulo"]),
 5        Index(value = ["autor", "isbn"], unique = false)
 6    ]
 7)
 8data class Libro(
 9    @PrimaryKey val id: String,
10    @ColumnInfo(name = "titulo") val titulo: String,
11    @ColumnInfo(name = "autor")  val autor: String,
12    @ColumnInfo(name = "year")   val year: Int? = 1900,
13    @ColumnInfo(name = "isbn")   val isbn: String,
14    @ColumnInfo(name = "cover")  val cover: String? = null,   // URL remota (T5)
15
16    @ColumnInfo(name = "es_favorito") val esFavorito: Boolean = false,
17    @ColumnInfo(name = "leido")       val leido: Boolean = false,
18    @ColumnInfo(name = "actualizado_en") val actualizadoEn: Long = 0L,   // T6
19
20    // ─── NUEVO en T7 ─────────────────────────────────────────────────────
21    // Ruta absoluta del fichero de portada dentro de filesDir.
22    // null significa "este libro no tiene portada propia": se usará la de
23    // Open Library o, en su defecto, nocover.jpg.
24    @ColumnInfo(name = "portada_local") val portadaLocal: String? = null
25)

Añadir una columna obliga a incrementar la versión de la base de datos a 3 en @Database. Como AppDatabase se construye con fallbackToDestructiveMigration(dropAllTables = true), Room borrará y recreará las tablas y la biblioteca de pruebas se perderá; es el mismo comportamiento que ya ocurrió en T6. En el Anexo B4-A1 y en el Anexo B3-A1 se explica cómo escribir una Migration real.

Cuidado con la sincronización. portada_local es un campo exclusivamente local, como es_favorito y leido: Open Library no lo conoce. Debe quedar fuera de actualizarDesdeRed() y de upsertConservandoFavorito(), igual que se hizo en T6 con los favoritos. Si se olvida, la primera sincronización borrará todas las portadas propias del usuario.

4.2 DAO y origen de datos local#

 1// ─── Añadir a LibrosDao (T7) ─────────────────────────────────────────────────
 2@Dao
 3interface LibrosDao {
 4
 5    // ... consultas de T4 y T6 ...
 6
 7    /**
 8     * Guarda (o borra, si [ruta] es null) la ruta de la portada propia.
 9     * Es una actualización selectiva: no toca ninguna otra columna.
10     */
11    @Query("UPDATE libros SET portada_local = :ruta WHERE id = :id")
12    suspend fun actualizarPortadaLocal(id: String, ruta: String?)
13}
1// ─── Añadir a LocalDataSource (T7) ───────────────────────────────────────────
2suspend fun actualizarPortadaLocal(id: String, ruta: String?) =
3    dao.actualizarPortadaLocal(id, ruta)
 1// ─── Añadir a LibrosRepository (T7) ──────────────────────────────────────────
 2/**
 3 * Registra la portada propia de un libro.
 4 * El repositorio no sabe nada de cámaras ni de galerías: recibe una ruta
 5 * de fichero ya resuelta. Es lo que mantiene la capa de datos independiente
 6 * del framework multimedia.
 7 */
 8suspend fun guardarPortadaLocal(libroId: String, ruta: String?) {
 9    localDataSource.actualizarPortadaLocal(libroId, ruta)
10}

4.3 El ViewModel#

DetalleViewModel necesita ahora un Context para copiar el fichero. Se resuelve con AndroidViewModel, que recibe la Application, un contexto de ámbito de aplicación que no provoca fugas de memoria, a diferencia del de una Activity.

 1import android.app.Application
 2import android.net.Uri
 3import androidx.lifecycle.AndroidViewModel
 4import androidx.lifecycle.ViewModelProvider
 5import androidx.lifecycle.viewModelScope
 6import androidx.lifecycle.viewmodel.initializer
 7import androidx.lifecycle.viewmodel.viewModelFactory
 8import com.ejemplo.appdummy.AppDummyApplication
 9import com.ejemplo.appdummy.data.repository.LibrosRepository
10import com.ejemplo.appdummy.utils.AlmacenPortadas
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.launch
15
16// ─── screens/detalle/DetalleViewModel.kt — ampliado en T7 ────────────────────────────────────────
17class DetalleViewModel(
18    application: Application,
19    private val libroId: String,
20    private val repository: LibrosRepository
21) : AndroidViewModel(application) {   // ← antes era ViewModel
22
23    private val _uiState = MutableStateFlow<DetalleUiState>(DetalleUiState.Cargando)
24    val uiState: StateFlow<DetalleUiState> = _uiState.asStateFlow()
25
26    init { cargarDetalle() }
27
28    private fun cargarDetalle() {
29        viewModelScope.launch {
30            val libro = repository.obtenerPorId(libroId)
31            _uiState.value = if (libro != null) DetalleUiState.Exito(libro)
32            else DetalleUiState.NoEncontrado
33        }
34    }
35
36    /**
37     * Recibe la Uri de una imagen (venga de la cámara o del selector de medios),
38     * la copia al almacenamiento privado y persiste la ruta resultante.
39     *
40     * La UI solo entrega una Uri: no sabe dónde acaba el fichero ni cómo se guarda.
41     */
42    fun asignarPortadaLocal(uri: Uri) {
43        viewModelScope.launch {
44            val contexto = getApplication<Application>()
45            val fichero = AlmacenPortadas.copiarDesdeUri(contexto, uri, libroId)
46            repository.guardarPortadaLocal(libroId, fichero?.absolutePath)
47            AlmacenPortadas.limpiarAnteriores(contexto, libroId, conservar = fichero)
48            cargarDetalle()
49        }
50    }
51
52    /** Elimina la portada propia y vuelve a mostrar la de Open Library. */
53    fun quitarPortadaLocal() {
54        viewModelScope.launch {
55            val contexto = getApplication<Application>()
56            repository.guardarPortadaLocal(libroId, null)
57            AlmacenPortadas.limpiarAnteriores(contexto, libroId, conservar = null)
58            cargarDetalle()
59        }
60    }
61
62    companion object {
63        fun factoryConId(id: String): ViewModelProvider.Factory = viewModelFactory {
64            initializer {
65                val app = checkNotNull(
66                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
67                ) as AppDummyApplication
68                DetalleViewModel(
69                    application = app,
70                    libroId = id,
71                    repository = app.container.librosRepository
72                )
73            }
74        }
75    }
76}

4.4 Mostrar la portada: prioridad de orígenes#

CaratulaLibro ya existe desde T5. Solo hay que anteponer la portada local al resto de orígenes. Coil acepta indistintamente una String con una URL, un File o una Uri, así que el cambio es mínimo:

 1@Composable
 2fun CaratulaLibro(
 3    coverUrl: String?,
 4    portadaLocal: String?,          // ← NUEVO en T7
 5    titulo: String,
 6    modifier: Modifier = Modifier
 7) {
 8    // Prioridad: portada propia del usuario > portada de Open Library > nocover.jpg
 9    val origen: Any? = portadaLocal?.let { File(it) } ?: coverUrl
10
11    AsyncImage(
12        model = ImageRequest.Builder(LocalContext.current)
13            .data(origen)
14            .crossfade(true)
15            .build(),
16        placeholder = painterResource(R.drawable.nocover),
17        error = painterResource(R.drawable.nocover),
18        contentDescription = "Portada de $titulo",
19        contentScale = ContentScale.Crop,
20        modifier = modifier
21            .aspectRatio(2f / 3f)
22            .clip(RoundedCornerShape(8.dp))
23    )
24}

Coil y la caché. Si se sustituye la portada de un libro por otra reutilizando el mismo nombre de fichero, Coil puede seguir mostrando la anterior porque la tiene en su caché de memoria. La solución más sencilla en clase es añadir .memoryCacheKey(origen.toString()).diskCacheKey(origen.toString()) a la petición, o incluir la fecha de modificación del fichero en la clave. Es un buen ejemplo de que una caché siempre acaba pidiendo una política de invalidación.


5. CameraX: captura de fotografías#

CameraX es la biblioteca de cámara recomendada por Google. Se apoya internamente en la API Camera2 del sistema, pero oculta su complejidad y, sobre todo, absorbe las diferencias de comportamiento entre fabricantes gracias a un laboratorio de compatibilidad que Google mantiene con dispositivos reales. Además, se integra con el ciclo de vida de los componentes Jetpack, lo que evita un error muy común, dejar la cámara abierta al salir de la pantalla.

5.1 Arquitectura: casos de uso#

CameraX no expone “la cámara”, sino casos de uso que se vinculan a un LifecycleOwner y se combinan entre sí:

Caso de uso Clase Para qué sirve
Vista previa Preview Mostrar en pantalla lo que ve la cámara
Captura de imagen ImageCapture Tomar fotografías
Análisis de imagen ImageAnalysis Procesar fotogramas en tiempo real (códigos QR, ML Kit)
Captura de vídeo VideoCapture<Recorder> Grabar vídeo con audio

La mayoría de dispositivos admite combinar tres casos de uso simultáneamente; Preview + ImageCapture está garantizado en todos.

El objeto que orquesta todo es ProcessCameraProvider, un singleton de ámbito de proceso. Se obtiene de forma asíncrona porque su inicialización implica consultar el hardware disponible.

5.2 PreviewView dentro de Compose#

PreviewView es una View clásica del sistema de vistas, no un Composable. Para integrarla en Compose se envuelve con AndroidView, el mecanismo de interoperabilidad que ya se usó en el Bloque 1.

AndroidView tiene dos parámetros clave:

  • factory: se ejecuta una sola vez, cuando el Composable entra en la composición. Aquí se crea e inicializa la vista.
  • update: se ejecuta en cada recomposición en la que cambie algún estado leído dentro del bloque. Aquí se sincroniza la vista con el estado de Compose.
  1package com.ejemplo.appdummy.screens.camara
  2
  3import android.Manifest
  4import android.net.Uri
  5import android.util.Log
  6import android.view.ViewGroup
  7import androidx.camera.core.CameraSelector
  8import androidx.camera.core.ImageCapture
  9import androidx.camera.core.ImageCaptureException
 10import androidx.camera.core.Preview
 11import androidx.camera.lifecycle.ProcessCameraProvider
 12import androidx.camera.view.PreviewView
 13import androidx.compose.foundation.layout.*
 14import androidx.compose.material.icons.Icons
 15import androidx.compose.material.icons.automirrored.filled.ArrowBack
 16import androidx.compose.material.icons.filled.Camera
 17import androidx.compose.material.icons.filled.Cameraswitch
 18import androidx.compose.material3.*
 19import androidx.compose.runtime.*
 20import androidx.compose.ui.Alignment
 21import androidx.compose.ui.Modifier
 22import androidx.compose.ui.platform.LocalContext
 23import androidx.compose.ui.unit.dp
 24import androidx.compose.ui.viewinterop.AndroidView
 25import androidx.core.content.ContextCompat
 26import androidx.lifecycle.compose.LocalLifecycleOwner
 27import java.io.File
 28
 29// ─── screens/camara/PantallaCamara.kt ────────────────────────────────────────────────────────────
 30@Composable
 31fun PantallaCamara(
 32    libroId: String,
 33    onFotoCapturada: (Uri) -> Unit,
 34    onVolver: () -> Unit
 35) {
 36    // Toda la pantalla queda protegida por el permiso de cámara.
 37    SolicitudPermiso(
 38        permiso = Manifest.permission.CAMERA,
 39        explicacion = "AppDummy necesita la cámara para fotografiar la portada de tus libros."
 40    ) {
 41        ContenidoCamara(libroId, onFotoCapturada, onVolver)
 42    }
 43}
 44
 45@OptIn(ExperimentalMaterial3Api::class)
 46@Composable
 47private fun ContenidoCamara(
 48    libroId: String,
 49    onFotoCapturada: (Uri) -> Unit,
 50    onVolver: () -> Unit
 51) {
 52    val context = LocalContext.current
 53    val lifecycleOwner = LocalLifecycleOwner.current
 54
 55    // ── Casos de uso ────────────────────────────────────────────────────────
 56    // remember evita que se recreen en cada recomposición: si se recrearan,
 57    // la cámara se reiniciaría constantemente y la vista previa parpadearía.
 58    val preview = remember { Preview.Builder().build() }
 59    val imageCapture = remember {
 60        ImageCapture.Builder()
 61            // MINIMIZE_LATENCY prioriza la rapidez del disparo;
 62            // MAXIMIZE_QUALITY aplicaría más procesado a costa de tardar más.
 63            .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
 64            .build()
 65    }
 66
 67    // Cámara seleccionada; es estado porque el usuario puede cambiarla.
 68    var selector by remember { mutableStateOf(CameraSelector.DEFAULT_BACK_CAMERA) }
 69    var capturando by remember { mutableStateOf(false) }
 70
 71    // La vista previa se guarda en estado para poder revincular los casos de
 72    // uso cuando cambia la cámara seleccionada.
 73    var previewView by remember { mutableStateOf<PreviewView?>(null) }
 74
 75    // Vinculación de los casos de uso al ciclo de vida.
 76    // Se relanza cuando cambia el selector o cuando la vista ya está creada.
 77    LaunchedEffect(selector, previewView) {
 78        val vista = previewView ?: return@LaunchedEffect
 79        // awaitInstance es la versión suspend de getInstance(): permite esperar
 80        // al proveedor sin bloquear el hilo principal ni usar callbacks.
 81        val cameraProvider = ProcessCameraProvider.awaitInstance(context)
 82        preview.surfaceProvider = vista.surfaceProvider
 83        try {
 84            // Antes de vincular hay que desvincular: la cámara es un recurso
 85            // exclusivo y no admite dos vinculaciones simultáneas.
 86            cameraProvider.unbindAll()
 87            cameraProvider.bindToLifecycle(lifecycleOwner, selector, preview, imageCapture)
 88        } catch (e: Exception) {
 89            Log.e("CameraX", "Error al vincular los casos de uso", e)
 90        }
 91    }
 92
 93    Scaffold(
 94        topBar = {
 95            TopAppBar(
 96                title = { Text("Portada") },
 97                navigationIcon = {
 98                    IconButton(onClick = onVolver) {
 99                        Icon(Icons.AutoMirrored.Filled.ArrowBack, "Volver")
100                    }
101                },
102                actions = {
103                    IconButton(onClick = {
104                        selector = if (selector == CameraSelector.DEFAULT_BACK_CAMERA)
105                            CameraSelector.DEFAULT_FRONT_CAMERA
106                        else
107                            CameraSelector.DEFAULT_BACK_CAMERA
108                    }) {
109                        Icon(Icons.Default.Cameraswitch, "Cambiar de cámara")
110                    }
111                }
112            )
113        }
114    ) { padding ->
115        Box(modifier = Modifier.fillMaxSize().padding(padding)) {
116
117            AndroidView(
118                factory = { ctx ->
119                    PreviewView(ctx).apply {
120                        layoutParams = ViewGroup.LayoutParams(
121                            ViewGroup.LayoutParams.MATCH_PARENT,
122                            ViewGroup.LayoutParams.MATCH_PARENT
123                        )
124                        // FILL_CENTER recorta para llenar la vista;
125                        // FIT_CENTER mostraría bandas negras.
126                        scaleType = PreviewView.ScaleType.FILL_CENTER
127                        previewView = this
128                    }
129                },
130                modifier = Modifier.fillMaxSize()
131            )
132
133            // Botón de disparo.
134            FilledIconButton(
135                onClick = {
136                    if (capturando) return@FilledIconButton
137                    capturando = true
138                    capturarFoto(
139                        imageCapture = imageCapture,
140                        destino = AlmacenPortadas.nuevoFicheroPara(context, libroId),
141                        context = context,
142                        onExito = { uri -> capturando = false; onFotoCapturada(uri) },
143                        onError = { capturando = false }
144                    )
145                },
146                modifier = Modifier
147                    .align(Alignment.BottomCenter)
148                    .padding(bottom = 32.dp)
149                    .size(72.dp)
150            ) {
151                if (capturando) {
152                    CircularProgressIndicator(
153                        modifier = Modifier.size(32.dp),
154                        color = MaterialTheme.colorScheme.onPrimary,
155                        strokeWidth = 3.dp
156                    )
157                } else {
158                    Icon(Icons.Default.Camera, "Capturar", modifier = Modifier.size(36.dp))
159                }
160            }
161        }
162    }
163}

5.3 Guardar la fotografía#

ImageCapture.takePicture() es asíncrono: no devuelve la imagen, sino que avisa por callback cuando el fichero ya está escrito. La variante que usamos escribe directamente en un File sin pasar por MediaStore, coherente con la decisión de almacenamiento privado del apartado 3.

El almacén de ficheros#

Antes de capturar hay que decidir dónde se escribe. Ese es el trabajo de AlmacenPortadas, que ahora genera un nombre distinto en cada captura:

 1// ─── data/almacen/AlmacenPortadas.kt ─────────────────────────────────────────────────────────────
 2
 3object AlmacenPortadas {
 4
 5    private const val CARPETA = "portadas"
 6
 7    private fun carpeta(context: Context): File =
 8        File(context.filesDir, CARPETA).apply { mkdirs() }
 9
10    /**
11     * Los identificadores de Open Library son rutas ("/works/OL45804W"),
12     * y la barra no es válida en un nombre de fichero: nos quedamos con el último segmento.
13     */
14    private fun prefijo(libroId: String): String = libroId.substringAfterLast('/')
15
16    /**
17     * Devuelve un fichero NUEVO en cada llamada.
18     *
19     * La marca de tiempo no es un capricho: de ella depende que, al rehacer la
20     * foto, cambie la ruta almacenada en Room. Si el nombre fuese fijo, la
21     * entidad quedaría idéntica tras la segunda captura, el StateFlow
22     * descartaría el valor por ser `equals` al anterior y la pantalla no se
23     * refrescaría (véase el apartado 7.5).
24     */
25    fun nuevoFicheroPara(context: Context, libroId: String): File =
26        File(carpeta(context), "${prefijo(libroId)}-${System.currentTimeMillis()}.jpg")
27
28    /**
29     * Copia el contenido de una Uri ajena (la que devuelve el selector de
30     * medios) a un fichero propio recién creado.
31     */
32    suspend fun copiarDesdeUri(
33        context: Context,
34        origen: Uri,
35        libroId: String
36    ): File? = withContext(Dispatchers.IO) {
37        runCatching {
38            val destino = nuevoFicheroPara(context, libroId)
39            context.contentResolver.openInputStream(origen)?.use { entrada ->
40                destino.outputStream().use { salida -> entrada.copyTo(salida) }
41            } ?: return@runCatching null
42            destino
43        }.getOrNull()
44    }
45
46    /**
47     * Borra las portadas anteriores del libro, conservando la indicada.
48     * Con `conservar = null` elimina todas: es lo que necesita "quitar portada".
49     */
50    fun limpiarAnteriores(context: Context, libroId: String, conservar: File? = null) {
51        carpeta(context).listFiles()
52            ?.filter { it.name.startsWith("${prefijo(libroId)}-") && it != conservar }
53            ?.forEach { it.delete() }
54    }
55}

Un nombre por captura, no un nombre por libro. Sobrescribir siempre el mismo fichero parece más ordenado, pero deja a la aplicación sin ninguna señal de que la imagen ha cambiado: ni Room, ni Compose, ni la caché de Coil trabajan con el contenido del fichero, sino con su ruta. Cambiar el nombre es la forma más barata de que los tres se enteren a la vez. La limpieza posterior evita que la carpeta crezca sin control.

La captura#

 1// ─── screens/camara/PantallaCamara.kt ─ ampliación ───────────────────────────────────────────────
 2/**
 3 * Dispara la cámara y guarda el resultado en [destino].
 4 *
 5 * @param onExito recibe la Uri del fichero escrito.
 6 */
 7private fun capturarFoto(
 8    imageCapture: ImageCapture,
 9    destino: File,
10    context: Context,
11    onExito: (Uri) -> Unit,
12    onError: () -> Unit
13) {
14    // OutputFileOptions decide el destino. Con un File, la imagen se escribe
15    // en almacenamiento privado y no se registra en la galería del sistema.
16    val opciones = ImageCapture.OutputFileOptions.Builder(destino).build()
17
18    imageCapture.takePicture(
19        opciones,
20        // El callback se ejecutará en el hilo principal: podemos tocar el estado de Compose.
21        ContextCompat.getMainExecutor(context),
22        object : ImageCapture.OnImageSavedCallback {
23
24            override fun onImageSaved(resultado: ImageCapture.OutputFileResults) {
25                // savedUri solo tiene valor cuando la salida es MediaStore.
26                // Al escribir en un File, viene a null y construimos la Uri nosotros.
27                onExito(resultado.savedUri ?: Uri.fromFile(destino))
28            }
29
30            override fun onError(excepcion: ImageCaptureException) {
31                Log.e("CameraX", "Error al guardar la fotografía", excepcion)
32                onError()
33            }
34        }
35    )
36}

onExito entrega una Uri, y esa Uri importa. Es el único hilo que conecta la captura con el resto de la aplicación: contiene la ruta del fichero recién escrito. El apartado 7 se ocupa de llevarla hasta el ViewModel; si se descarta, la fotografía queda en disco pero la aplicación nunca se entera de que existe.

Un fallo silencioso que conviene conocer. onError solo escribe en Logcat y devuelve el botón a su estado normal. Si una captura falla, el usuario no ve ningún aviso. Como ampliación, se propone elevar el error a un Snackbar desde PantallaCamara.

Ampliación: camera-compose. Desde CameraX 1.6.0 existe androidx.camera:camera-compose, que aporta CameraXViewfinder como alternativa nativa a envolver PreviewView en un AndroidView. Mantenemos PreviewView por ser la vía documentada oficialmente y la que el alumno encontrará en la mayoría de ejemplos.


6. El selector de medios del sistema#

Además de hacer la foto, el usuario debe poder elegir una imagen que ya tiene. Para eso Android ofrece el selector de fotos (photo picker): una interfaz del sistema, ajena a nuestra aplicación, que devuelve únicamente los elementos que la persona ha señalado.

Existe de forma nativa desde Android 13 (API 33) y está retroportado a versiones anteriores a través de Google Play Services, de modo que el contrato funciona en todo el rango de API del proyecto.

Sin permisos, y esto no es un atajo. La aplicación no obtiene acceso al almacenamiento: solo recibe una Uri por cada elemento elegido. Por eso no hay que declarar READ_MEDIA_IMAGES ni READ_EXTERNAL_STORAGE, ni solicitar nada en tiempo de ejecución. Es el mismo principio de mínimo privilegio que en T1D justificaba pedir permisos solo cuando no hay alternativa: aquí, sencillamente, no hace falta.

PickVisualMedia ACTION_GET_CONTENT
Permisos Ninguno Puede requerir permisos de almacenamiento
Alcance Solo los elementos elegidos Depende del proveedor
Interfaz La del sistema, uniforme La del explorador de ficheros
Selección múltiple PickMultipleVisualMedia Con EXTRA_ALLOW_MULTIPLE
Duración del acceso Temporal, hasta reiniciar el proceso Temporal

6.1. El componente SelectorPortada#

 1package com.ejemplo.appdummy.screens.componentes
 2
 3import android.net.Uri
 4import androidx.activity.compose.rememberLauncherForActivityResult
 5import androidx.activity.result.PickVisualMediaRequest
 6import androidx.activity.result.contract.ActivityResultContracts
 7import androidx.compose.foundation.layout.*
 8import androidx.compose.material.icons.Icons
 9import androidx.compose.material.icons.filled.CameraAlt
10import androidx.compose.material.icons.filled.Photo
11import androidx.compose.material3.*
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.unit.dp
15
16// ─── screens/componentes/SelectorPortada.kt ──────────────────────────────────────────────────────
17
18/**
19 * Ofrece las dos vías para conseguir una portada propia.
20 *
21 * @param onImagenElegida se invoca con la Uri devuelta por el selector del sistema.
22 * @param onAbrirCamara se invoca al pulsar "Hacer foto"; solo dispara la navegación,
23 *                      porque en ese instante todavía no existe ninguna imagen.
24 */
25@Composable
26fun SelectorPortada(
27    onImagenElegida: (Uri) -> Unit,
28    onAbrirCamara: () -> Unit,
29    modifier: Modifier = Modifier
30) {
31    val lanzador = rememberLauncherForActivityResult(
32        contract = ActivityResultContracts.PickVisualMedia()
33    ) { uri ->
34        // uri es null si el usuario cierra el selector sin elegir nada.
35        uri?.let(onImagenElegida)
36    }
37
38    Row(
39        horizontalArrangement = Arrangement.spacedBy(8.dp),
40        modifier = modifier
41    ) {
42        OutlinedButton(onClick = onAbrirCamara) {
43            Icon(Icons.Default.CameraAlt, contentDescription = null)
44            Spacer(Modifier.width(8.dp))
45            Text("Hacer foto")
46        }
47
48        OutlinedButton(
49            onClick = {
50                lanzador.launch(
51                    PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
52                )
53            }
54        ) {
55            Icon(Icons.Default.Photo, contentDescription = null)
56            Spacer(Modifier.width(8.dp))
57            Text("Galería")
58        }
59    }
60}

Los dos botones no son simétricos, y conviene explicar por qué. «Galería» lanza un contrato de resultado: la respuesta vuelve al mismo composable, en el mismo lanzador, y por eso onImagenElegida puede recibir la Uri directamente. «Hacer foto» abre otra pantalla del grafo de navegación: en el momento de pulsarlo no hay ninguna imagen todavía, así que onAbrirCamara no puede llevar Uri. El resultado de la cámara viaja por otro camino, que es justamente lo que resuelve el apartado 7.

La Uri no es la imagen. Una content:// Uri es un permiso temporal de lectura sobre un elemento del proveedor, no un fichero nuestro. Pierde validez —entre otros casos— al reiniciar el proceso, así que guardarla en Room sería un error: al día siguiente apuntaría a la nada. Por eso AlmacenPortadas.copiarDesdeUri() es imprescindible: convierte un permiso efímero en un fichero propio y duradero.


7. Integración en la pantalla de detalle#

Ya están las piezas: componente de permisos, cámara, selector, almacén de ficheros y persistencia. Falta unirlas en la navegación y en la pantalla de detalle.

El problema que resuelve este apartado tiene nombre: cómo devuelve un resultado una pantalla del grafo de navegación. La galería lo tenía fácil porque el contrato responde en el mismo composable que lo lanzó. La cámara, en cambio, es un destino independiente: cuando termina, la pantalla que necesita el dato ya no está en primer plano.

7.1. Las rutas#

// ─── navegacion/Rutas.kt - ampliación ────────────────────────────────────────────────────────────

@Serializable data object Inicio
@Serializable data object Listado
@Serializable data class Detalle(val id: String)
@Serializable data object Favoritos
@Serializable data object NuevoLibro

// ─── NUEVO en T7 ─────────────────────────────────────────────────────────────
// La cámara necesita saber a qué libro pertenece la portada que va a capturar:
// el id viaja como argumento tipado de la ruta (T3).
@Serializable data class Camara(val libroId: String)
@Serializable data object Reproductor      // audio y vídeo de muestra

7.2. El ViewModel: dos orígenes, dos tratamientos#

Galería y cámara no requieren el mismo trabajo, y forzarlas a compartir función es un error clásico:

Origen Estado de la imagen al recibir la Uri Qué falta hacer
Galería Fuera de nuestro alcance, tras un permiso temporal Copiar a un fichero propio y persistir su ruta
Cámara Ya escrita en nuestra carpeta por CameraX Solo persistir la ruta
 1// ─── screens/detalle/DetalleViewModel.kt (fragmento) ─────────────────────────────────────────────
 2
 3/**
 4 * Origen: selector de medios. La Uri es ajena y temporal, hay que copiarla.
 5 */
 6fun asignarPortadaLocal(uri: Uri) {
 7    viewModelScope.launch {
 8        val contexto = getApplication<Application>()
 9        val fichero = AlmacenPortadas.copiarDesdeUri(contexto, uri, libroId)
10        repository.guardarPortadaLocal(libroId, fichero?.absolutePath)
11        AlmacenPortadas.limpiarAnteriores(contexto, libroId, conservar = fichero)
12        cargarDetalle()
13    }
14}
15
16/**
17 * Origen: cámara. CameraX ya ha escrito el JPEG en su destino definitivo,
18 * así que copiarlo sería duplicar trabajo: basta con registrar su ruta.
19 */
20fun asignarPortadaCapturada(uri: Uri) {
21    viewModelScope.launch {
22        val contexto = getApplication<Application>()
23        // Uri.fromFile() conserva la ruta absoluta en uri.path.
24        val fichero = uri.path?.let { File(it) }
25
26        if (fichero != null && fichero.exists() && fichero.length() > 0) {
27            repository.guardarPortadaLocal(libroId, fichero.absolutePath)
28            AlmacenPortadas.limpiarAnteriores(contexto, libroId, conservar = fichero)
29        }
30        cargarDetalle()
31    }
32}
33
34/**
35 * Quitar la portada propia: se olvida la ruta y se borran todos los ficheros del libro.
36 */
37fun quitarPortadaLocal() {
38    viewModelScope.launch {
39        val contexto = getApplication<Application>()
40        repository.guardarPortadaLocal(libroId, null)
41        AlmacenPortadas.limpiarAnteriores(contexto, libroId, conservar = null)
42        cargarDetalle()
43    }
44}

Nunca pases la Uri de la cámara a asignarPortadaLocal(). Parece la simplificación evidente —una sola función para los dos casos— pero provoca una copia redundante del fichero y deja un JPEG huérfano en la carpeta. Peor todavía sería si nuevoFicheroPara() devolviese siempre el mismo nombre: origen y destino coincidirían, destino.outputStream() truncaría el fichero a cero bytes antes de que copyTo() llegase a leerlo, y la portada acabaría vacía. Es un error difícil de diagnosticar porque no lanza ninguna excepción.

7.3. Devolver el resultado de la cámara#

Navigation Compose no tiene un startActivityForResult. Su mecanismo equivalente es el SavedStateHandle de la entrada anterior de la pila: la pantalla que termina deja el resultado ahí, y la que lo espera lo observa.

// ─── navegacion/AppNavigation.kt ─────────────────────────────────────────────────────────────────

/** Clave del resultado que la pantalla de cámara deja para la de detalle. */
private const val CLAVE_PORTADA = "portadaCapturada"

composable<Detalle> { backStackEntry ->
    val ruta: Detalle = backStackEntry.toRoute<Detalle>()

    // El ViewModel se crea aquí, y no como parámetro por defecto de
    // PantallaDetalle, porque el efecto de más abajo necesita invocarlo.
    val viewModel: DetalleViewModel = viewModel(factory = DetalleViewModel.factoryConId(ruta.id))

    // El resultado que dejó PantallaCamara, si lo hay.
    val uriCapturada by backStackEntry.savedStateHandle
        .getStateFlow<Uri?>(CLAVE_PORTADA, null)
        .collectAsStateWithLifecycle()

    LaunchedEffect(uriCapturada) {
        uriCapturada?.let { uri ->
            viewModel.asignarPortadaCapturada(uri)
            // Consumir el resultado: sin esto se volvería a aplicar en cada
            // recomposición o al girar el dispositivo.
            backStackEntry.savedStateHandle[CLAVE_PORTADA] = null
        }
    }

    PantallaDetalle(
        libroId = ruta.id,
        viewModel = viewModel,
        onVolver = { navController.navigateUp() },
        onAbrirCamara = { libroId -> navController.navigate(Camara(libroId = libroId)) }
    )
}

composable<Camara> { backStackEntry ->
    val ruta: Camara = backStackEntry.toRoute<Camara>()
    PantallaCamara(
        libroId = ruta.libroId,
        onFotoCapturada = { uri ->
            // La foto ya está escrita. Dejamos su Uri en la entrada anterior
            // de la pila —la de Detalle— y volvemos.
            navController.previousBackStackEntry
                ?.savedStateHandle
                ?.set(CLAVE_PORTADA, uri)
            navController.navigateUp()
        },
        onVolver = { navController.navigateUp() }
    )
}

¿Por qué Uri puede guardarse ahí? SavedStateHandle almacena lo que quepa en un Bundle. Uri implementa Parcelable, así que viaja sin conversiones y sobrevive incluso a que el sistema mate el proceso mientras la cámara está en primer plano.

Por qué hace falta el LaunchedEffect y no basta con navigateUp(). Al volver, la entrada de Detalle sigue viva en la pila con su ViewModel intacto: no se recrea nada. Y como cargarDetalle() es una consulta puntual (suspend, no Flow), la pantalla mostraría el estado de antes de la captura. El efecto es lo que dispara la relectura.

Por qué hay que poner la clave a null después. getStateFlow conserva el último valor. Si no se consume, un giro de pantalla volvería a ejecutar asignarPortadaCapturada() con la misma Uri, y la limpieza de ficheros anteriores podría borrar la portada buena.

7.4. La pantalla de detalle#

// ─── screens/detalle/PantallaDetalle.kt ──────────────────────────────────────────────────────────

@Composable
fun PantallaDetalle(
    libroId: String,
    viewModel: DetalleViewModel,          // ← MODIFICADO: ya no se crea por defecto aquí
    onVolver: () -> Unit,
    onAbrirCamara: (String) -> Unit
) {

    // ... resto de PantallaDetalle ...

    is DetalleUiState.Exito -> {
        Column(
            modifier = Modifier.fillMaxSize().padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            CaratulaLibro(
                coverUrl = estado.libro.cover,
                portadaLocal = estado.libro.portadaLocal,   // ← T7
                titulo = estado.libro.titulo,
                modifier = Modifier.width(160.dp)
            )

            SelectorPortada(
                // El ViewModel se encarga de copiar y persistir: la UI solo pasa la Uri.
                onImagenElegida = { uri -> viewModel.asignarPortadaLocal(uri) },
                // Aquí no hay Uri todavía: solo se abre la pantalla de cámara.
                onAbrirCamara = { onAbrirCamara(estado.libro.id) }
            )

            // Solo tiene sentido ofrecer "quitar" si hay portada propia.
            if (estado.libro.portadaLocal != null) {
                TextButton(onClick = { viewModel.quitarPortadaLocal() }) {
                    Text("Quitar portada propia")
                }
            }

            Text(estado.libro.titulo, style = MaterialTheme.typography.headlineMedium)
            // ... resto de la pantalla de T5 ...
        }
    }

La pantalla de detalle no conoce CameraX. Solo sabe navegar a una ruta y entregar una Uri al ViewModel. Si mañana se cambiara CameraX por otra biblioteca, PantallaDetalle no se tocaría. Es el mismo criterio que en T6 mantuvo PantallaListado ajena a la existencia de la red.

7.5. El recorrido completo#

Pulsar "Hacer foto"
   └─> onAbrirCamara(id) ──> navigate(Camara(libroId = id))
        └─> PantallaCamara: CameraX escribe portadas/OL45804W-1755600000000.jpg
             └─> onFotoCapturada(uri)
                  ├─> previousBackStackEntry.savedStateHandle[CLAVE_PORTADA] = uri
                  └─> navigateUp()
                       └─> Detalle observa el resultado (LaunchedEffect)
                            └─> DetalleViewModel.asignarPortadaCapturada(uri)
                                 ├─> repository.guardarPortadaLocal(id, ruta)   ← Room
                                 ├─> AlmacenPortadas.limpiarAnteriores(...)     ← disco
                                 └─> cargarDetalle()  ──> nuevo UiState ──> recomposición

Conviene conocer por qué la ruta con marca de tiempo hace que este recorrido funcione la segunda vez. Tres mecanismos distintos dependen de que la ruta cambie:

  1. StateFlow descarta las asignaciones iguales. Si Libro conservase la misma ruta, el DetalleUiState.Exito reconstruido sería equals al anterior y _uiState.value = ... no emitiría nada.
  2. Compose omite las recomposiciones sin cambios. Aunque el estado emitiera, CaratulaLibro recibiría el mismo String y se saltaría.
  3. Coil cachea por clave, y la clave es la ruta. Aunque recompusiera, serviría la imagen anterior desde su caché de memoria.

Con un nombre de fichero nuevo, los tres se resuelven de una vez y CaratulaLibro no necesita ninguna clave de caché manual.

Una alternativa más limpia, para cuando se domine Flow. Si el DAO devolviera Flow<LibroEntity?> y el DetalleViewModel usara stateIn, la pantalla de detalle se refrescaría sola al escribir en Room: no harían falta ni el SavedStateHandle ni el LaunchedEffect, porque la cámara podría persistir desde su propio ViewModel. Es el mismo salto de mentalidad que dio T6 al pasar de consultar a observar. Se propone como ampliación.

7.6. Comprobaciones#

# Prueba Resultado esperado
1 Elegir una imagen de la galería La portada se sustituye de inmediato
2 Hacer una foto y volver La portada se sustituye por la fotografía
3 Hacer una segunda foto del mismo libro La portada se actualiza con la nueva captura
4 Inspeccionar files/portadas/ Un único fichero por libro; los anteriores se han borrado
5 Cerrar y reabrir la aplicación La portada propia se conserva
6 «Quitar portada propia» Vuelve la portada remota y la carpeta queda vacía
7 Cancelar el selector de galería sin elegir nada No ocurre nada; la portada anterior se mantiene
8 Girar el dispositivo tras capturar La portada no se reasigna ni desaparece

Cómo inspeccionar los ficheros. En Android Studio, View ▸ Tool Windows ▸ Device Explorer, y navega hasta /data/data/com.ejemplo.appdummy/files/portadas/.


8. Reproducción de audio con MediaPlayer#

MediaPlayer forma parte del framework de Android desde la versión 1.0. Es la opción adecuada para audio local de corta o media duración: efectos de sonido, fragmentos, avisos. Para catálogos grandes, streaming adaptativo o reproducción en segundo plano, la herramienta correcta es Media3 (sección 9).

8.1 Una máquina de estados#

La dificultad de MediaPlayer no está en su API, sino en que es una máquina de estados explícita: llamar a un método en el estado equivocado lanza IllegalStateException.

      [Idle] ──setDataSource()──► [Initialized]
                                       │
                                 prepare() / prepareAsync()
                                       │
                                       ▼
                                  [Prepared] ◄──── seekTo()
                                       │
                                    start()
                                       │
                                       ▼
                   pause()  ┌──── [Started] ────┐  stop()
                            ▼                   ▼
                        [Paused] ──start()──► [Stopped] ──prepare()──► [Prepared]
                                                  │
                                          reset() │ release()
                                                   ▼
                                          [Idle]  /  [End]

Dos reglas prácticas: prepare() bloquea el hilo y solo debe usarse con recursos locales; para una URL hay que usar prepareAsync() y esperar a OnPreparedListener. Y una vez liberado con release(), el objeto entra en estado End y no puede reutilizarse.

8.2 MediaPlayer dentro de un ViewModel#

El reproductor es un recurso caro del sistema —ocupa un decodificador de hardware— y debe sobrevivir a los cambios de configuración, así que su sitio natural es el ViewModel.

  1package com.ejemplo.appdummy.screens.reproductor
  2
  3import android.app.Application
  4import android.media.AudioAttributes
  5import android.media.MediaPlayer
  6import android.util.Log
  7import androidx.annotation.RawRes
  8import androidx.lifecycle.AndroidViewModel
  9import androidx.lifecycle.viewModelScope
 10import kotlinx.coroutines.Job
 11import kotlinx.coroutines.delay
 12import kotlinx.coroutines.flow.MutableStateFlow
 13import kotlinx.coroutines.flow.StateFlow
 14import kotlinx.coroutines.flow.asStateFlow
 15import kotlinx.coroutines.launch
 16import kotlin.time.Duration.Companion.milliseconds
 17
 18// ─── screens/reproductor/AudioViewModel.kt ───────────────────────────────────────────────────────
 19class AudioViewModel(application: Application) : AndroidViewModel(application) {
 20
 21    // null mientras no hay nada preparado o después de liberar el reproductor.
 22    private var reproductor: MediaPlayer? = null
 23
 24    // Corrutina que actualiza la barra de progreso; se cancela al parar.
 25    private var trabajoProgreso: Job? = null
 26
 27    private val _reproduciendo = MutableStateFlow(false)
 28    val reproduciendo: StateFlow<Boolean> = _reproduciendo.asStateFlow()
 29
 30    private val _posicionMs = MutableStateFlow(0)
 31    val posicionMs: StateFlow<Int> = _posicionMs.asStateFlow()
 32
 33    private val _duracionMs = MutableStateFlow(0)
 34    val duracionMs: StateFlow<Int> = _duracionMs.asStateFlow()
 35
 36    /** Reproduce un recurso empaquetado en res/raw. */
 37    fun reproducirRecurso(@RawRes recurso: Int) {
 38        liberar()
 39        // MediaPlayer.create() hace por dentro setDataSource() + prepare():
 40        // devuelve el reproductor ya en estado Prepared. Solo es válido para
 41        // recursos locales, porque prepare() bloquea el hilo llamante.
 42        reproductor = MediaPlayer.create(getApplication(), recurso)?.apply {
 43            setAudioAttributes(
 44                AudioAttributes.Builder()
 45                    // USAGE_MEDIA indica al sistema que es contenido que el
 46                    // usuario ha pedido escuchar: se enruta al canal de
 47                    // multimedia y respeta su volumen.
 48                    .setUsage(AudioAttributes.USAGE_MEDIA)
 49                    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
 50                    .build()
 51            )
 52            setOnCompletionListener {
 53                _reproduciendo.value = false
 54                _posicionMs.value = 0
 55                trabajoProgreso?.cancel()
 56            }
 57            _duracionMs.value = duration
 58            start()
 59        }
 60        _reproduciendo.value = reproductor != null
 61        observarProgreso()
 62    }
 63
 64    /** Reproduce audio remoto. Nunca con prepare(): bloquearía la interfaz. */
 65    fun reproducirUrl(url: String) {
 66        liberar()
 67        reproductor = MediaPlayer().apply {
 68            setDataSource(url)
 69            setOnPreparedListener { mp ->
 70                _duracionMs.value = mp.duration
 71                mp.start()
 72                _reproduciendo.value = true
 73                observarProgreso()
 74            }
 75            setOnErrorListener { _, que, extra ->
 76                Log.e("MediaPlayer", "Error what=$que extra=$extra")
 77                _reproduciendo.value = false
 78                true    // true = el error queda gestionado; no se llama a OnCompletion
 79            }
 80            prepareAsync()
 81        }
 82    }
 83
 84    fun alternarReproduccion() {
 85        val mp = reproductor ?: return
 86        if (mp.isPlaying) {
 87            mp.pause()
 88            _reproduciendo.value = false
 89        } else {
 90            mp.start()
 91            _reproduciendo.value = true
 92            observarProgreso()
 93        }
 94    }
 95
 96    /** Salta a una posición concreta. Se llama al soltar el Slider, no al arrastrarlo. */
 97    fun buscar(posicionMs: Int) {
 98        reproductor?.seekTo(posicionMs)
 99        _posicionMs.value = posicionMs
100    }
101
102    private fun observarProgreso() {
103        trabajoProgreso?.cancel()
104        trabajoProgreso = viewModelScope.launch {
105            while (reproductor?.isPlaying == true) {
106                _posicionMs.value = reproductor?.currentPosition ?: 0
107                delay(250.milliseconds)
108            }
109        }
110    }
111
112    private fun liberar() {
113        trabajoProgreso?.cancel()
114        reproductor?.apply {
115            if (isPlaying) stop()
116            release()          // libera el decodificador; el objeto queda inservible
117        }
118        reproductor = null
119        _reproduciendo.value = false
120        _posicionMs.value = 0
121        _duracionMs.value = 0
122    }
123
124    /**
125     * onCleared() se ejecuta cuando el ViewModel se destruye definitivamente,
126     * no en cada rotación de pantalla. Es el único lugar correcto para liberar
127     * el reproductor: sin esta llamada el audio seguiría sonando y el
128     * decodificador quedaría ocupado (fuga de recursos).
129     */
130    override fun onCleared() {
131        super.onCleared()
132        liberar()
133    }
134}

8.3 El reproductor en Compose#

 1import androidx.annotation.RawRes
 2import androidx.compose.foundation.layout.*
 3import androidx.compose.material.icons.Icons
 4import androidx.compose.material.icons.filled.MusicNote
 5import androidx.compose.material.icons.filled.Pause
 6import androidx.compose.material.icons.filled.PlayArrow
 7import androidx.compose.material3.*
 8import androidx.compose.runtime.*
 9import androidx.compose.ui.Alignment
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.unit.dp
12import androidx.lifecycle.compose.collectAsStateWithLifecycle
13import androidx.lifecycle.viewmodel.compose.viewModel
14
15// ─── screens/reproductor/ReproductorAudio.kt ─────────────────────────────────────────────────────
16
17@Composable
18fun ReproductorAudio(
19    @RawRes recurso: Int,
20    modifier: Modifier = Modifier,
21    viewModel: AudioViewModel = viewModel()
22) {
23    val reproduciendo by viewModel.reproduciendo.collectAsStateWithLifecycle()
24    val posicion by viewModel.posicionMs.collectAsStateWithLifecycle()
25    val duracion by viewModel.duracionMs.collectAsStateWithLifecycle()
26
27    // Estado local mientras el usuario arrastra el Slider: si se enviara cada
28    // valor intermedio al reproductor, se producirían decenas de seekTo().
29    var arrastre by remember { mutableStateOf<Float?>(null) }
30
31    Column(
32        modifier = modifier.fillMaxWidth().padding(16.dp),
33        horizontalAlignment = Alignment.CenterHorizontally
34    ) {
35        Icon(
36            Icons.Default.MusicNote, contentDescription = null,
37            modifier = Modifier.size(32.dp),
38            tint = MaterialTheme.colorScheme.primary
39        )
40        Spacer(Modifier.height(8.dp))
41
42        Slider(
43            value = arrastre ?: posicion.toFloat(),
44            valueRange = 0f..(duracion.takeIf { it > 0 }?.toFloat() ?: 1f),
45            onValueChange = { arrastre = it },
46            onValueChangeFinished = {
47                arrastre?.let { viewModel.buscar(it.toInt()) }
48                arrastre = null
49            },
50            modifier = Modifier.fillMaxWidth()
51        )
52
53        Row(
54            modifier = Modifier.fillMaxWidth(),
55            horizontalArrangement = Arrangement.SpaceBetween
56        ) {
57            Text(formatearTiempo(posicion), style = MaterialTheme.typography.labelSmall)
58            Text(formatearTiempo(duracion), style = MaterialTheme.typography.labelSmall)
59        }
60
61        Spacer(Modifier.height(8.dp))
62
63        FilledIconButton(
64            onClick = {
65                if (duracion == 0) viewModel.reproducirRecurso(recurso)
66                else viewModel.alternarReproduccion()
67            },
68            modifier = Modifier.size(48.dp)
69        ) {
70            Icon(
71                imageVector = if (reproduciendo) Icons.Default.Pause else Icons.Default.PlayArrow,
72                contentDescription = if (reproduciendo) "Pausar" else "Reproducir",
73                modifier = Modifier.size(32.dp)
74            )
75        }
76    }
77}
78
79/** Convierte milisegundos en mm:ss. Función pura: fácil de probar con JUnit. */
80fun formatearTiempo(ms: Int): String {
81    val totalSegundos = ms / 1000
82    return "%d:%02d".format(totalSegundos / 60, totalSegundos % 60)
83}

El fichero de audio se coloca en res/raw/muestra.mp3 y se referencia como R.raw.muestra. La carpeta res/raw guarda recursos que se empaquetan sin comprimir ni procesar, a diferencia de res/drawable.


9. Reproducción de vídeo con Media3 (ExoPlayer)#

Media3 es la biblioteca de medios actual de Google. Unifica bajo el paquete androidx.media3 lo que antes eran proyectos separados: ExoPlayer (el reproductor), MediaSession (integración con los controles del sistema) y las interfaces de usuario asociadas. Es la sustituta oficial del antiguo com.google.android.exoplayer2, que ya no recibe actualizaciones.

MediaPlayer frente a ExoPlayer#

Aspecto MediaPlayer ExoPlayer (Media3)
Formatos MP3, AAC, MP4, WAV, OGG… Los anteriores más DASH, HLS y SmoothStreaming
Streaming adaptativo No Sí
Personalización Escasa Alta (fuentes, pistas, caché)
DRM Limitado Widevine, ClearKey
Actualizaciones Ligadas a la versión de Android Independientes, vía Jetpack
Cuándo usarlo Sonidos y audio local sencillo Vídeo, streaming, listas de reproducción

9.1 ExoPlayer en el ViewModel#

 1package com.ejemplo.appdummy.screens.reproductor
 2
 3import android.app.Application
 4import androidx.lifecycle.AndroidViewModel
 5import androidx.media3.common.MediaItem
 6import androidx.media3.common.Player
 7import androidx.media3.exoplayer.ExoPlayer
 8
 9// ─── screens/reproductor/VideoViewModel.kt ───────────────────────────────────────────────────────
10class VideoViewModel(application: Application) : AndroidViewModel(application) {
11
12    // ExoPlayer necesita un Context para acceder a los códecs del sistema.
13    val player: ExoPlayer = ExoPlayer.Builder(application).build().apply {
14        repeatMode = Player.REPEAT_MODE_OFF   // constante de Player, no de ExoPlayer
15        playWhenReady = false                 // no reproducir hasta que el usuario lo pida
16    }
17
18    /** Carga un contenido. prepare() inicia la descarga del búfer. */
19    fun cargar(url: String) {
20        player.setMediaItem(MediaItem.fromUri(url))
21        player.prepare()
22    }
23
24    override fun onCleared() {
25        super.onCleared()
26        // Igual que MediaPlayer: sin release() se retiene el decodificador.
27        player.release()
28    }
29}

9.2 PlayerView en Compose y ciclo de vida#

Un reproductor de vídeo debe pausarse cuando la aplicación pasa a segundo plano: seguir decodificando fotogramas invisibles consume batería y, si el usuario recibe una llamada, resulta molesto. El ciclo de vida de un Composable (composición y descomposición) no coincide con el de la Activity, así que hay que observar el Lifecycle explícitamente.

 1import androidx.annotation.OptIn
 2import androidx.compose.foundation.layout.aspectRatio
 3import androidx.compose.foundation.layout.fillMaxWidth
 4import androidx.compose.runtime.Composable
 5import androidx.compose.runtime.DisposableEffect
 6import androidx.compose.runtime.LaunchedEffect
 7import androidx.compose.ui.Modifier
 8import androidx.compose.ui.viewinterop.AndroidView
 9import androidx.lifecycle.Lifecycle
10import androidx.lifecycle.LifecycleEventObserver
11import androidx.lifecycle.compose.LocalLifecycleOwner
12import androidx.lifecycle.viewmodel.compose.viewModel
13import androidx.media3.common.util.UnstableApi
14import androidx.media3.ui.AspectRatioFrameLayout
15import androidx.media3.ui.PlayerView
16
17// ─── screens/reproductor/ReproductorVideo.kt ─────────────────────────────────────────────────────
18
19@OptIn(UnstableApi::class)
20@Composable
21fun ReproductorVideo(
22    urlVideo: String,
23    modifier: Modifier = Modifier,
24    viewModel: VideoViewModel = viewModel()
25) {
26    val lifecycleOwner = LocalLifecycleOwner.current
27
28    // Carga el contenido solo cuando cambia la URL, no en cada recomposición.
29    LaunchedEffect(urlVideo) { viewModel.cargar(urlVideo) }
30
31    // DisposableEffect ejecuta código al entrar en la composición y garantiza
32    // una limpieza en onDispose al salir. Es la herramienta adecuada cuando hay
33    // que registrar y dar de baja un observador; LaunchedEffect no ofrece
34    // ese punto de limpieza.
35    DisposableEffect(lifecycleOwner) {
36        val observador = LifecycleEventObserver { _, evento ->
37            when (evento) {
38                Lifecycle.Event.ON_STOP -> viewModel.player.pause()
39                else -> Unit
40            }
41        }
42        lifecycleOwner.lifecycle.addObserver(observador)
43        onDispose { lifecycleOwner.lifecycle.removeObserver(observador) }
44    }
45
46    AndroidView(
47        factory = { ctx ->
48            PlayerView(ctx).apply {
49                player = viewModel.player
50                useController = true    // controles de reproducción integrados
51                resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
52            }
53        },
54        // onRelease se ejecuta cuando la vista abandona la composición:
55        // se desvincula el reproductor, pero NO se libera (vive en el ViewModel).
56        onRelease = { vista -> vista.player = null },
57        modifier = modifier
58            .fillMaxWidth()
59            .aspectRatio(16f / 9f)
60    )
61}

¿Por qué ON_STOP y no ON_PAUSE? En pantalla dividida o en multiventana, una aplicación visible pero sin foco recibe ON_PAUSE; pausar ahí el vídeo sería incorrecto porque el usuario lo sigue viendo. ON_STOP se emite cuando la aplicación deja de ser visible, que es exactamente el momento en que interesa detener la decodificación.

Ampliación. Media3 1.11.0 incluye dos módulos pensados para Compose: media3-ui-compose, con los elementos básicos PlayerSurface y ContentFrame y los state holders (PlayPauseButtonState, CurrentMediaItemState…), y media3-ui-compose-material3, que aporta directamente los composables Player y MiniController ya ajustados a Material 3. Permiten construir el reproductor sin AndroidView. Están documentados en Getting started with Compose-based UI .

Puedes utilizar Test videos para probar la reproducción de vídeo. Son vídeos cortos de dominio público, con distintos formatos y resoluciones.


10. Grabación de vídeo con VideoCapture (ampliación)#

El caso de uso VideoCapture<Recorder> completa CameraX. No forma parte del mínimo exigible del tema, pero es la demostración más directa del criterio RA3-b sobre captura y almacenamiento de datos multimedia.

A diferencia de la fotografía, aquí sí tiene sentido usar MediaStore: un vídeo grabado por el usuario es contenido suyo y debe aparecer en la galería.

 1// Configuración de los casos de uso
 2val recorder = remember {
 3    Recorder.Builder()
 4        // QualitySelector negocia con el dispositivo la mejor calidad
 5        // disponible entre las solicitadas; HD está prácticamente garantizada.
 6        .setQualitySelector(QualitySelector.from(Quality.HD))
 7        .build()
 8}
 9val videoCapture = remember { VideoCapture.withOutput(recorder) }
10
11var grabacion by remember { mutableStateOf<Recording?>(null) }
12var grabando  by remember { mutableStateOf(false) }
13
14// ... vinculación con bindToLifecycle(lifecycleOwner, selector, preview, videoCapture) ...
15
16FloatingActionButton(
17    onClick = {
18        if (grabando) {
19            grabacion?.stop()        // Finalize llegará por el listener de eventos
20            grabacion = null
21            return@FloatingActionButton
22        }
23
24        val nombre = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
25            .format(System.currentTimeMillis())
26
27        val valores = ContentValues().apply {
28            put(MediaStore.Video.Media.DISPLAY_NAME, "APPDUMMY_$nombre")
29            put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
30            // RELATIVE_PATH exige API 29+; minSdk del proyecto es 30.
31            put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/AppDummy")
32        }
33
34        val salida = MediaStoreOutputOptions
35            .Builder(context.contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
36            .setContentValues(valores)
37            .build()
38
39        grabacion = videoCapture.output
40            .prepareRecording(context, salida)
41            .withAudioEnabled()     // requiere RECORD_AUDIO ya concedido
42            .start(ContextCompat.getMainExecutor(context)) { evento ->
43                when (evento) {
44                    is VideoRecordEvent.Start -> grabando = true
45                    is VideoRecordEvent.Finalize -> {
46                        grabando = false
47                        if (evento.hasError()) {
48                            Log.e("CameraX", "Error de grabación: ${evento.error}")
49                        } else {
50                            Log.d("CameraX", "Vídeo en ${evento.outputResults.outputUri}")
51                        }
52                    }
53                }
54            }
55    },
56    containerColor = if (grabando) MaterialTheme.colorScheme.error
57                     else MaterialTheme.colorScheme.primary
58) {
59    Icon(
60        imageVector = if (grabando) Icons.Default.Stop else Icons.Default.Videocam,
61        contentDescription = if (grabando) "Detener" else "Grabar"
62    )
63}

withAudioEnabled() lanza SecurityException si RECORD_AUDIO no está concedido en el momento de la llamada. No basta con declararlo en el manifiesto: hay que comprobarlo antes, con RequestMultiplePermissions, tal y como se vio en la sección 2.


Estructura de paquetes al final del Bloque 4 (T7)#

com.ejemplo.appdummy/
├── AppDummyApplication.kt
├── MainActivity.kt
│
├── data/
│   ├── datasource/
│   │   ├── local/
│   │   │   ├── AppDatabase.kt                 ← @Database(version = 3)
│   │   │   ├── Converters.kt
│   │   │   ├── LibrosDao.kt                   ← + actualizarPortadaLocal
│   │   │   └── LocalDataSource.kt             ← + actualizarPortadaLocal
│   │   └── remote/                            ← sin cambios en T7
│   ├── di/
│   │   └── AppContainer.kt
│   ├── model/
│   │   └── Libro.kt                           ← + columna portada_local
│   └── repository/
│       ├── LibrosRepository.kt                ← + guardarPortadaLocal()
│       └── ResultadoSincronizacion.kt
│
├── navegacion/
│   ├── AppDummyBottomBar.kt
│   ├── AppNavigation.kt                       ← + rutas Camara y Reproductor
│   ├── ItemsNavegacion.kt
│   └── Rutas.kt                               ← + Camara(libroId), Reproductor
│
├── screens/
│   ├── camara/
│   │   └── PantallaCamara.kt                  ← NUEVO: Preview + ImageCapture
│   ├── componentes/
│   │   ├── CaratulaLibro.kt                   ← + portadaLocal
│   │   ├── ItemLibro.kt                       ← + portadaLocal
│   │   ├── SelectorPortada.kt                 ← NUEVO: PickVisualMedia
│   │   └── SolicitudPermiso.kt                ← NUEVO: permisos reutilizable
│   ├── detalle/
│   │   ├── DetalleUiState.kt
│   │   ├── DetalleViewModel.kt                ← AndroidViewModel + portada
│   │   └── PantallaDetalle.kt                 ← + acciones multimedia
│   ├── favoritos/
│   ├── listado/
│   ├── nuevo/
│   └── reproductor/
│       ├── AudioViewModel.kt                  ← NUEVO: MediaPlayer
│       ├── ReproductorAudio.kt                ← NUEVO
│       ├── ReproductorVideo.kt                ← NUEVO: PlayerView + AndroidView
│       └── VideoViewModel.kt                  ← NUEVO: ExoPlayer
│
└── utils/
    ├── AlmacenPortadas.kt                     ← NUEVO: ficheros privados
    ├── Isbn.kt
    └── ObservadorConectividad.kt

Los cambios de este tema se reparten de forma muy distinta a los de T6. Allí casi todo caía en data/; aquí la mayor parte está en screens/, porque la multimedia es, ante todo, interacción con el usuario y con el hardware. Lo que no cambia es la dirección de las dependencias: la interfaz sigue hablando solo con el ViewModel, y el repositorio sigue sin saber que existe una cámara.


Desarrollo práctico guiado: AppDummy multimedia 💻#

Estos son los cambios que debes realizar sobre la versión v8 :

  • Añade las dependencias de CameraX y Media3 al catálogo libs.versions.toml y al build.gradle.kts, y declara RECORD_AUDIO en el manifiesto. Comprueba en el panel Gradle que no aparecen conflictos de versiones entre los artefactos de CameraX.
  • Extrae SolicitudPermiso a screens/componentes a partir del código de PantallaGestionPermisos de la v3. Prueba los tres caminos: conceder, denegar una vez y denegar marcando no volver a preguntar. Solo el tercero debe llevarte a los ajustes del sistema.
  • Añade la columna portada_local, sube la versión de @Database a 3 y desinstala la app del emulador antes de volver a ejecutarla. Verifica con App Inspection que la columna aparece en la tabla libros.
  • Implementa AlmacenPortadas y comprueba con el Device Explorer de Android Studio que los ficheros se crean en /data/data/com.ejemplo.appdummy/files/portadas/.
  • Crea PantallaCamara y la ruta Camara(libroId). Prueba a girar el dispositivo con la vista previa abierta: si la cámara se reinicia o la pantalla parpadea, revisa qué objetos están fuera de remember.
  • Selecciona una imagen con SelectorPortada y guarda deliberadamente la Uri en Room en lugar de copiar el fichero. Reinicia el emulador por completo y observa cómo la portada desaparece. Deshaz el cambio después: acabas de comprobar por qué existe copiarDesdeUri().
  • Marca un libro como favorito, asígnale una portada propia y lanza una sincronización desde el listado. Confirma que es_favorito y portada_local siguen intactos: si la portada desaparece, portada_local se ha colado en actualizarDesdeRed().
  • Añade res/raw/muestra.mp3 y monta la pantalla de reproducción de audio. Comprueba en el Profiler de memoria que, al salir de la pantalla, el MediaPlayer se libera. Prueba después a comentar la llamada a liberar() dentro de onCleared() y verifica que el audio sigue sonando: es la demostración de la fuga de recursos.
  • Escribe un test unitario en app/src/test para formatearTiempo(), cubriendo 0 ms, 65 000 ms y 3 600 000 ms. Es una función pura y no necesita emulador.
  • Monta ReproductorVideo con una URL pública de vídeo MP4. Pulsa el botón de inicio del dispositivo con el vídeo en marcha y comprueba que el sonido se detiene. Elimina el DisposableEffect y repite la prueba para ver la diferencia.
  • Depura una sesión con Logcat filtrando por la etiqueta CameraX: identifica en qué momento exacto se vinculan y se desvinculan los casos de uso al navegar entre pantallas.

Ampliación opcional. Implementa la grabación de vídeo de la sección 10 en una pantalla propia, con el indicador REC y la solicitud conjunta de CAMERA y RECORD_AUDIO. Como ejercicio adicional, reescribe ReproductorVideo usando el composable Player de media3-ui-compose-material3 y compara la cantidad de código necesaria en ambos enfoques.


Referencias#

Calendar  Última modificación: domingo, 6 de septiembre de 2026