Permisos, almacenamiento y multimedia en Android con Kotlin

Capítulo 10

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

Permisos en Android: qué son y cuándo se piden

Los permisos controlan el acceso a recursos sensibles del dispositivo (fotos, cámara, ubicación, micrófono, etc.). En Android moderno hay dos ideas clave: permisos declarados (en el manifiesto) y permisos en tiempo de ejecución (el usuario decide en el momento de uso). Además, muchas tareas comunes ya no requieren permisos si usas los pickers del sistema (por ejemplo, seleccionar una imagen con el selector de fotos), lo cual mejora privacidad y reduce fricción.

Tipos de acceso frecuentes en multimedia y archivos

  • Seleccionar una imagen: recomendado usar el selector del sistema (Photo Picker o Document Picker). Normalmente no requiere permisos porque el usuario elige explícitamente el archivo.
  • Tomar una foto con cámara: puede requerir android.permission.CAMERA si usas la cámara directamente. Si delegas en una app de cámara mediante un contrato del sistema, el permiso puede no ser necesario, pero es común solicitarlo para flujos más controlados.
  • Leer/guardar archivos: recomendado usar Storage Access Framework (SAF) con ACTION_OPEN_DOCUMENT / ACTION_CREATE_DOCUMENT. Normalmente no requiere permisos y te da acceso solo al documento seleccionado.
  • Acceso amplio a fotos/medios: en Android 13+ existen permisos granulares como READ_MEDIA_IMAGES, pero para un caso de “seleccionar una imagen” suele ser mejor el picker.

Checklist de seguridad y privacidad (práctico)

  • Minimiza permisos: si puedes resolverlo con un picker del sistema, evita pedir permisos de lectura de almacenamiento.
  • Pide permisos “just-in-time”: solicita el permiso justo antes de la acción que lo necesita, no al abrir la app.
  • Explica el motivo: si el usuario deniega, muestra un mensaje claro y ofrece alternativa (por ejemplo, seleccionar desde el picker en lugar de acceso amplio).
  • Maneja “No volver a preguntar”: si el usuario bloquea el permiso, guía a Ajustes de la app.
  • No guardes más de lo necesario: evita copiar imágenes a almacenamiento propio si solo necesitas mostrarlas; usa Uri y streams.
  • Respeta el alcance: usa SAF/Photo Picker para que el usuario controle qué comparte.
  • Evita logs sensibles: no imprimas rutas, URIs o metadatos privados en logs en producción.
  • Revisa dependencias: librerías de imagen/cámara deben ser confiables y actualizadas.

Herramientas recomendadas: Activity Result API

Para permisos y selección de contenido, usa la Activity Result API, que simplifica el manejo de resultados y evita APIs obsoletas. En Compose, se integra con rememberLauncherForActivityResult.

Contratos útiles

  • ActivityResultContracts.RequestPermission(): pedir un permiso.
  • ActivityResultContracts.PickVisualMedia(): Photo Picker (cuando está disponible).
  • ActivityResultContracts.GetContent(): selector genérico por MIME type (por ejemplo image/*).
  • ActivityResultContracts.OpenDocument(): SAF para abrir documentos (persistible).
  • ActivityResultContracts.CreateDocument(): SAF para crear/guardar un archivo.
  • ActivityResultContracts.TakePicture(): tomar foto y guardarla en un Uri proporcionado.

Guía paso a paso: laboratorio “Selecciona una imagen y muéstrala”

Objetivo: construir un flujo donde el usuario pulsa un botón, se abre el selector de imágenes, y la app muestra la imagen seleccionada. Además, se manejarán estados de permiso concedido/denegado para el caso opcional de cámara (para practicar permisos en tiempo de ejecución).

1) Dependencias y permisos en el manifiesto

Para seleccionar una imagen con picker normalmente no necesitas permisos. Para el laboratorio incluiremos opcionalmente el permiso de cámara para practicar el flujo de concedido/denegado.

<!-- AndroidManifest.xml --> <manifest ...>     <uses-permission android:name="android.permission.CAMERA" />     <application ...>         ...     </application> </manifest>

Nota: si tu app no ofrece función de cámara, no declares este permiso.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

2) Modelo de estado para la UI

Define estados simples: la Uri de la imagen seleccionada y el estado del permiso de cámara (para mostrar mensajes y habilitar/deshabilitar acciones).

sealed class CameraPermissionState {     data object Unknown : CameraPermissionState()     data object Granted : CameraPermissionState()     data object Denied : CameraPermissionState()     data object DeniedPermanently : CameraPermissionState() }  data class MediaUiState(     val selectedImageUri: android.net.Uri? = null,     val cameraPermissionState: CameraPermissionState = CameraPermissionState.Unknown,     val message: String? = null )

3) Selector de imágenes con Photo Picker (y fallback)

Implementa un launcher para seleccionar imágenes. Si el Photo Picker no está disponible en algún dispositivo, puedes usar GetContent como alternativa. En la práctica, muchos proyectos usan PickVisualMedia como primera opción.

// En un Composable @Composable fun MediaLabScreen() {     var uiState by androidx.compose.runtime.mutableStateOf(MediaUiState())      val pickImageLauncher = androidx.activity.compose.rememberLauncherForActivityResult(         contract = androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia()     ) { uri ->         uiState = if (uri != null) {             uiState.copy(selectedImageUri = uri, message = null)         } else {             uiState.copy(message = "No se seleccionó ninguna imagen.")         }     }      val fallbackGetContentLauncher = androidx.activity.compose.rememberLauncherForActivityResult(         contract = androidx.activity.result.contract.ActivityResultContracts.GetContent()     ) { uri ->         uiState = if (uri != null) {             uiState.copy(selectedImageUri = uri, message = null)         } else {             uiState.copy(message = "No se seleccionó ninguna imagen.")         }     }      // UI ... }

Para lanzar el picker:

// Photo Picker (imágenes) pickImageLauncher.launch(     androidx.activity.result.PickVisualMediaRequest(         androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly     ) )  // Fallback genérico fallbackGetContentLauncher.launch("image/*")

4) Mostrar la imagen seleccionada en Compose

Para mostrar una Uri en Compose, una opción práctica es usar una librería de carga de imágenes (por ejemplo Coil). Si ya la tienes en tu proyecto, puedes usar AsyncImage. Si no, también puedes decodificar manualmente, pero suele ser más verboso y propenso a errores.

// Ejemplo con Coil (si está disponible en tu proyecto) // coil-compose: AsyncImage  @Composable fun SelectedImagePreview(uri: android.net.Uri?) {     if (uri == null) {         androidx.compose.material3.Text("Aún no hay imagen seleccionada.")         return     }     coil.compose.AsyncImage(         model = uri,         contentDescription = "Imagen seleccionada",         modifier = androidx.compose.ui.Modifier             .fillMaxWidth()             .aspectRatio(1f)     ) }

5) Permiso de cámara: solicitar y manejar concedido/denegado

Aunque el laboratorio principal es “seleccionar imagen”, añadimos un bloque de práctica para permisos. La idea: un botón “Habilitar cámara” solicita el permiso y actualiza el estado. Si se deniega permanentemente, se ofrece ir a Ajustes.

@Composable fun CameraPermissionSection(     onStateChange: (CameraPermissionState, String?) -> Unit ) {     val context = androidx.compose.ui.platform.LocalContext.current     val activity = context as? android.app.Activity      val requestCameraPermissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult(         contract = androidx.activity.result.contract.ActivityResultContracts.RequestPermission()     ) { granted ->         if (granted) {             onStateChange(CameraPermissionState.Granted, "Permiso de cámara concedido.")         } else {             // Detectar si es denegación permanente (no mostrar diálogo de nuevo)             val permanentlyDenied = activity != null &&                 !androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale(                     activity,                     android.Manifest.permission.CAMERA                 )             if (permanentlyDenied) {                 onStateChange(CameraPermissionState.DeniedPermanently, "Permiso denegado permanentemente. Habilítalo en Ajustes.")             } else {                 onStateChange(CameraPermissionState.Denied, "Permiso de cámara denegado.")             }         }     }      androidx.compose.material3.Button(onClick = {         requestCameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)     }) {         androidx.compose.material3.Text("Habilitar cámara")     } }

Función para abrir Ajustes de la app cuando el permiso está bloqueado:

fun openAppSettings(context: android.content.Context) {     val intent = android.content.Intent(         android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,         android.net.Uri.fromParts("package", context.packageName, null)     )     intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)     context.startActivity(intent) }

6) Pantalla completa del laboratorio (UI + estados)

Integra todo: botones para seleccionar imagen (Photo Picker y fallback), previsualización, mensajes, y sección de permisos.

@Composable fun MediaLabScreen() {     var uiState by androidx.compose.runtime.mutableStateOf(MediaUiState())     val context = androidx.compose.ui.platform.LocalContext.current      val pickImageLauncher = androidx.activity.compose.rememberLauncherForActivityResult(         contract = androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia()     ) { uri ->         uiState = if (uri != null) uiState.copy(selectedImageUri = uri, message = null)         else uiState.copy(message = "No se seleccionó ninguna imagen.")     }      val fallbackGetContentLauncher = androidx.activity.compose.rememberLauncherForActivityResult(         contract = androidx.activity.result.contract.ActivityResultContracts.GetContent()     ) { uri ->         uiState = if (uri != null) uiState.copy(selectedImageUri = uri, message = null)         else uiState.copy(message = "No se seleccionó ninguna imagen.")     }      androidx.compose.foundation.layout.Column(         modifier = androidx.compose.ui.Modifier             .fillMaxSize()             .padding(16.dp),         verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp)     ) {         androidx.compose.material3.Text("Laboratorio: seleccionar y mostrar una imagen")          androidx.compose.material3.Button(onClick = {             pickImageLauncher.launch(                 androidx.activity.result.PickVisualMediaRequest(                     androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly                 )             )         }) {             androidx.compose.material3.Text("Seleccionar imagen (Photo Picker)")         }          androidx.compose.material3.OutlinedButton(onClick = {             fallbackGetContentLauncher.launch("image/*")         }) {             androidx.compose.material3.Text("Seleccionar imagen (GetContent)")         }          SelectedImagePreview(uri = uiState.selectedImageUri)          uiState.message?.let { msg ->             androidx.compose.material3.Text(msg)         }          androidx.compose.material3.Divider()          androidx.compose.material3.Text("Práctica de permisos: cámara")         CameraPermissionSection { newState, msg ->             uiState = uiState.copy(cameraPermissionState = newState, message = msg)         }          if (uiState.cameraPermissionState is CameraPermissionState.DeniedPermanently) {             androidx.compose.material3.OutlinedButton(onClick = {                 openAppSettings(context)             }) {                 androidx.compose.material3.Text("Abrir Ajustes de la app")             }         }     } }

Lectura y guardado de archivos (SAF) con ejemplos cortos

Para leer o guardar archivos (PDF, texto, imágenes exportadas), el enfoque recomendado es el Storage Access Framework. El usuario elige el documento y tu app recibe una Uri con permisos acotados.

Abrir un documento

val openDocumentLauncher = rememberLauncherForActivityResult(     contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() ) { uri ->     // uri puede ser null si el usuario cancela }  // Lanzar para PDFs y texto openDocumentLauncher.launch(arrayOf("application/pdf", "text/plain"))

Crear/guardar un documento

val createDocumentLauncher = rememberLauncherForActivityResult(     contract = androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain") ) { uri ->     // Escribe contenido usando contentResolver.openOutputStream(uri) }  createDocumentLauncher.launch("mi_archivo.txt")

Leer/escribir usando ContentResolver

fun readTextFromUri(context: android.content.Context, uri: android.net.Uri): String {     context.contentResolver.openInputStream(uri).use { input ->         if (input == null) return ""         return input.bufferedReader().readText()     } }  fun writeTextToUri(context: android.content.Context, uri: android.net.Uri, text: String) {     context.contentResolver.openOutputStream(uri).use { output ->         if (output == null) return         output.bufferedWriter().use { it.write(text) }     } }

Uso de cámara (enfoque práctico): tomar foto a un Uri

Si quieres capturar una foto y guardarla, el contrato TakePicture() requiere que le pases un Uri de destino (normalmente usando un FileProvider). Este flujo es robusto porque no depende de obtener el bitmap en memoria.

1) Configurar FileProvider

En el manifiesto:

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

Crea res/xml/file_paths.xml:

<paths>     <cache-path name="cache" path="." /> </paths>

2) Crear un archivo temporal y obtener su Uri

fun createTempImageUri(context: android.content.Context): android.net.Uri {     val imagesDir = java.io.File(context.cacheDir, "images").apply { mkdirs() }     val file = java.io.File.createTempFile("photo_", ".jpg", imagesDir)     return androidx.core.content.FileProvider.getUriForFile(         context,         context.packageName + ".fileprovider",         file     ) }

3) Lanzar TakePicture y actualizar UI

@Composable fun TakePhotoButton(     enabled: Boolean,     onPhotoUri: (android.net.Uri) -> Unit,     onMessage: (String) -> Unit ) {     val context = androidx.compose.ui.platform.LocalContext.current     var pendingUri by androidx.compose.runtime.mutableStateOf<android.net.Uri?>(null)      val takePictureLauncher = rememberLauncherForActivityResult(         contract = androidx.activity.result.contract.ActivityResultContracts.TakePicture()     ) { success ->         val uri = pendingUri         if (success && uri != null) onPhotoUri(uri)         else onMessage("No se pudo tomar la foto o se canceló.")     }      androidx.compose.material3.Button(         enabled = enabled,         onClick = {             val uri = createTempImageUri(context)             pendingUri = uri             takePictureLauncher.launch(uri)         }     ) {         androidx.compose.material3.Text("Tomar foto")     } }

Recomendación: habilita este botón solo si el permiso de cámara está concedido (según tu CameraPermissionState).

Tabla rápida: qué usar según el caso

NecesidadRecomendado¿Permiso?
Elegir una imagen del usuarioPhoto Picker (PickVisualMedia)No (en la mayoría de casos)
Elegir cualquier archivoSAF (OpenDocument)No
Guardar/exportar archivoSAF (CreateDocument)No
Capturar foto y guardarlaTakePicture + FileProviderFrecuente: sí (CAMERA)

Pruebas manuales: casos que debes verificar

  • El usuario selecciona imagen y se muestra correctamente.
  • El usuario cancela el picker: se muestra mensaje y no se rompe la UI.
  • Permiso de cámara concedido: se habilita “Tomar foto” y se actualiza la imagen.
  • Permiso denegado: se muestra mensaje y la app sigue operativa.
  • Permiso denegado permanentemente: aparece botón para abrir Ajustes.
  • Rotación/recreación: si necesitas persistir la Uri, guárdala en estado persistente (por ejemplo, en tu capa de estado) y vuelve a renderizar.

Ahora responde el ejercicio sobre el contenido:

Quieres permitir que el usuario elija una imagen para mostrarla en una pantalla de Compose, priorizando privacidad y evitando pedir permisos innecesarios. ¿Qué enfoque es el más recomendado?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El uso de pickers del sistema con la Activity Result API permite que el usuario elija explícitamente la imagen y la app reciba una Uri con acceso acotado, normalmente sin pedir permisos de almacenamiento, mejorando privacidad y reduciendo fricción.

Siguiente capítulo

Notificaciones y componentes del sistema Android

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
71%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.