Tema 8: Multimedia en Android

Objetivos de este tema

  • Reproducir audio y video en una app Android usando MediaPlayer y ExoPlayer.
  • Capturar fotos y vídeos mediante la cámara del dispositivo.
  • Acceder a la galería de imágenes del dispositivo utilizando PhotoPicker.
  • Usar del micrófono del disposivo.
  • Utilizar sensores básicos del dispositivo como el acelerómetro y el sensor de luz.
  • Integrar funcionalidades en una arquitectura moderna (MVVM + Clean Architecture) con Jetpack Compose.

8.1. Reproducción de audio y video

En Android hay diferentes maneras de reproducir contenido multimedia. Las más comunes son MediaPlayer (nativa) y ExoPlayer, esta última más moderna y recomendada para Compose.

8.1.1. MediaPlayer (API Nativa)

Esta clase está integrada en Android, por lo que no necesita dependencias externas, es sencilla de utilizar pero algo limitada y poco personalizable.

1val mediaPlayer = MediaPlayer.create(ctxt, R.raw.epic_cinematic)
2mediaPlayer.start() // Reproduce.
3mediaPlayer.pause() // Pausa la reproducción.
4mediaPlayer.stop() // Detiene.
5mediaPlayer.prepare() // Prepara el MediaPlayer para poder reproducirlo de nuevo.
6mediaPlayer.release() // // Libera recursos del MediaPlayer.

Código completo

Nota

MediaPlayer no está recomendado para reproducción en streaming o formatos complejos.

8.1.2. ExoPlayer

ExoPlayer es una biblioteca de código abierto desarrollada por Google, ahora ya forma parte de Jetpack Media3. Es más potente y flexible que MediaPlayer y se encuentra actualizada. Además de ser la recomendación actual.

Dependencias necesarias para incluir ExoPlayer en el proyecto.

1// ExoPlayer
2implementation("androidx.media3:media3-exoplayer:1.8.0")
3implementation("androidx.media3:media3-ui:1.8.0")
4implementation("androidx.media3:media3-common:1.8.0")

El siguiente Composable utiliza un AndroidView para incrustar el PlayerView de ExoPlayer.

 1import androidx.compose.foundation.layout.fillMaxWidth
 2import androidx.compose.runtime.Composable
 3import androidx.compose.runtime.remember
 4import androidx.compose.ui.Modifier
 5import androidx.compose.ui.platform.LocalContext
 6import androidx.compose.ui.viewinterop.AndroidView
 7import androidx.media3.common.MediaItem
 8import androidx.media3.exoplayer.ExoPlayer
 9import androidx.media3.ui.PlayerView
10
11// VideoPlayer.kt
12@Composable
13fun VideoPlayer(videoUrl: String) {
14    val ctxt = LocalContext.current
15
16    // Se crea la instancia de ExoPlayer.
17    val exoPlayer = remember {
18        ExoPlayer.Builder(ctxt).build().apply {
19            val mediaItem = MediaItem.fromUri(videoUrl)
20            setMediaItem(mediaItem)
21            prepare()
22            playWhenReady = true
23        }
24    }
25
26    // Integración de la vista nativa de Android (PlayerView) en Compose.
27    AndroidView(
28        factory = { ctx ->
29            PlayerView(ctx).apply {
30                player = exoPlayer
31                useController = true // muestra controles
32            }
33        },
34        modifier = Modifier.fillMaxWidth(),
35        onRelease = { playerView ->
36            playerView.player?.release()
37        }
38    )
39}

La llamada desde la pantalla que muestre el vídeo será simplemente pasándole una URL.

1@Composable
2fun VideoScreen() {
3    VideoPlayer(videoUrl = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
4}

Código completo

Puedes obtener más vídeos de muestra en este repositorio.

8.2. Captura de imágenes

Para capturar imágenes desde la cámara se puede utilizar ActivityResultContracts.TakePicture. Será necesario especificar en el Manifiest el permiso de cámara:

1<uses-permission android:name="android.permission.CAMERA" />
2<uses-feature android:name="android.hardware.camera" android:required="false" />

El siguiente Composable muestra como capturar una imagen previa de la captura fotográfica, previa solicitud de permisos, utilizando la clase PermissionHandlerViewModel del tema 3.

 1@Composable
 2fun CheckPermission(padding: PaddingValues, viewModel: PermissionHandlerViewModel = viewModel()) {
 3    val ctxt = LocalContext.current
 4
 5    // Obtiene el estado del permiso desde el ViewModel.
 6    val permissionState = viewModel.uiState.collectAsState()
 7    // Este callback se usa para solicitar el permiso de cámara.
 8    val requestPermission =
 9        rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
10            viewModel.onPermissionResult(
11                granted, ActivityCompat.shouldShowRequestPermissionRationale(
12                    ctxt as Activity, Manifest.permission.CAMERA
13                )
14            )
15        }
16
17    LaunchedEffect(permissionState) {
18        when {
19            permissionState.value.granted -> {
20                Log.d("CameraPermission", "Acceso a cámara concedido")
21            }
22
23            else -> {
24                // Primer lanzamiento: solicitamos el permiso
25                requestPermission.launch(Manifest.permission.CAMERA)
26            }
27        }
28    }
29
30    when {
31        permissionState.value.granted -> {
32            CameraCapture(padding = padding) {/* onImageCaptured */
33                Log.i("MainActivity", "onImageCaptured: $it")
34            }
35        }
36    }
37}
 1@Composable
 2fun CameraCapture(padding: PaddingValues, onImageCaptured: (Uri) -> Unit) {
 3    val ctxt = LocalContext.current
 4    val bitmap = remember { mutableStateOf<Bitmap?>(null) }
 5    val takePicture = rememberLauncherForActivityResult(
 6        contract = ActivityResultContracts.TakePicturePreview()
 7    ) { bitmap.value = it }
 8
 9    Column(
10        modifier = Modifier
11            .fillMaxSize()
12            .padding(padding),
13    ) {
14        if (bitmap.value != null) {
15            Image(
16                bitmap = bitmap.value!!.asImageBitmap(),
17                contentDescription = "Foto capturada",
18                modifier = Modifier.size(200.dp)
19            )
20            onImageCaptured(bitmap.value!!.let {
21                // Se guarda la imagen en el almacenamiento interno y devolvemos su URI
22                val filename = "captured_image.png"
23                val stream = ctxt.openFileOutput(filename, 0)
24                it.compress(Bitmap.CompressFormat.PNG, 100, stream)
25                stream.close()
26                Uri.fromFile(ctxt.getFileStreamPath(filename))
27            })
28        }
29
30        Button(onClick = { takePicture.launch() }) {
31            Text("Tomar foto")
32        }
33    }
34}
Información

TakePicturePreview() devuelve un Bitmap. Para guardar una imagen completa, deberás utilizar TakePicture (requiere Uri).

8.3. Captura de imágenes y vídeo con CameraX

CameraX es una biblioteca de Android Jetpack que simplifica el uso de la cámara en dispositivos Android. Diseñada para trabajar de forma consistente en diferentes modelos y marcas, resuelve muchos problemas de compatibilidad. Recomendada para proyectos con Jetpack Compose y arquitectura MVVM y Clean Architecture.

Nota

Para utilizar CameraX y reducir el uso de permisos, se recomienda usar CameraX con ViewModel y lifecycle, evitando así el permiso de almacenamiento en Android 10 (API 29) y posteriores.

Ventajas que ofrece:

  • Funciona en dispositivos con API 21+.
  • Diseño orientado a Compose.
  • Soporte integrado para vistas de previsualización (PreviewView).
  • Integración con ViewModel y lifecycle.
  • Permite captura de fotos, grabación de vídeo y análisis de imágenes (como detección de rostros).

8.3.1. Dependencias y permisos necesarias para CameraX

 1// CameraX core
 2implementation("androidx.camera:camera-core:1.5.1")
 3
 4// CameraX Lifecycle
 5implementation("androidx.camera:camera-lifecycle:1.5.1")
 6
 7// CameraX View (para PreviewView)
 8implementation("androidx.camera:camera-view:1.5.1")
 9
10// CameraX Camera2 (backend recomendado)
11implementation("androidx.camera:camera-camera2:1.5.1")
12
13// Si se utiliza análisis (opcional)
14implementation("androidx.camera:camera-extensions:1.5.1")

Para poder grabar vídeo, será necesario añadir la siguiente dependencia:

1// VideoCapture
2implementation("androidx.camera:camera-video:1.5.1")

Además, es necesario añadir los permisos en el archivo AndroidManifest.xml:

1<uses-permission android:name="android.permission.CAMERA" />
2<uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- Para grabar audio -->
3<uses-feature android:name="android.hardware.camera.any" /> <!-- Para usar cualquier cámara (frontal o trasera) -->
4<uses-feature android:name="android.hardware.microphone" android:required="true" /> <!-- Para grabar audio -->
5<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
Nota

En Android 10 (API 29) y posteriores, no se necesita el permitso WRITE_EXTERNAL_STORAGE si se guarda en directorios de la app (como getExternalFilesDir). Se utilizará MediaStore para guardar en galería.

Importante

Es posible que en algunos emuladores no funcione la cámara. Se recomienda probar en un dispositivo real.

8.3.2. Captura de imágenes básica con CameraX en Compose con vista previa

El objetivo es crear una app que permita capturar fotos guardándolos en el almacenamiento interno del dispositivo, la actividad principal se encargará de inicializar CameraX y gestionar la UI, mostrando la previsualización de la cámara y un botón para capturar la imagen.

En primer lugar, en la MainActivity se crea un objeto para gestionar un ExecutorService, se usará para ejecutar tareas relacionadas con la cámara en un hilo secundario, sin bloquear la UI principal.

 1class MainActivity : ComponentActivity() {
 2    // Executor para tareas en segundo plano.
 3    private lateinit var cameraExecutor: ExecutorService
 4
 5    override fun onCreate(savedInstanceState: Bundle?) {
 6        super.onCreate(savedInstanceState)
 7        enableEdgeToEdge()
 8
 9        setContent {
10            // Se inicializa el executor una sola vez.
11            cameraExecutor = Executors.newSingleThreadExecutor()
12            CameraApp()
13        }
14    }
15
16    ...
17}

Observa como en el bloque setContent se inicializa cameraExecutor, creando un hilo único dedicado a ejecutar las operaciones de la cámara. Esto garantiza que las tareas se procesen de manera secuencial en ese hilo y no interfieran con el hilo principal. Luego se llama a la función CameraApp(), que es el Composable principal de la aplicación.

Importante

Todos los métodos que se describen a continuación deben estar dentro de la clase MainActivity.

Ahora se creará el método CameraApp(), composable gestiona la solicitud de permisos en tiempo de ejecución y decide si muestra la pantalla de la cámara o una pantalla solicitando permisos. Utiliza la API ActivityResult desde Compose, recordando el estado, configurado para lanzar la petición una sola vez.

 1@Composable
 2fun CameraApp() {
 3    val context = LocalContext.current
 4    var hasPermissions by remember { mutableStateOf(false) }
 5
 6    // Solicitar permisos.
 7    val launcher = rememberLauncherForActivityResult(
 8        contract = ActivityResultContracts.RequestMultiplePermissions(),
 9        onResult = { permissions ->
10            hasPermissions = permissions.values.all { it }
11        }
12    )
13
14    LaunchedEffect(Unit) {
15        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
16            launcher.launch(arrayOf(Manifest.permission.CAMERA))
17        else {
18            launcher.launch(
19                arrayOf(
20                    Manifest.permission.CAMERA,
21                    Manifest.permission.WRITE_EXTERNAL_STORAGE // Solo necesario < Q (29)
22                )
23            )
24        }
25    }
26
27    if (hasPermissions) {
28        CameraScreen(context = context, executor = cameraExecutor)
29    } else {
30        RequestPermissionScreen {
31            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
32                launcher.launch(arrayOf(Manifest.permission.CAMERA))
33            else {
34                launcher.launch(
35                    arrayOf(
36                        Manifest.permission.CAMERA,
37                        Manifest.permission.WRITE_EXTERNAL_STORAGE // Solo necesario < Q (29)
38                    )
39                )
40            }
41        }
42    }
43}
  • Se crea el objeto context a partir de LocalContext.current para exponer el Context de Android dentro del árbol de composición. Este contexto es esencial para muchas operaciones en Android, como acceder a recursos, iniciar actividades o servicios, y obtener información del sistema. En este caso, se pasa a CameraScreen para que pueda utilizarlo al configurar la cámara y guardar imágenes.
  • Se define una variable mutable hasPermissions para comprobar si la aplicación tiene los permisos necesarios. Inicialmente es false.
  • Se utiliza rememberLauncherForActivityResult para crear un lanzador que solicitará múltiples permisos. El resultado de la solicitud se maneja en el lambda onResult, donde se actualiza hasPermissions si todos los permisos han sido concedidos.
  • LaunchedEffect(Unit) se usa para lanzar la solicitud de permisos cuando el composable se monta por primera vez. Dependiendo de la versión de Android, se solicitan los permisos necesarios.
  • Finalmente, se muestra CameraScreen si los permisos han sido concedidos, o RequestPermissionScreen si no. Este último muestra un mensaje y un botón para solicitar los permisos de nuevo.

A continuación, se implementa el composable RequestPermissionScreen, que muestra un mensaje indicando que se necesitan permisos y un botón para reintentar la solicitud.

 1@Composable
 2private fun RequestPermissionScreen(onRetry: () -> Unit) {
 3    Surface(modifier = Modifier.fillMaxSize()) {
 4        Column(
 5            modifier = Modifier.fillMaxSize(),
 6            horizontalAlignment = Alignment.CenterHorizontally,
 7            verticalArrangement = Arrangement.Center
 8        ) {
 9            Text(text = "Se requiere permiso de cámara", color = Color.Red)
10            Spacer(modifier = Modifier.height(16.dp))
11            Button(onClick = onRetry) {// Reintentar
12                Text("Solicitar permisos")
13            }
14        }
15    }
16}

El siguiente paso es implementar el composable CameraScreen, que se encargará de mostrar la previsualización de la cámara y un botón para capturar fotos. Este composable utiliza PreviewView para mostrar la vista previa de la cámara y ImageCapture para capturar imágenes.

 1@Composable
 2private fun CameraScreen(context: Context, executor: ExecutorService) {
 3    val imageCaptureState = remember { mutableStateOf<ImageCapture?>(null) }
 4    val isCameraReady by remember { derivedStateOf { imageCaptureState.value != null } }
 5
 6    Column(
 7        modifier = Modifier
 8            .fillMaxSize()
 9            .padding(48.dp)
10    ) {
11        // Vista de previsualización
12        AndroidView(
13            factory = { ctx ->
14                val previewView = PreviewView(ctx)
15                val lifecycleOwner = context as LifecycleOwner
16
17                // Iniciar la cámara después de que la vista esté lista
18                startCamera(
19                    context = context,
20                    previewView = previewView,
21                    lifecycleOwner = lifecycleOwner,
22                    onSuccess = { capture ->
23                        Log.d("CameraX", "Cámara iniciada correctamente")
24                        imageCaptureState.value = capture  // <-- Actualizamos el estado
25                    },
26                    onError = { e ->
27                        Toast.makeText(
28                            context,
29                            "Error cámara: ${e.message}",
30                            Toast.LENGTH_SHORT
31                        ).show()
32                    }
33                )
34
35                previewView
36            },
37            modifier = Modifier.weight(1f)
38        )
39
40        // Botón para tomar foto (solo si está listo)
41        Button(
42            onClick = {
43                val capture = imageCaptureState.value
44                if (capture != null) {
45                    takePhoto(capture, context, executor) {
46                        // Se utiliza el main executor para mostrar el Toast.
47                        ContextCompat.getMainExecutor(context).execute {
48                            Toast.makeText(context, "Foto guardada", Toast.LENGTH_SHORT).show()
49                        }
50                    }
51                } else {
52                    Toast.makeText(context, "Espere… cámara iniciándose", Toast.LENGTH_SHORT).show()
53                }
54            },
55            enabled = isCameraReady, // Deshabilitado hasta que esté listo
56            modifier = Modifier
57                .align(Alignment.CenterHorizontally)
58                .padding(16.dp)
59        ) {
60            Text(if (isCameraReady) "Hacer Foto" else "Iniciando cámara...")
61        }
62    }
63}
  • Se define imageCaptureState como un estado mutable que almacenará la instancia de ImageCapture una vez que la cámara esté lista. Inicialmente es null.
  • La variable derivada isCameraReady indica si la cámara está lista para capturar fotos, es decir, si imageCaptureState.value no es null.
  • Se utiliza AndroidView para integrar PreviewView, que muestra la previsualización de la cámara. Dentro del factory se llama a startCamera para configurar e iniciar la cámara.
  • El botón para tomar fotos estará habilitado cuando la cámara está lista (isCameraReady es true). Al hacer clic, se llama a takePhoto para capturar y guardar la imagen.
  • Se mostrará un Toast para notificar al usuario que la foto se ha guardado o si la cámara aún se está iniciando.
  • Se utiliza ContextCompat.getMainExecutor(context).execute para asegurarse de que el Toast se muestre en el hilo principal, de no hacer así, podría producirse un error de ejecución.

A continuación, se implementa la función startCamera, que configura e inicia la cámara utilizando CameraX. Este método no es un composable, sino una función normal que se llama desde el factory de AndroidView.

 1private fun startCamera(
 2    context: Context,
 3    previewView: PreviewView,
 4    lifecycleOwner: LifecycleOwner,
 5    onSuccess: (ImageCapture) -> Unit,
 6    onError: (Exception) -> Unit
 7) {
 8    val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
 9
10    cameraProviderFuture.addListener({
11        try {
12            val cameraProvider = cameraProviderFuture.get()
13
14            val preview = Preview.Builder().build().apply {
15                setSurfaceProvider(previewView.surfaceProvider)
16            }
17
18            val imageCapture = ImageCapture.Builder()
19                .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
20                .build()
21
22            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
23
24            // Se desvincula el provider previo antes de re-vincular
25            cameraProvider.unbindAll()
26
27            // Se vincula al ciclo de vida correcto
28            cameraProvider.bindToLifecycle(
29                lifecycleOwner,
30                cameraSelector,
31                preview,
32                imageCapture
33            )
34
35            // Se notifica que ImageCapture está listo
36            onSuccess(imageCapture)
37
38        } catch (e: Exception) {
39            onError(e)
40        }
41    }, ContextCompat.getMainExecutor(context))
42}
  • Se obtiene una instancia de ProcessCameraProvider, que es será la responsable de gestionar la cámara.
  • Se añade un listener para cuando el CameraProvider esté listo, y dentro del bloque try-catch se configura la cámara.
  • Se crea un Preview para mostrar la vista previa de la cámara en PreviewView.
  • Se configura ImageCapture para capturar las fotos, estableciendo el modo de captura para minimizar la latencia.
  • Se selecciona la cámara trasera utilizando CameraSelector.DEFAULT_BACK_CAMERA.
  • Se desvinculan todas las cámaras previamente vinculadas para evitar conflictos con el método unbindAll().
  • Se vinculan Preview e ImageCapture al ciclo de vida del lifecycleOwner, asegurando que la cámara se gestione correctamente según el estado de la actividad o fragmento.
  • Si todo es correcto, se llama a onSuccess pasando la instancia de ImageCapture. Si se produjese un error, se llama a onError.

El siguiente método, takePhoto, se encargará de capturar la foto y guardarla en el almacenamiento interno del dispositivo. Al igual que startCamera, no es un composable, ya que se llama desde el botón en CameraScreen y realiza operaciones que no están relacionadas con la UI directamente.

 1private fun takePhoto(
 2    imageCapture: ImageCapture,
 3    context: Context,
 4    executor: ExecutorService,
 5    onSaved: () -> Unit
 6) {
 7    val contentValues = ContentValues().apply {
 8        put(MediaStore.MediaColumns.DISPLAY_NAME, "IMG_${System.currentTimeMillis()}")
 9        put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
10        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Solo para Android 10+ (Q)
11            put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/CameraX-Compose")
12        }
13    }
14
15    val outputOptions = ImageCapture.OutputFileOptions.Builder(
16        context.contentResolver,
17        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
18        contentValues
19    ).build()
20
21    imageCapture.takePicture(
22        outputOptions,
23        executor,
24        object : ImageCapture.OnImageSavedCallback {
25            override fun onImageSaved(result: ImageCapture.OutputFileResults) {
26                onSaved()
27            }
28
29            override fun onError(exception: ImageCaptureException) {
30                exception.printStackTrace()
31                Log.e("CameraX", "Error al guardar la foto: ${exception.message}", exception)
32
33                // Se utiliza el main executor para mostrar el Toast.
34                ContextCompat.getMainExecutor(context).execute {
35                    Toast.makeText(
36                        context,
37                        "Error al guardar: ${exception.message}",
38                        Toast.LENGTH_SHORT
39                    ).show()
40                }
41            }
42        }
43    )
44}
  • Se crea un ContentValues para definir los metadatos de la imagen, como el nombre del archivo, el tipo MIME y la ruta relativa (solo para Android 10 y posteriores).
  • Se configuran las opciones de salida utilizando ImageCapture.OutputFileOptions.Builder, especificando el ContentResolver, la URI de destino y los ContentValues.
  • Se llama a takePicture de la instancia de ImageCapture, pasándole las opciones de salida, el executor para ejecutar la operación en segundo plano, y un callback para manejar el resultado.
  • En onImageSaved, se llama a onSaved() para notificar que la imagen se ha guardado correctamente.
  • En onError, se maneja cualquier error que pueda producirse durante la captura o el guardado de la imagen, imprimiendo el error en el log y mostrando un Toast en el hilo principal para informar al usuario.
  • Se utiliza ContextCompat.getMainExecutor(context).execute para asegurarse de que el Toast se muestre en el hilo principal, de no hacer así, podría producirse un error de ejecución.
  • El uso de MediaStore permite que las imágenes capturadas se guarden en la galería del dispositivo, haciendo que sean accesibles para otras aplicaciones y para el propio usuario.

Por último, es importante liberar los recursos del ExecutorService cuando la actividad se destruya, para evitar fugas de memoria. Esto se hace sobrescribiendo el método onDestroy en MainActivity.

1override fun onDestroy() {
2    super.onDestroy()
3    cameraExecutor.shutdown() // Se libera el executor.
4}

Código completo

8.3.3. Captura de vídeo con CameraX en Compose con vista previa

En primer lugar, recuerda añadir la dependencia de VideoCapture en el archivo build.gradle (ver sección 8.3.1) y los permisos necesarios en el AndroidManifest.xml.

Este ejemplo se inicia en un proyecto nuevo, desde la MainActivity se gestionará la cámara y la grabación de vídeo.

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5
 6        setContent {
 7            MaterialTheme {
 8                Scaffold(modifier = Modifier.padding(8.dp)) { innerPadding ->
 9                    CameraRecorderScreen(innerPadding)
10                }
11            }
12        }
13    }
14}

También se añadirá el método RecordingHolder, que es un objeto singleton que se utilizará para mantener una referencia al Recording activo. Será necesario para poder detener la grabación cuando se desee.

1// Holder para el Recording activo (fuera del ViewModel para simplicidad del ejemplo)
2private object RecordingHolder {
3    var recording: Recording? = null
4}

A continuación, se implementa el composable CameraRecorderScreen, que se encargará de mostrar la previsualización de la cámara y los botones para iniciar y detener la grabación de vídeo. Se detallará cada parte del código paso a paso.

 1@Composable
 2fun CameraRecorderScreen(innerPadding: PaddingValues) {
 3    val contxt = LocalContext.current // Contexto necesario para varias llamadas.
 4    val lifecycleOwner = LocalLifecycleOwner.current // Necesario para vincular el ciclo de vida a CameraX.
 5
 6    // Gestión de permisos, estado simple para el ejemplo.
 7    var hasCamera by remember { mutableStateOf(false) }
 8    var hasMic by remember { mutableStateOf(false) }
 9
10    val permissionsLauncher = rememberLauncherForActivityResult(
11        ActivityResultContracts.RequestMultiplePermissions()
12    ) { result ->
13        hasCamera = result[Manifest.permission.CAMERA] == true
14        hasMic = result[Manifest.permission.RECORD_AUDIO] == true
15    }
16
17    // Se lanza la petición de permisos al inicio, solo una vez (key1 = Unit).
18    LaunchedEffect(Unit) {
19        permissionsLauncher.launch(
20            arrayOf(
21                Manifest.permission.CAMERA,
22                Manifest.permission.RECORD_AUDIO
23            )
24        )
25    }

En esta primera parte del código, se definen las variables y estados necesarios:

  • contxt: Obtiene el contexto actual de Android utilizando LocalContext.current. Este contexto es necesario para muchas operaciones en Android, como acceder a recursos, iniciar actividades o servicios, y obtener información del sistema. En este caso, se pasa a CameraX para configurar la cámara y guardar vídeos.
  • lifecycleOwner: Obtiene el propietario del ciclo de vida actual utilizando LocalLifecycleOwner.current. Esto es crucial para vincular los casos de uso de CameraX al ciclo de vida de la actividad o fragmento, asegurando que la cámara se gestione correctamente según el estado de la UI.
  • hasCamera y hasMic: Variables de estado que indican si la aplicación tiene los permisos necesarios para acceder a la cámara y al micrófono, respectivamente. Se inician a false.
  • permissionsLauncher: Utiliza rememberLauncherForActivityResult para crear un lanzador que solicitará múltiples permisos. El resultado de la solicitud se maneja en el lambda onResult (parámetro result), donde se actualizan hasCamera y hasMic según los permisos concedidos.
  • LaunchedEffect(Unit): Se usa para lanzar la solicitud de permisos cuando el composable se monta por primera vez. Se solicitan los permisos necesarios para la cámara y el micrófono.
27    // Se crea previewView para la cámara, se usa remember para que no se recree en recomposiciones.
28    val previewView = remember {
29        PreviewView(contxt).apply { // PreviewView es un View de Android.
30            layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
31            implementationMode = PreviewView.ImplementationMode.COMPATIBLE
32            scaleType = PreviewView.ScaleType.FILL_CENTER
33        }
34    }
35
36    // cameraProviderFuture es un proceso asíncrono, se guarda en remember para que no se reinicie.
37    val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(contxt) }
38    // videoCapture se guarda como estado para poder usarlo en los botones.
39    var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
40    // isRecording para controlar el estado de grabación y deshabilitar botones.
41    var isRecording by remember { mutableStateOf(false) }
42    // lastVideoUri para mostrar la última ruta guardada en Galería.
43    var lastVideoUri by remember { mutableStateOf<String?>(null) }
44
45    // Executor principal para callbacks de CameraX
46    val mainExecutor = remember { ContextCompat.getMainExecutor(contxt) }

Seguidamente, se han definido más variables y estados necesarios para la configuración de la cámara y la grabación de vídeo:

  • previewView: Se crea una instancia de PreviewView, que es una vista nativa de Android utilizada para mostrar la previsualización de la cámara. Se utiliza remember para asegurarse de que esta vista no se recree en cada recomposición del composable. Se configuran sus parámetros de diseño y modo de implementación.
  • cameraProviderFuture: Se obtiene una instancia de ProcessCameraProvider utilizando getInstance(contxt). Este proceso es asíncrono, por lo que se guarda en remember para evitar que se reinicie en cada recomposición.
  • videoCapture: Se define como un estado mutable que almacenará la instancia de VideoCapture<Recorder> una vez que la cámara esté configurada. Inicialmente es null.
  • isRecording: Variable de estado que indica si la grabación de vídeo está en curso. Se utiliza para controlar el estado de los botones de grabación y detener. Inicialmente será false.
  • lastVideoUri: Variable de estado que almacenará la URI del último vídeo guardado en la galería. Se utiliza para mostrar esta información al usuario. Inicialmente también es null.
  • mainExecutor: Se obtiene el ejecutor principal utilizando ContextCompat.getMainExecutor(contxt). Este executor se utilizará para manejar los callbacks de CameraX, asegurando que se ejecuten en el hilo principal.
48    // Configurar CameraX una vez que el CameraProvider esté disponible.
49    LaunchedEffect(cameraProviderFuture) {
50        // Listener asíncrono, se lanza cuando cameraProviderFuture está listo.
51        cameraProviderFuture.addListener({
52            // CameraProvider listo, se configuran casos de uso.
53            val cameraProvider = cameraProviderFuture.get()
54
55            val previewUseCase = Preview.Builder().build()
56                .also { it.setSurfaceProvider(previewView.surfaceProvider) }
57
58            // Configuración de grabación con calidad y fallback.
59            // Se puede ajustar la lista de calidades según necesidades.
60            val qualitySelector = QualitySelector.fromOrderedList(
61                listOf(Quality.UHD, Quality.FHD, Quality.HD, Quality.SD),
62                FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
63            )
64
65            // Recorder con el selector de calidad.
66            val recorder = Recorder.Builder()
67                .setQualitySelector(qualitySelector)
68                .build()
69
70            // Caso de uso de VideoCapture
71            val videoUseCase = VideoCapture.withOutput(recorder)
72
73            try {
74                cameraProvider.unbindAll()
75                cameraProvider.bindToLifecycle(
76                    lifecycleOwner,
77                    CameraSelector.DEFAULT_BACK_CAMERA,
78                    previewUseCase,
79                    videoUseCase
80                )
81                videoCapture = videoUseCase
82            } catch (e: Exception) {
83                Log.e("CameraX", "Fallo al vincular casos de uso", e)
84            }
85        }, mainExecutor)
86    }

Ahora se ha configurado CameraX una vez que el CameraProvider está disponible:

  • LaunchedEffect(cameraProviderFuture): Se utiliza para ejecutar el bloque de código cuando cameraProviderFuture cambia. Esto asegura que la configuración de la cámara solo se realice una vez que el CameraProvider esté listo.
  • addListener: Se añade un listener asíncrono que se ejecutará cuando cameraProviderFuture está listo. Dentro de este bloque, se obtiene la instancia de CameraProvider.
  • previewUseCase: Se crea un caso de uso de Preview para mostrar la previsualización de la cámara en previewView.
  • qualitySelector: Se configura un selector de calidad que define una lista ordenada de calidades de vídeo (UHD, FHD, HD, SD) y una estrategia de fallback para seleccionar una calidad inferior o superior si la solicitada no está disponible. La estrategia de fallback asegura que siempre se seleccione una calidad válida, evitando errores si la calidad deseada no está soportada por el dispositivo.
  • recorder: Se crea una instancia de Recorder utilizando el qualitySelector.
  • videoUseCase: Se crea un caso de uso de VideoCapture utilizando el recorder.
  • bindToLifecycle: Se desvinculan todos los casos de uso previamente vinculados con unbindAll(), y luego se vinculan previewUseCase y videoUseCase al ciclo de vida del lifecycleOwner, utilizando la cámara trasera.
    • Si la vinculación es correcta, se actualiza videoCapture con la instancia de videoUseCase. Si se produce un error, se captura la excepción y se registra en el log.
  • Se utiliza mainExecutor para asegurar que las operaciones relacionadas con la UI se ejecuten en el hilo principal.
 88    // Pantalla UI.
 89    Box(Modifier.fillMaxSize().padding(innerPadding)) {
 90        // Vista previa
 91        AndroidView(
 92            factory = { previewView },
 93            modifier = Modifier.fillMaxSize()
 94        )
 95
 96        // Controles
 97        Column(
 98            modifier = Modifier
 99                .fillMaxWidth()
100                .align(Alignment.BottomCenter)
101                .padding(16.dp),
102            horizontalAlignment = Alignment.CenterHorizontally
103        ) {
104            if (!hasCamera) {
105                Text("Concede permiso de cámara para empezar.")
106                Spacer(Modifier.height(8.dp))
107            }
108
109            Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
110                Button(
111                    enabled = !isRecording && hasCamera && videoCapture != null,
112                    onClick = {
113                        val vc = videoCapture ?: return@Button
114
115                        // 1) Crear el registro de salida en MediaStore (Galería)
116                        val name = "CameraX-${
117                            SimpleDateFormat(
118                                "yyyyMMdd-HHmmss",
119                                Locale.US
120                            ).format(System.currentTimeMillis())
121                        }.mp4"
122                        val contentValues = ContentValues().apply {
123                            put(MediaStore.Video.Media.DISPLAY_NAME, name)
124                            put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
125                            // Ruta visible en Galería (Android 10+)
126                            put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX Recorder")
127                        }
128                        val mediaStoreOptions = MediaStoreOutputOptions.Builder(
129                            contxt.contentResolver,
130                            MediaStore.Video.Media.EXTERNAL_CONTENT_URI
131                        ).setContentValues(contentValues).build()
132
133                        // 2) Preparar la grabación
134                        var pending = vc.output.prepareRecording(contxt, mediaStoreOptions)
135
136                        // Habilitar audio solo si el permiso está concedido
137                        if (hasMic) {
138                            if (ActivityCompat.checkSelfPermission(
139                                    contxt,
140                                    Manifest.permission.RECORD_AUDIO
141                                ) == PackageManager.PERMISSION_GRANTED
142                            ) {
143                                pending = pending.withAudioEnabled()
144                            }
145                        }
146
147                        // 3) Iniciar y escuchar eventos
148                        val recording = pending.start(mainExecutor) { event ->
149                            when (event) {
150                                is VideoRecordEvent.Start -> isRecording = true
151
152                                is VideoRecordEvent.Finalize -> {
153                                    isRecording = false
154                                    if (event.error == VideoRecordEvent.Finalize.ERROR_NONE) {
155                                        lastVideoUri = event.outputResults.outputUri.toString()
156                                        // El archivo ya queda indexado en la Galería
157                                    } else Log.e("CameraX", "Error al finalizar: ${event.error}")
158                                }
159                            }
160                        }
161
162                        // Guardamos el handle en estado recordable para poder parar más tarde
163                        RecordingHolder.recording = recording
164                    }
165                ) { Text("Grabar") }
166
167                Button(
168                    enabled = isRecording,
169                    onClick = {
170                        // 4) Detener grabación
171                        RecordingHolder.recording?.stop()
172                        RecordingHolder.recording?.close()
173                        RecordingHolder.recording = null
174                    }
175                ) {
176                    Text("Detener")
177                }
178            }
179
180            Spacer(Modifier.height(8.dp))
181            if (lastVideoUri != null) {
182                Text(
183                    text = "Guardado en Galería:\n$lastVideoUri",
184                    style = MaterialTheme.typography.bodySmall
185                )
186            }
187        }
188    }
189}

Para terminar, se implementa la UI de la pantalla de grabación de vídeo:

  • Se utiliza un Box para contener la vista previa de la cámara y los controles de grabación.
  • AndroidView se usa para integrar previewView, que muestra la previsualización de la cámara.
  • Se crea una columna para los controles, que incluye un mensaje si no se tiene permiso para grabar audio.
  • Se añaden dos botones: uno para iniciar la grabación y otro para detenerla.
    • El botón “Grabar” está habilitado solo si no se está grabando, se tiene permiso de cámara y videoCapture no es null. Al hacer clic, se prepara la grabación creando un registro en MediaStore, configurando las opciones de salida y habilitando el audio si se tiene permiso. Luego, inicia la grabación y escucha los eventos de grabación para actualizar el estado.
    • El botón “Detener” está habilitado solo si se está grabando. Al hacer clic, detiene la grabación utilizando el Recording almacenado en RecordingHolder.
  • Se muestra la URI del último vídeo guardado en la galería si está disponible.
  • Se utiliza mainExecutor para asegurar que las operaciones relacionadas con la UI se ejecuten en el hilo principal.

Código completo

8.4. Selección de imágenes desde la galería con el Photo Picker

Photo Picker es una API que aparece en Android 13 (API 33) y permite a las aplicaciones acceder a los archivos multimedia del usuario sin necesidad de solicitar permisos de almacenamiento. Su diseño se centra en la privacidad y la simplicidad, delegando al sistema la presentación del selector y el control de los permisos temporales de acceso a los archivos.

A diferencia del acceso tradicional mediante permisos (READ_EXTERNAL_STORAGE, READ_MEDIA_IMAGES), el Photo Picker:

  • No requiere permisos adicionales.
  • Ofrece una interfaz de selección uniforme y gestionada por el sistema.
  • Permite elegir imágenes, vídeos o ambos, según la configuración del contrato.

En Compose, el Photo Picker se integra mediante el uso del Activity Result API, que proporciona un mecanismo seguro y estructurado para iniciar actividades y recibir resultados.

Para mostrar su funcionamiento se desarrollará una aplicación que muestre el selector de imágenes del sistema para elegir una imagen de la galería y mostrarla en pantalla. Se utilizará Coil para cargar y mostrar la imagen seleccionada, por lo que deberás añadir la dependencia en el archivo build.gradle:

1implementation("io.coil-kt.coil3:coil-compose:3.2.0")
2implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")

Al utilizar coil-network-okhttp, se añade soporte para cargar imágenes desde URLs y otras fuentes de red, mejorando la capacidad de la aplicación para manejar imágenes de diversas ubicaciones, añade el permiso de acceso a Internet en el AndroidManifest.xml (no es necesario para el Photo Picker, pero sí para cargar imágenes desde la web):

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

Se creará un método para obtener la información de la imagen seleccionada.

 1// Obtiene información básica (nombre, tamaño y tipo MIME) de un Uri.
 2fun getImageInfo(context: Context, uri: Uri): String {
 3    val contentResolver = context.contentResolver
 4    val projection = arrayOf(
 5        android.provider.OpenableColumns.DISPLAY_NAME,
 6        android.provider.OpenableColumns.SIZE
 7    )
 8
 9    contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
10        val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
11        val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
12
13        if (cursor.moveToFirst()) {
14            val name = cursor.getString(nameIndex)
15            val sizeBytes = cursor.getLong(sizeIndex)
16            val mime = contentResolver.getType(uri) ?: "Desconocido"
17
18            val sizeFormatted = DecimalFormat("#,##0.00").format(sizeBytes / 1024.0)
19            return "📄 $name\n📐 $sizeFormatted KB\n🗂 Tipo: $mime"
20        }
21    }
22    return "Información no disponible"
23}

Este método getImageInfo recibe un Context y un Uri, y devuelve una cadena con el nombre del archivo, su tamaño en KB y el tipo MIME. Se utiliza un ContentResolver para consultar los metadatos del archivo. A continuación, se implementa el método GalleryPicker, que será el composable encargado de mostrar el botón para abrir el selector y la imagen seleccionada.

 1@Composable
 2fun GalleryPicker(modifier: Modifier = Modifier, onImageSelected: (Uri?) -> Unit) {
 3    val contxt = LocalContext.current
 4    // Se guarda la Uri de forma "saveable" (Uri es Parcelable) para sobrevivir a rotaciones, etc.
 5    var selectedImageUri by rememberSaveable { mutableStateOf<Uri?>(null) }
 6    var imageInfo by rememberSaveable { mutableStateOf("") }
 7
 8    // Lanzador del Photo Picker (selección única)
 9    val pickImageLauncher = rememberLauncherForActivityResult(
10        contract = ActivityResultContracts.PickVisualMedia(),
11        onResult = { uri: Uri? ->
12            selectedImageUri = uri
13            imageInfo = uri?.let { getImageInfo(contxt, it) } ?: ""
14            onImageSelected(uri)
15        }
16    )
17
18    Column(
19        modifier = modifier
20            .fillMaxSize()
21            .padding(16.dp)
22            .verticalScroll(rememberScrollState()),
23        horizontalAlignment = Alignment.CenterHorizontally,
24        verticalArrangement = Arrangement.Center
25    ) {
26        AsyncImage( // Vista previa con Coil si hay Uri
27            model = selectedImageUri,
28            contentDescription = "Imagen seleccionada",
29            modifier = Modifier
30                .size(320.dp)
31                .clip(RoundedCornerShape(16.dp)),
32            contentScale = ContentScale.Crop
33        )
34
35        Spacer(Modifier.height(4.dp))
36
37        // Información de la imagen
38        if (selectedImageUri != null) {
39            Spacer(Modifier.height(16.dp))
40            Text(
41                text = imageInfo,
42                style = MaterialTheme.typography.bodyMedium
43            )
44        }
45
46        Spacer(Modifier.height(32.dp))
47
48        Button(
49            onClick = {
50                // Solo imágenes (puedes cambiar a ImageAndVideo si procede)
51                pickImageLauncher.launch(
52                    PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
53                )
54            }
55        ) {
56            Text("Elegir de galería")
57        }
58    }
59}

En este método se definen varios elementos clave:

  • selectedImageUri: Estado que almacena la URI de la imagen seleccionada. Se utiliza rememberSaveable para que el valor persista a través de recomposiciones y cambios de configuración (como rotaciones).
  • imageInfo: Estado que almacena la información de la imagen seleccionada, obtenida mediante el método getImageInfo.
  • pickImageLauncher: Utiliza rememberLauncherForActivityResult para crear un lanzador que inicia el Photo Picker. El contrato utilizado es ActivityResultContracts.PickVisualMedia(), que permite seleccionar medios visuales. El resultado se maneja en el lambda onResult, donde se actualizan selectedImageUri e imageInfo, y se llama a onImageSelected para notificar al padre.
  • La UI se compone de una columna que contiene:
    • AsyncImage: Componente de Coil que muestra la imagen seleccionada si selectedImageUri no es null. Se aplica una forma redondeada y se ajusta el contenido para recortar la imagen (Crop).
    • Un Text que muestra la información de la imagen si hay una imagen seleccionada.
    • Un botón que, al hacer clic, lanza el Photo Picker para seleccionar solo imágenes (se puede cambiar a ImageAndVideo si quieres permitir la selección de vídeos también).

Por último, se utiliza GalleryPicker en la MainActivity para mostrar el selector de imágenes.

 1class MainActivity : ComponentActivity() {
 2    override fun onCreate(savedInstanceState: Bundle?) {
 3        super.onCreate(savedInstanceState)
 4        enableEdgeToEdge()
 5
 6        setContent {
 7            DocumentationT8_6Theme {
 8                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
 9                    GalleryPicker(modifier = Modifier.padding(innerPadding)) { uri: Uri? ->
10                        // Aquí se puede manejar el Uri seleccionado (o null si se canceló)
11                        if (uri == null) {
12                            Toast.makeText(
13                                this,
14                                "No se ha seleccionado ninguna imagen",
15                                Toast.LENGTH_LONG
16                            ).show()
17                            return@GalleryPicker
18                        } else Toast.makeText(this, "Uri: ${uri.path}", Toast.LENGTH_LONG).show()
19                    }
20                }
21            }
22        }
23    }
24}

Código completo

8.5. Grabación de audio con MediaRecorder

Para la grabación de audio, Android dispone de la clase MediaRecorder, que permite capturar sonido desde el micrófono y codificarlo en distintos formatos, como AAC, AMR o MP3 (dependiendo del dispositivo).

8.5.1. Permisos necesarios

Para grabar audio, es necesario solicitar el permiso RECORD_AUDIO en el archivo AndroidManifest.xml:

Si el audio se guarda en MediaStore (colección de música), no será necesario el permiso WRITE_EXTERNAL_STORAGE en Android 10 o superior.

1<uses-permission android:name="android.permission.RECORD_AUDIO" />
Información

En Android 13 (API 33) se introducen permisos granulares (READ_MEDIA_AUDIO, READ_MEDIA_IMAGES, READ_MEDIA_VIDEO), pero no son necesarios para insertar archivos propios en MediaStore.

8.5.2 MediaRecorder

La clase MediaRecorder permite configurar y controlar la grabación de audio. A continuación, se describen los pasos básicos para utilizar esta clase:

  • Configurar la fuente de audio: setAudioSource(MediaRecorder.AudioSource.MIC)
  • Definir el formato de salida y el codificador:
    • Formato recomendado: MPEG_4
    • Códec recomendado: AAC
  • Establecer el destino de salida (setOutputFile), en este caso, un descriptor de archivo obtenido de MediaStore.
  • Llamar a prepare() y luego start().
  • Al finalizar, detener con stop() y liberar recursos con release().

8.5.3 Ejemplo completo de Grabadora con cronómetro

A continuación, se muestra una implementación completa y funcional de una grabadora de audio en Jetpack Compose. En el ejemplo podrás ver cómo solicitar permisos, iniciar y detener la grabación, y mostrar un cronómetro en tiempo real.

En primer lugar se creará el método encargado de formatear el tiempo en segundos a un formato mm:ss.

1private fun formatTime(seconds: Long): String {
2    val m = seconds / 60
3    val s = seconds % 60
4    return "%02d:%02d".format(m, s)
5}

Como puedes ver, el método formatTime toma un valor en segundos y lo convierte en una cadena con el formato mm:ss, donde mm representa los minutos y ss los segundos, ambos con dos dígitos. A continuación, se implementa el método createMediaStoreOutput, que se encargará de crear un archivo de salida en MediaStore y devolver su URI y descriptor de archivo.

 1private fun createMediaStoreOutput(context: Context): Pair<Uri, ParcelFileDescriptor> {
 2    val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
 3    val name = "REC_$timestamp.m4a"
 4
 5    val values = ContentValues().apply {
 6        put(MediaStore.Audio.Media.DISPLAY_NAME, name)
 7        put(MediaStore.Audio.Media.MIME_TYPE, "audio/mp4")
 8        put(MediaStore.Audio.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MUSIC}/PMDM")
 9    }
10
11    val resolver: ContentResolver = context.contentResolver
12    val uri = resolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values)
13        ?: throw IllegalStateException("No se pudo crear el archivo en MediaStore")
14
15    val pfd = resolver.openFileDescriptor(uri, "w")
16        ?: throw IllegalStateException("No se pudo abrir el descriptor de archivo")
17
18    return uri to pfd
19}

Si se entra en detalle, el método createMediaStoreOutput realiza las siguientes acciones:

  • Genera un nombre de archivo único basado en la fecha y hora actual, con el formato REC_yyyyMMdd_HHmmss.m4a.
  • Crea un ContentValues para definir los metadatos del archivo, incluyendo el nombre, el tipo MIME y la ruta relativa dentro del directorio de música.
  • Utiliza el ContentResolver para insertar un nuevo registro en MediaStore, obteniendo la URI del archivo creado.
  • Abre un ParcelFileDescriptor en modo escritura ("w") para el archivo recién creado.
  • Devuelve un par que contiene la URI y el descriptor de archivo, que se utilizarán para configurar el MediaRecorder.

Ya por último, se implementa el composable AudioRecorderScreen, que es la interfaz de usuario para la grabadora de audio.

 1@Composable
 2fun AudioRecorderScreen(modifier: Modifier = Modifier) {
 3    val contxt = LocalContext.current
 4    var isRecording by remember { mutableStateOf(false) }
 5    var elapsedTime by remember { mutableStateOf(0L) }
 6    var recorder by remember { mutableStateOf<MediaRecorder?>(null) }
 7    var outputUri by remember { mutableStateOf<Uri?>(null) }
 8
 9    // Lanzador para solicitar permisos
10    val requestPermission = rememberLauncherForActivityResult(
11        contract = ActivityResultContracts.RequestPermission()
12    ) { granted ->
13        if (!granted)
14            Toast.makeText(contxt, "Permiso denegado", Toast.LENGTH_SHORT).show()
15    }
16
17    // Se lanza la petición de permisos al inicio, solo una vez (key1 = Unit).
18    LaunchedEffect(Unit) {
19        requestPermission.launch(Manifest.permission.RECORD_AUDIO)
20    }
21
22    // Cronómetro
23    LaunchedEffect(isRecording) {
24        if (isRecording) {
25            elapsedTime = 0
26            while (isActive && isRecording) {
27                delay(1000)
28                elapsedTime++
29            }
30        }
31    }
32
33    Column(
34        modifier = modifier
35            .fillMaxSize()
36            .padding(24.dp),
37        verticalArrangement = Arrangement.spacedBy(16.dp)
38    ) {
39        Text("Grabadora de audio", style = MaterialTheme.typography.titleLarge)
40        Text("Tiempo: ${formatTime(elapsedTime)}")
41
42        Button(
43            onClick = {
44                if (isRecording) {
45                    recorder?.stop()
46                    recorder?.release()
47                    recorder = null
48                    isRecording = false
49                    Toast.makeText(contxt, "Grabación finalizada", Toast.LENGTH_SHORT).show()
50                } else {
51                    try {
52                        val (uri, fd) = createMediaStoreOutput(contxt)
53                        outputUri = uri
54
55                        recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
56                            MediaRecorder(contxt)
57                        } else {
58                            MediaRecorder()
59                        }.apply {
60                            setAudioSource(MediaRecorder.AudioSource.MIC)
61                            setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
62                            setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
63                            setAudioEncodingBitRate(128000)
64                            setAudioSamplingRate(44100)
65                            setOutputFile(fd.fileDescriptor)
66                            prepare()
67                            start()
68                        }
69                        isRecording = true
70                    } catch (e: Exception) {
71                        Toast.makeText(
72                            contxt,
73                            "Error: ${e.localizedMessage}",
74                            Toast.LENGTH_SHORT
75                        ).show()
76                    }
77                }
78            }
79        ) {
80            Text(if (isRecording) "Detener" else "Grabar")
81        }
82
83        outputUri?.let {
84            Text("Archivo guardado en:\n$it", style = MaterialTheme.typography.bodySmall)
85        }
86    }
87}

Si se analiza el código, el composable AudioRecorderScreen realiza las siguientes funciones:

  • Define varios estados:

    • isRecording: Indica si la grabación está en curso.
    • elapsedTime: Almacena el tiempo transcurrido en segundos.
    • recorder: Mantiene una referencia al objeto MediaRecorder.
    • outputUri: Almacena la URI del archivo de audio guardado.
  • Utiliza rememberLauncherForActivityResult para crear un lanzador que solicita el permiso RECORD_AUDIO. Si el permiso es denegado, muestra un mensaje de error.

  • LaunchedEffect(Unit): Solicita el permiso de grabación de audio cuando el composable se monta por primera vez.

  • LaunchedEffect(isRecording): Implementa un cronómetro que incrementa elapsedTime cada segundo mientras isRecording es true.

  • La UI se compone de una columna que contiene:

    • Un título y un texto que muestra el tiempo transcurrido formateado.
    • Un botón que inicia o detiene la grabación. Al hacer clic:
      • Si ya se está grabando, detiene y libera el MediaRecorder, actualiza el estado y muestra un mensaje.
      • Si no se está grabando, crea un nuevo archivo en MediaStore, configura y comienza la grabación con MediaRecorder. Si ocurre un error, muestra un mensaje de error.
    • Si outputUri no es null, muestra la URI del archivo guardado.

Código completo

8.6. Sensores básicos

Android proporciona acceso a una variedad de sensores integrados en los dispositivos, como acelerómetros, giroscopios, sensores de luz, proximidad, y otros. Estos sensores permiten que las aplicaciones puedan recopilar datos del entorno y del movimiento del dispositivo para ofrecer experiencias más interactivas y contextuales.

Los sensores se pueden utilizar para una variedad de propósitos, como detectar la orientación del dispositivo, medir la luz ambiental para ajustar el brillo de la pantalla o detectar el movimiento del dispositivo para activar ciertas funciones. Para acceder a los sensores en Android, se utiliza el SensorManager, que proporciona métodos para registrar y desregistrar oyentes de sensores, así como para obtener información sobre los sensores disponibles en el dispositivo.

Obviamente, el uso de sensores puede afectar al consumo de batería del dispositivo, por lo que es importante gestionar adecuadamente el registro y desregistro de los oyentes de sensores para minimizar el impacto en la duración de la batería.

El siguiente composable muestra cómo utilizar el acelerómetro para medir los cambios en la aceleración del dispositivo y mostrar los valores de los ejes X, Y y Z en tiempo real.

 1@Composable
 2fun AccelerometerSensor() {
 3    val contxt = LocalContext.current
 4    val sensorManager = contxt.getSystemService(Context.SENSOR_SERVICE) as SensorManager
 5    val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
 6
 7    var x by remember { mutableStateOf(0f) }
 8    var y by remember { mutableStateOf(0f) }
 9    var z by remember { mutableStateOf(0f) }
10
11    // Filtro paso-bajo simple, lowPass se usa para suavizar los valores del sensor,
12    // evitando cambios bruscos en la UI.
13    val alpha = 0.1f
14    fun lowPass(new: Float, old: Float) = old + alpha * (new - old)
15
16    // Registrar el listener del sensor cuando el Composable entre en composición.
17    // DisposableEffect se asegura de que el listener se desregistre cuando el Composable se elimine.
18    DisposableEffect(Unit) {
19        if (accelerometer == null) {
20            return@DisposableEffect onDispose {}
21        }
22
23        val listener = object : SensorEventListener {
24            override fun onSensorChanged(event: SensorEvent?) {
25                if (event?.sensor?.type == Sensor.TYPE_ACCELEROMETER) {
26                    x = lowPass(event.values[0], x)
27                    y = lowPass(event.values[1], y)
28                    z = lowPass(event.values[2], z)
29                }
30            }
31
32            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
33        }
34
35        sensorManager.registerListener(
36            listener,
37            accelerometer,
38            SensorManager.SENSOR_DELAY_NORMAL
39        )
40
41        // Asegurar que se desregistre el listener cuando el Composable se elimine.
42        onDispose {
43            sensorManager.unregisterListener(listener)
44        }
45    }
46
47    Column {
48        Text("Acelerómetro (m/s²):")
49        Text("X: ${"%.2f".format(x)}   Y: ${"%.2f".format(y)}   Z: ${"%.2f".format(z)}")
50        if (accelerometer == null) Text("No disponible en este dispositivo.")
51    }
52}

Las acciones principales que realiza este composable son:

  • Obtiene el SensorManager y el sensor de tipo TYPE_ACCELEROMETER.
  • Define variables de estado x, y y z para almacenar los valores del acelerómetro.
  • Implementa un filtro de paso bajo simple para suavizar los valores del sensor y evitar cambios bruscos en la UI.
  • Utiliza DisposableEffect para registrar un SensorEventListener cuando el composable entra en composición, y se asegura de desregistrar el listener cuando el composable se elimina.
  • Se registra el listener con un retardo normal (SENSOR_DELAY_NORMAL), pero se pueden usar otros valores según la necesidad de la aplicación, SENSOR_DELAY_GAME, por ejemplo, es más rápido y da una respuesta más fluida, pero consume más batería.
  • En el método onSensorChanged, actualiza los valores de x, y y z utilizando el filtro de paso bajo.
  • Muestra los valores del acelerómetro en la UI, formateados a dos decimales.
  • Si el acelerómetro no está disponible en el dispositivo, muestra un mensaje indicando que no está disponible.

Código completo

Si en lugar de utilizar el acelerómetro quieres usar otro sensor, por ejemplo, el giroscopio, simplemente cambia la línea:

val gyroscope = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)

Y en lugar de comprobar los valores de X, Y y Z del acelerómetro, lo harás con los del giroscopio, que son las tasas de rotación alrededor de los ejes X, Y y Z, que serían pitch/roll/yaw, valores de rotación en radianes por segundo.

Fuentes