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 sistema

Redirigir 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 CAMERA declarado en Manifest
  • Permiso READ_MEDIA_IMAGES con maxSdkVersion="32" Y READ_EXTERNAL_STORAGE con maxSdkVersion="32" para compatibilidad
  • ProcessCameraProvider.unbindAll() antes de bindToLifecycle()
  • MediaPlayer.release() en ViewModel.onCleared()
  • MediaPlayer.prepareAsync() (no prepare()) para URLs remotas
  • DisposableEffect para gestionar el ciclo de vida de ExoPlayer
  • PickVisualMedia sin permisos de almacenamiento en API 33+
  • FileProvider configurado si se comparten archivos con otras apps

Sensores y notificaciones (T8):

  • callbackFlow con awaitClose { sensorManager.unregisterListener(...) }
  • SENSOR_DELAY_NORMAL para UI (no FASTEST — consume batería)
  • Permiso ACCESS_FINE_LOCATION solicitado antes de llamar a requestLocationUpdates
  • fusedClient.removeLocationUpdates(callback) en awaitClose
  • NotificationChannel creado en Application.onCreate()
  • FLAG_IMMUTABLE en todos los PendingIntent (obligatorio API 31+)
  • Permiso POST_NOTIFICATIONS solicitado en tiempo de ejecución para API 33+
  • WorkManager.enqueueUniquePeriodicWork con KEEP para evitar duplicados
  • CoroutineWorker (no Worker) para trabajo asíncrono en WorkManager

Referencias del Anexo#

Calendar  Última modificación: viernes, 26 de junio de 2026