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.CAMERAsi 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
Uriy 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 ejemploimage/*).ActivityResultContracts.OpenDocument(): SAF para abrir documentos (persistible).ActivityResultContracts.CreateDocument(): SAF para crear/guardar un archivo.ActivityResultContracts.TakePicture(): tomar foto y guardarla en unUriproporcionado.
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.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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
| Necesidad | Recomendado | ¿Permiso? |
|---|---|---|
| Elegir una imagen del usuario | Photo Picker (PickVisualMedia) | No (en la mayoría de casos) |
| Elegir cualquier archivo | SAF (OpenDocument) | No |
| Guardar/exportar archivo | SAF (CreateDocument) | No |
| Capturar foto y guardarla | TakePicture + FileProvider | Frecuente: 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.