Anexo 5. Referencia: Permisos en Android moderno, FileProvider y WorkManager avanzado#
- Tipo: Anexo de apoyo — material de consulta y profundización
- Bloque: B4 — Multimedia en Android
- RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
1. Mapa completo de permisos en Android#
Permisos peligrosos más habituales (solicitar en tiempo de ejecución)#
| Grupo | Permiso | API | Cuándo es necesario |
|---|---|---|---|
| Cámara | CAMERA |
Todos | Usar CameraX / Camera2 |
| Almacenamiento | READ_MEDIA_IMAGES |
33+ | Leer imágenes sin Photo Picker |
| Almacenamiento | READ_MEDIA_VIDEO |
33+ | Leer vídeos sin Photo Picker |
| Almacenamiento | READ_EXTERNAL_STORAGE |
≤32 | Leer medios en API ≤ 32 |
| Almacenamiento | WRITE_EXTERNAL_STORAGE |
≤28 | Escribir fuera de MediaStore |
| Micrófono | RECORD_AUDIO |
Todos | Grabar audio / vídeo con sonido |
| Localización | ACCESS_COARSE_LOCATION |
Todos | Red (~300m) |
| Localización | ACCESS_FINE_LOCATION |
Todos | GPS (~10m) |
| Localización | ACCESS_BACKGROUND_LOCATION |
29+ | GPS con app en segundo plano |
| Notificaciones | POST_NOTIFICATIONS |
33+ | Publicar cualquier notificación |
| Actividad física | ACTIVITY_RECOGNITION |
29+ | Contador de pasos (TYPE_STEP_COUNTER) |
| Sensores corporales | BODY_SENSORS |
Todos | Frecuencia cardíaca |
| Teléfono | READ_PHONE_STATE |
Todos | Estado de la llamada |
| Contactos | READ_CONTACTS |
Todos | Leer la agenda |
Permisos normales (solo declarar en Manifest, no solicitar en runtime)#
1<uses-permission android:name="android.permission.INTERNET" />
2<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
3<uses-permission android:name="android.permission.VIBRATE" />
4<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />Flujo correcto para solicitar un permiso peligroso#
1. Comprobar si ya está concedido (ContextCompat.checkSelfPermission)
│
├── GRANTED → Ejecutar la función directamente
│
└── DENIED
│
├── shouldShowRequestPermissionRationale() == true
│ → Mostrar explicación al usuario ANTES del diálogo
│
└── Lanzar launcher.launch(permiso)
│
├── Usuario acepta → GRANTED → ejecutar función
│
└── Usuario deniega
│
├── Primera denegación → puede volver a solicitar
└── "No volver a preguntar" → redirigir a Ajustes del sistemaRedirigir al usuario a los Ajustes si deniega permanentemente#
1@Composable
2fun MensajePermisoDenegadoPermanentemente(permiso: String) {
3 val context = LocalContext.current
4
5 Column(
6 horizontalAlignment = Alignment.CenterHorizontally,
7 modifier = Modifier.padding(24.dp)
8 ) {
9 Icon(Icons.Default.Block, contentDescription = null,
10 modifier = Modifier.size(64.dp),
11 tint = MaterialTheme.colorScheme.error)
12 Spacer(Modifier.height(16.dp))
13 Text(
14 "El permiso ha sido denegado permanentemente. " +
15 "Ve a Ajustes → Aplicaciones → AppFlix → Permisos para activarlo.",
16 textAlign = TextAlign.Center
17 )
18 Spacer(Modifier.height(24.dp))
19 Button(onClick = {
20 // Abrir la pantalla de permisos de la app en los ajustes del sistema
21 val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
22 data = Uri.fromParts("package", context.packageName, null)
23 }
24 context.startActivity(intent)
25 }) {
26 Text("Ir a Ajustes")
27 }
28 }
29}2. FileProvider: compartir archivos entre apps#
Cuando una app necesita compartir un archivo (foto tomada con CameraX, documento generado) con otra app (WhatsApp, email, Google Drive), no puede pasar una ruta de archivo directa (file:///) desde Android 7.0 (API 24). En su lugar, debe usar un FileProvider que genera una URI de contenido segura (content://).
Configuración de FileProvider#
1. Declarar en AndroidManifest.xml:
1<application ...>
2 <provider
3 android:name="androidx.core.content.FileProvider"
4 android:authorities="${applicationId}.fileprovider"
5 android:exported="false"
6 android:grantUriPermissions="true">
7 <meta-data
8 android:name="android.support.FILE_PROVIDER_PATHS"
9 android:resource="@xml/file_provider_paths" />
10 </provider>
11</application>2. Crear res/xml/file_provider_paths.xml:
1<?xml version="1.0" encoding="utf-8"?>
2<paths>
3 <!-- Directorio privado de la app: /data/data/<package>/files/ -->
4 <files-path name="archivos_internos" path="." />
5
6 <!-- Caché interna: /data/data/<package>/cache/ -->
7 <cache-path name="cache_interno" path="." />
8
9 <!-- Almacenamiento externo privado: /sdcard/Android/data/<package>/files/ -->
10 <external-files-path name="archivos_externos" path="." />
11
12 <!-- Solo para imágenes tomadas con CameraX guardadas en caché temporal -->
13 <external-cache-path name="cache_externo" path="." />
14</paths>3. Obtener la URI segura en el código:
1// Crear un archivo temporal para la foto antes de capturarla
2fun crearArchivoTemporal(context: Context): File {
3 val directorio = context.cacheDir // directorio de caché interno
4 return File.createTempFile(
5 "APPFLIX_${System.currentTimeMillis()}", // prefijo
6 ".jpg", // sufijo
7 directorio
8 )
9}
10
11// Convertir el File en una URI segura para compartir
12fun obtenerUriSegura(context: Context, archivo: File): Uri {
13 return FileProvider.getUriForFile(
14 context,
15 "${context.packageName}.fileprovider", // authority del Manifest
16 archivo
17 )
18}
19
20// Compartir una imagen con otras apps (WhatsApp, Drive, etc.)
21fun compartirImagen(context: Context, uri: Uri, titulo: String) {
22 val intent = Intent(Intent.ACTION_SEND).apply {
23 type = "image/jpeg"
24 putExtra(Intent.EXTRA_STREAM, uri)
25 putExtra(Intent.EXTRA_SUBJECT, titulo)
26 // Conceder permiso temporal de lectura a la app receptora
27 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
28 }
29 context.startActivity(Intent.createChooser(intent, "Compartir imagen con..."))
30}3. Guardar fotos con CameraX usando FileProvider (alternativa a MediaStore)#
Para dispositivos con API < 29 que no soportan MediaStore.Images.Media.RELATIVE_PATH, o cuando se quiere guardar en el directorio privado de la app:
1// Alternativa a MediaStore para API < 29 o directorio privado
2fun tomarFotaConFileProvider(
3 imageCapture: ImageCapture,
4 context: Context,
5 onFotoCapturada: (Uri) -> Unit,
6 onError: () -> Unit
7) {
8 // Crear el archivo en el directorio de imágenes externo privado
9 val directorioFotos = File(
10 context.getExternalFilesDir(Environment.DIRECTORY_PICTURES),
11 "AppFlix"
12 ).also { if (!it.exists()) it.mkdirs() }
13
14 val archivoFoto = File(
15 directorioFotos,
16 "APPFLIX_${System.currentTimeMillis()}.jpg"
17 )
18
19 val outputOptions = ImageCapture.OutputFileOptions.Builder(archivoFoto).build()
20
21 imageCapture.takePicture(
22 outputOptions,
23 ContextCompat.getMainExecutor(context),
24 object : ImageCapture.OnImageSavedCallback {
25 override fun onImageSaved(output: ImageCapture.OutputFileResults) {
26 // Convertir el File a una URI segura con FileProvider
27 val uri = FileProvider.getUriForFile(
28 context,
29 "${context.packageName}.fileprovider",
30 archivoFoto
31 )
32 onFotoCapturada(uri)
33 }
34 override fun onError(exception: ImageCaptureException) { onError() }
35 }
36 )
37}4. Sensor de orientación: construir una brújula#
El sensor TYPE_ORIENTATION está obsoleto desde API 8. La forma moderna de obtener la orientación es combinar TYPE_ACCELEROMETER y TYPE_MAGNETIC_FIELD usando SensorManager.getRotationMatrix() y SensorManager.getOrientation():
1// data/datasource/local/BrujulaDataSource.kt
2class BrujulaDataSource(private val sensorManager: SensorManager) {
3
4 // Devuelve el azimut en grados (0-360, donde 0/360 = Norte)
5 fun observarAzimut(): Flow<Float> = callbackFlow {
6 val acelerometro = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
7 val magnetometro = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
8
9 if (acelerometro == null || magnetometro == null) {
10 close(Exception("Sensores de orientación no disponibles"))
11 return@callbackFlow
12 }
13
14 var ultimaGravedad: FloatArray? = null
15 var ultimoCampoMagnetico: FloatArray? = null
16
17 val listener = object : SensorEventListener {
18 override fun onSensorChanged(event: SensorEvent) {
19 when (event.sensor.type) {
20 Sensor.TYPE_ACCELEROMETER -> ultimaGravedad = event.values.clone()
21 Sensor.TYPE_MAGNETIC_FIELD -> ultimoCampoMagnetico = event.values.clone()
22 }
23
24 val gravedad = ultimaGravedad ?: return
25 val magnetico = ultimoCampoMagnetico ?: return
26
27 val matrizRotacion = FloatArray(9)
28 val matrizInclinacion = FloatArray(9)
29
30 if (SensorManager.getRotationMatrix(
31 matrizRotacion, matrizInclinacion, gravedad, magnetico
32 )) {
33 val orientacion = FloatArray(3)
34 SensorManager.getOrientation(matrizRotacion, orientacion)
35
36 // orientacion[0] = azimut en radianes (-π a π)
37 // Convertir a grados (0-360)
38 val azimutGrados = Math.toDegrees(orientacion[0].toDouble()).toFloat()
39 val azimutNormalizado = (azimutGrados + 360) % 360
40 trySend(azimutNormalizado)
41 }
42 }
43
44 override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
45 }
46
47 sensorManager.registerListener(listener, acelerometro, SensorManager.SENSOR_DELAY_UI)
48 sensorManager.registerListener(listener, magnetometro, SensorManager.SENSOR_DELAY_UI)
49
50 awaitClose {
51 sensorManager.unregisterListener(listener)
52 }
53 }
54}
55
56// Composable de brújula con Canvas
57@Composable
58fun IndicadorBrujula(azimut: Float, modifier: Modifier = Modifier) {
59 // Suavizar el giro con animación
60 val azimutAnimado by animateFloatAsState(
61 targetValue = azimut,
62 animationSpec = tween(durationMillis = 200),
63 label = "azimut"
64 )
65
66 val colorAguja = MaterialTheme.colorScheme.error
67 val colorFondo = MaterialTheme.colorScheme.surfaceVariant
68
69 Canvas(modifier = modifier.size(200.dp)) {
70 val centro = Offset(size.width / 2f, size.height / 2f)
71 val radio = size.minDimension / 2f - 16.dp.toPx()
72
73 // Círculo de fondo
74 drawCircle(color = colorFondo, radius = radio, center = centro)
75
76 // Aguja roja (Norte) — rotada según el azimut
77 rotate(degrees = -azimutAnimado, pivot = centro) {
78 // Punta norte (roja)
79 drawLine(
80 color = colorAguja,
81 start = centro,
82 end = Offset(centro.x, centro.y - radio * 0.8f),
83 strokeWidth = 6.dp.toPx(),
84 cap = StrokeCap.Round
85 )
86 // Punta sur (gris)
87 drawLine(
88 color = androidx.compose.ui.graphics.Color.Gray,
89 start = centro,
90 end = Offset(centro.x, centro.y + radio * 0.5f),
91 strokeWidth = 4.dp.toPx(),
92 cap = StrokeCap.Round
93 )
94 }
95
96 // Punto central
97 drawCircle(
98 color = androidx.compose.ui.graphics.Color.DarkGray,
99 radius = 8.dp.toPx(),
100 center = centro
101 )
102 }
103
104 // Dirección cardinal
105 val direccion = when {
106 azimut < 22.5 || azimut >= 337.5 -> "N"
107 azimut < 67.5 -> "NE"
108 azimut < 112.5 -> "E"
109 azimut < 157.5 -> "SE"
110 azimut < 202.5 -> "S"
111 azimut < 247.5 -> "SO"
112 azimut < 292.5 -> "O"
113 else -> "NO"
114 }
115 Text(
116 text = "${"%.0f".format(azimut)}° $direccion",
117 style = MaterialTheme.typography.labelLarge,
118 modifier = Modifier.padding(top = 8.dp)
119 )
120}5. WorkManager avanzado: cadenas de tareas y condiciones#
1// Cadena de tareas: comprimir imagen → subir a servidor → notificar
2val comprimir = OneTimeWorkRequestBuilder<ComprimirImagenWorker>()
3 .setInputData(workDataOf("uri_imagen" to uri.toString()))
4 .build()
5
6val subir = OneTimeWorkRequestBuilder<SubirImagenWorker>()
7 .setConstraints(Constraints.Builder()
8 .setRequiredNetworkType(NetworkType.UNMETERED) // solo WiFi
9 .build())
10 .build()
11
12val notificar = OneTimeWorkRequestBuilder<NotificarSubidaWorker>().build()
13
14// beginWith → then → then: ejecuta las tareas en secuencia
15// La salida de cada tarea (outputData) se pasa como entrada a la siguiente
16WorkManager.getInstance(context)
17 .beginWith(comprimir)
18 .then(subir)
19 .then(notificar)
20 .enqueue()
21
22// Tareas en paralelo con combine
23val descarga1 = OneTimeWorkRequestBuilder<DescargarRecursoWorker>()
24 .setInputData(workDataOf("url" to "https://..."))
25 .build()
26val descarga2 = OneTimeWorkRequestBuilder<DescargarRecursoWorker>()
27 .setInputData(workDataOf("url" to "https://..."))
28 .build()
29val combinar = OneTimeWorkRequestBuilder<CombinarResultadosWorker>().build()
30
31// beginWithAsync descarga ambas en paralelo, combinar espera a que terminen las dos
32WorkManager.getInstance(context)
33 .beginWith(listOf(descarga1, descarga2)) // paralelo
34 .then(combinar) // espera a ambas
35 .enqueue()6. Tabla de referencia rápida: versiones del Bloque 4#
1// Todas las dependencias del Bloque 4 para build.gradle.kts (módulo app)
2dependencies {
3 // ── CameraX ───────────────────────────────────────────────────────────────
4 val cameraxVersion = "1.4.2"
5 implementation("androidx.camera:camera-core:$cameraxVersion")
6 implementation("androidx.camera:camera-camera2:$cameraxVersion")
7 implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
8 implementation("androidx.camera:camera-view:$cameraxVersion")
9 implementation("androidx.camera:camera-extensions:$cameraxVersion") // opcional
10
11 // ── Media3 / ExoPlayer ────────────────────────────────────────────────────
12 val media3Version = "1.5.1"
13 implementation("androidx.media3:media3-exoplayer:$media3Version")
14 implementation("androidx.media3:media3-ui:$media3Version")
15 implementation("androidx.media3:media3-common:$media3Version")
16
17 // ── Localización ─────────────────────────────────────────────────────────
18 implementation("com.google.android.gms:play-services-location:21.3.0")
19
20 // ── WorkManager ───────────────────────────────────────────────────────────
21 implementation("androidx.work:work-runtime-ktx:2.10.1")
22
23 // ── Core KTX (compatibilidad, notificaciones) ─────────────────────────────
24 implementation("androidx.core:core-ktx:1.16.0")
25
26 // ── Coil (ya del B3, necesario para galería) ───────────────────────────────
27 implementation("io.coil-kt.coil3:coil-compose:3.2.0")
28 implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")
29}| Librería | Versión | Nota |
|---|---|---|
| CameraX (todos los artefactos) | 1.4.2 |
camera-core, camera-camera2, camera-lifecycle, camera-view |
| Media3/ExoPlayer (todos) | 1.5.1 |
media3-exoplayer, media3-ui, media3-common |
| Play Services Location | 21.3.0 |
FusedLocationProviderClient |
| WorkManager | 2.10.1 |
work-runtime-ktx |
| Core KTX | 1.16.0 |
NotificationCompat, FileProvider, ContextCompat |
7. Checklist de verificación: Bloque 4#
Multimedia (T7):
- Permiso
CAMERAdeclarado en Manifest - Permiso
READ_MEDIA_IMAGESconmaxSdkVersion="32"YREAD_EXTERNAL_STORAGEconmaxSdkVersion="32"para compatibilidad ProcessCameraProvider.unbindAll()antes debindToLifecycle()MediaPlayer.release()enViewModel.onCleared()MediaPlayer.prepareAsync()(noprepare()) para URLs remotasDisposableEffectpara gestionar el ciclo de vida de ExoPlayerPickVisualMediasin permisos de almacenamiento en API 33+FileProviderconfigurado si se comparten archivos con otras apps
Sensores y notificaciones (T8):
callbackFlowconawaitClose { sensorManager.unregisterListener(...) }SENSOR_DELAY_NORMALpara UI (noFASTEST— consume batería)- Permiso
ACCESS_FINE_LOCATIONsolicitado antes de llamar arequestLocationUpdates fusedClient.removeLocationUpdates(callback)enawaitCloseNotificationChannelcreado enApplication.onCreate()FLAG_IMMUTABLEen todos losPendingIntent(obligatorio API 31+)- Permiso
POST_NOTIFICATIONSsolicitado en tiempo de ejecución para API 33+ WorkManager.enqueueUniquePeriodicWorkconKEEPpara evitar duplicadosCoroutineWorker(noWorker) para trabajo asíncrono en WorkManager