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-coredefine los casos de uso,camera-camera2es la implementación real sobre la API Camera2 del sistema,camera-lifecyclepermite ligar la cámara a unLifecycleOwner,camera-viewaporta la vistaPreviewViewycamera-videoel caso de uso de grabación. Si se olvidacamera-camera2el 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_IMAGESyREAD_EXTERNAL_STORAGEno aparecen en ningún punto del proyecto. Además,minSdk = 30elimina 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 devuelvefalsede inmediato y la aplicación parecerá rota. Por eso se consultashouldShowRequestPermissionRationale()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}
usecierra siempre.openInputStream()youtputStream()devuelven flujos que deben cerrarse pase lo que pase. La función de extensiónuse { }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_locales un campo exclusivamente local, comoes_favoritoyleido: Open Library no lo conoce. Debe quedar fuera deactualizarDesdeRed()y deupsertConservandoFavorito(), 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 elComposableentra 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}
onExitoentrega unaUri, y esaUriimporta. 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.
onErrorsolo 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 unSnackbardesdePantallaCamara.
Ampliación:
camera-compose. Desde CameraX 1.6.0 existeandroidx.camera:camera-compose, que aportaCameraXViewfindercomo alternativa nativa a envolverPreviewViewen unAndroidView. MantenemosPreviewViewpor 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
Uripor cada elemento elegido. Por eso no hay que declararREAD_MEDIA_IMAGESniREAD_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 esoonImagenElegidapuede recibir laUridirectamente. «Hacer foto» abre otra pantalla del grafo de navegación: en el momento de pulsarlo no hay ninguna imagen todavía, así queonAbrirCamarano puede llevarUri. El resultado de la cámara viaja por otro camino, que es justamente lo que resuelve el apartado 7.
La
Urino es la imagen. Unacontent://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 esoAlmacenPortadas.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 muestra7.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
Uride la cámara aasignarPortadaLocal(). 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 sinuevoFicheroPara()devolviese siempre el mismo nombre: origen y destino coincidirían,destino.outputStream()truncaría el fichero a cero bytes antes de quecopyTo()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é
Uripuede guardarse ahí?SavedStateHandlealmacena lo que quepa en unBundle.UriimplementaParcelable, 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
LaunchedEffecty no basta connavigateUp(). Al volver, la entrada deDetallesigue viva en la pila con su ViewModel intacto: no se recrea nada. Y comocargarDetalle()es una consulta puntual (suspend, noFlow), 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
nulldespués.getStateFlowconserva el último valor. Si no se consume, un giro de pantalla volvería a ejecutarasignarPortadaCapturada()con la mismaUri, 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
Urial ViewModel. Si mañana se cambiara CameraX por otra biblioteca,PantallaDetalleno se tocaría. Es el mismo criterio que en T6 mantuvoPantallaListadoajena 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ónConviene 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:
StateFlowdescarta las asignaciones iguales. SiLibroconservase la misma ruta, elDetalleUiState.Exitoreconstruido seríaequalsal anterior y_uiState.value = ...no emitiría nada.- Compose omite las recomposiciones sin cambios. Aunque el estado emitiera,
CaratulaLibrorecibiría el mismoStringy se saltaría. - 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 devolvieraFlow<LibroEntity?>y elDetalleViewModelusarastateIn, la pantalla de detalle se refrescaría sola al escribir en Room: no harían falta ni elSavedStateHandleni elLaunchedEffect, 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.mp3y se referencia comoR.raw.muestra. La carpetares/rawguarda recursos que se empaquetan sin comprimir ni procesar, a diferencia deres/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_STOPy noON_PAUSE? En pantalla dividida o en multiventana, una aplicación visible pero sin foco recibeON_PAUSE; pausar ahí el vídeo sería incorrecto porque el usuario lo sigue viendo.ON_STOPse 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ásicosPlayerSurfaceyContentFramey los state holders (PlayPauseButtonState,CurrentMediaItemState…), ymedia3-ui-compose-material3, que aporta directamente los composablesPlayeryMiniControllerya ajustados a Material 3. Permiten construir el reproductor sinAndroidView. 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()lanzaSecurityExceptionsiRECORD_AUDIOno está concedido en el momento de la llamada. No basta con declararlo en el manifiesto: hay que comprobarlo antes, conRequestMultiplePermissions, 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.ktLos 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á enscreens/, 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.tomly albuild.gradle.kts, y declaraRECORD_AUDIOen el manifiesto. Comprueba en el panel Gradle que no aparecen conflictos de versiones entre los artefactos de CameraX. - Extrae
SolicitudPermisoascreens/componentesa partir del código dePantallaGestionPermisosde 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@Databasea3y desinstala la app del emulador antes de volver a ejecutarla. Verifica con App Inspection que la columna aparece en la tablalibros. - Implementa
AlmacenPortadasy comprueba con el Device Explorer de Android Studio que los ficheros se crean en/data/data/com.ejemplo.appdummy/files/portadas/. - Crea
PantallaCamaray la rutaCamara(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 deremember. - Selecciona una imagen con
SelectorPortaday guarda deliberadamente laUrien 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é existecopiarDesdeUri(). - Marca un libro como favorito, asígnale una portada propia y lanza una sincronización desde el listado. Confirma que
es_favoritoyportada_localsiguen intactos: si la portada desaparece,portada_localse ha colado enactualizarDesdeRed(). - Añade
res/raw/muestra.mp3y monta la pantalla de reproducción de audio. Comprueba en el Profiler de memoria que, al salir de la pantalla, elMediaPlayerse libera. Prueba después a comentar la llamada aliberar()dentro deonCleared()y verifica que el audio sigue sonando: es la demostración de la fuga de recursos. - Escribe un test unitario en
app/src/testparaformatearTiempo(), cubriendo 0 ms, 65 000 ms y 3 600 000 ms. Es una función pura y no necesita emulador. - Monta
ReproductorVideocon 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 elDisposableEffecty 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
CAMERAyRECORD_AUDIO. Como ejercicio adicional, reescribeReproductorVideousando el composablePlayerdemedia3-ui-compose-material3y compara la cantidad de código necesaria en ambos enfoques.
Referencias#
- Introducción a CameraX — Android Developers
- Arquitectura de CameraX — Android Developers
- Implementar una vista previa — Android Developers
- Capturar imágenes con CameraX — Android Developers
- Grabar vídeo con CameraX — Android Developers
- Notas de versión de CameraX — Android Developers
- Selector de fotos de Android — Android Developers
- Solicitar permisos en tiempo de ejecución — Android Developers
- Almacenamiento de datos específico de la app — Android Developers
- Descripción general de MediaPlayer — Android Developers
- Introducción a Jetpack Media3 — Android Developers
- Interfaz de usuario con Compose en Media3 — Android Developers
- Notas de versión de Media3 — Android Developers
- Interoperabilidad de Compose con Vistas (
AndroidView) — Android Developers - Efectos secundarios en Compose (
LaunchedEffect,DisposableEffect) — Android Developers - Anexo B4-A1 — Referencia: permisos en Android moderno, FileProvider y WorkManager