Tema 8. Sensores, GPS y Notificaciones#

  • Bloque: B4 — Multimedia en Android
  • Duración aproximada: 6 horas
  • RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA3-a Se han utilizado las herramientas y entornos de desarrollo para aplicaciones multimedia.
RA3-e Se han gestionado los permisos de acceso al hardware multimedia del dispositivo.
RA3-f Se han utilizado los servicios de localización del dispositivo.
RA3-g Se han incorporado mecanismos de notificación y comunicación con el usuario.

Dependencias necesarias#

 1// build.gradle.kts (módulo app)
 2dependencies {
 3    // Localización con FusedLocationProviderClient
 4    // (Requiere Google Play Services — presente en la mayoría de dispositivos Android)
 5    implementation("com.google.android.gms:play-services-location:21.3.0")
 6
 7    // WorkManager — tareas en segundo plano y notificaciones programadas
 8    implementation("androidx.work:work-runtime-ktx:2.10.1")
 9
10    // Core KTX — NotificationCompat y utilidades de compatibilidad
11    // (Ya incluido transitivamente, pero conviene declararlo explícitamente)
12    implementation("androidx.core:core-ktx:1.16.0")
13}

Permisos en AndroidManifest.xml:

 1<!-- ─── Sensores ─────────────────────────────────────────────────────────── -->
 2<!-- La mayoría de sensores NO requieren permiso (acelerómetro, giroscopio...) -->
 3<!-- Solo estos sensores especiales sí requieren permiso: -->
 4<uses-permission android:name="android.permission.BODY_SENSORS" />           <!-- frecuencia cardiaca -->
 5<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />   <!-- contador de pasos (API 29+) -->
 6
 7<!-- ─── Localización ─────────────────────────────────────────────────────── -->
 8<!-- Solo solicitar la precisión que realmente necesita la app -->
 9<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- vecindario (~100-300m) -->
10<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />   <!-- GPS preciso (~10m) -->
11<!-- Solo si la app necesita localización en SEGUNDO PLANO (muy restrictivo) -->
12<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> -->
13
14<!-- ─── Notificaciones ───────────────────────────────────────────────────── -->
15<!-- Obligatorio en API 33+ para publicar notificaciones -->
16<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

1. Sensores del dispositivo#

Android proporciona acceso a una amplia variedad de sensores físicos a través del SensorManager. Los sensores se clasifican en tres categorías:

Categoría Sensores Ejemplos de uso
Movimiento Acelerómetro, giroscopio, gravedad, vectores de rotación Detectar agitación, orientación, giros
Posición Campo magnético, proximidad Brújula, apagar pantalla en llamada
Entorno Luz ambiental, presión, temperatura Ajuste automático de brillo, altímetro

Sensores más importantes#

Constante Tipo Datos devueltos
TYPE_ACCELEROMETER Movimiento Aceleración en X, Y, Z (m/s²) — incluye gravedad
TYPE_LINEAR_ACCELERATION Movimiento Aceleración lineal X, Y, Z (sin gravedad)
TYPE_GRAVITY Movimiento Vector de gravedad X, Y, Z
TYPE_GYROSCOPE Movimiento Velocidad angular X, Y, Z (rad/s)
TYPE_MAGNETIC_FIELD Posición Campo magnético X, Y, Z (µT)
TYPE_ROTATION_VECTOR Posición Orientación del dispositivo (cuaternión)
TYPE_PROXIMITY Posición Distancia al objeto más cercano (cm o binario)
TYPE_LIGHT Entorno Iluminancia ambiental (lux)
TYPE_STEP_COUNTER Movimiento Pasos desde el último reinicio del sistema

La mayoría de sensores no requieren permiso. Solo TYPE_STEP_COUNTER / ACTIVITY_RECOGNITION (API 29+) y los sensores biométricos necesitan permiso explícito.

Lectura de sensores con callbackFlow#

La API de sensores de Android usa callbacks (SensorEventListener). Para integrarlos con el patrón reactivo del Bloque 2 (StateFlow en ViewModel), se usa callbackFlow para convertir el callback en un Flow:

 1// data/datasource/local/SensorDataSource.kt
 2class SensorDataSource(private val sensorManager: SensorManager) {
 3
 4    // callbackFlow: crea un Flow que emite valores a través de un callback
 5    // El Flow se cancela cuando ya no hay colectores (lifecycleScope/viewModelScope)
 6    fun observarAcelerometro(): Flow<Triple<Float, Float, Float>> = callbackFlow {
 7
 8        // Obtener el sensor de acelerómetro — puede ser null si el dispositivo no lo tiene
 9        val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
10            ?: run {
11                close(Exception("Acelerómetro no disponible en este dispositivo"))
12                return@callbackFlow
13            }
14
15        val listener = object : SensorEventListener {
16            override fun onSensorChanged(event: SensorEvent) {
17                // event.values: array con las lecturas del sensor
18                // Acelerómetro: [0]=X, [1]=Y, [2]=Z en m/s²
19                // trySend: envía el valor al Flow sin bloquear
20                trySend(Triple(event.values[0], event.values[1], event.values[2]))
21            }
22
23            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
24                // SENSOR_STATUS_ACCURACY_HIGH/MEDIUM/LOW/UNRELIABLE
25                // Opcional: notificar la precisión al colector
26            }
27        }
28
29        // SENSOR_DELAY_NORMAL: ~200ms entre lecturas (suficiente para UI)
30        // SENSOR_DELAY_GAME:   ~20ms (para juegos, más batería)
31        // SENSOR_DELAY_FASTEST: tan rápido como el sensor permita
32        sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
33
34        // awaitClose: se ejecuta cuando el Flow se cancela o el scope se destruye
35        // CRÍTICO: des-registrar el listener para no agotar la batería
36        awaitClose {
37            sensorManager.unregisterListener(listener)
38        }
39    }
40
41    fun observarLuzAmbiental(): Flow<Float> = callbackFlow {
42        val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT)
43            ?: run { close(); return@callbackFlow }
44
45        val listener = object : SensorEventListener {
46            override fun onSensorChanged(event: SensorEvent) {
47                trySend(event.values[0])   // lux
48            }
49            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
50        }
51
52        sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
53        awaitClose { sensorManager.unregisterListener(listener) }
54    }
55
56    fun observarProximidad(): Flow<Boolean> = callbackFlow {
57        val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
58            ?: run { close(); return@callbackFlow }
59
60        val listener = object : SensorEventListener {
61            override fun onSensorChanged(event: SensorEvent) {
62                // Muchos sensores de proximidad son binarios: 0.0 = cerca, > 0 = lejos
63                val cercano = event.values[0] < (sensor.maximumRange / 2f)
64                trySend(cercano)
65            }
66            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
67        }
68
69        sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
70        awaitClose { sensorManager.unregisterListener(listener) }
71    }
72}

SensorViewModel: consumir los sensores en la UI#

  1// ui/sensores/SensorViewModel.kt
  2class SensorViewModel(
  3    application: Application
  4) : AndroidViewModel(application) {
  5
  6    private val sensorManager = application.getSystemService(Context.SENSOR_SERVICE)
  7        as SensorManager
  8
  9    private val sensorDataSource = SensorDataSource(sensorManager)
 10
 11    // Aceleración lineal (m/s²) en los tres ejes
 12    val aceleracion: StateFlow<Triple<Float, Float, Float>> =
 13        sensorDataSource.observarAcelerometro()
 14            .stateIn(
 15                scope = viewModelScope,
 16                started = SharingStarted.WhileSubscribed(5_000),
 17                initialValue = Triple(0f, 0f, 0f)
 18            )
 19
 20    // Luz ambiental en lux
 21    val luzAmbiental: StateFlow<Float> =
 22        sensorDataSource.observarLuzAmbiental()
 23            .stateIn(
 24                scope = viewModelScope,
 25                started = SharingStarted.WhileSubscribed(5_000),
 26                initialValue = 0f
 27            )
 28
 29    // Detector de agitación basado en el acelerómetro
 30    // Si la aceleración supera el umbral en cualquier eje, se considera "agitado"
 31    val estaAgitando: StateFlow<Boolean> =
 32        sensorDataSource.observarAcelerometro()
 33            .map { (x, y, z) ->
 34                val magnitud = kotlin.math.sqrt(
 35                    (x * x + y * y + z * z).toDouble()
 36                ).toFloat()
 37                // 9.8 m/s² es la gravedad terrestre — si el total supera 15, está agitando
 38                magnitud > 15f
 39            }
 40            .distinctUntilChanged()   // solo emite cuando cambia el estado (evita spam)
 41            .stateIn(
 42                scope = viewModelScope,
 43                started = SharingStarted.WhileSubscribed(5_000),
 44                initialValue = false
 45            )
 46
 47    companion object {
 48        val Factory: ViewModelProvider.Factory = viewModelFactory {
 49            initializer {
 50                val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
 51                    as Application
 52                SensorViewModel(app)
 53            }
 54        }
 55    }
 56}
 57
 58// Composable que muestra los datos del sensor
 59@Composable
 60fun PantallaSensores(
 61    viewModel: SensorViewModel = viewModel(factory = SensorViewModel.Factory)
 62) {
 63    val aceleracion by viewModel.aceleracion.collectAsStateWithLifecycle()
 64    val luzAmbiental by viewModel.luzAmbiental.collectAsStateWithLifecycle()
 65    val estaAgitando by viewModel.estaAgitando.collectAsStateWithLifecycle()
 66
 67    val (x, y, z) = aceleracion
 68
 69    Column(
 70        modifier = Modifier.fillMaxSize().padding(24.dp),
 71        verticalArrangement = Arrangement.spacedBy(16.dp)
 72    ) {
 73        Text("Sensores del dispositivo", style = MaterialTheme.typography.headlineSmall)
 74
 75        Card(modifier = Modifier.fillMaxWidth()) {
 76            Column(modifier = Modifier.padding(16.dp)) {
 77                Text("Acelerómetro", style = MaterialTheme.typography.titleMedium)
 78                Spacer(Modifier.height(8.dp))
 79                Text("X: ${"%.2f".format(x)} m/s²")
 80                Text("Y: ${"%.2f".format(y)} m/s²")
 81                Text("Z: ${"%.2f".format(z)} m/s²")
 82
 83                Spacer(Modifier.height(8.dp))
 84                // Indicador visual de agitación
 85                AnimatedVisibility(visible = estaAgitando) {
 86                    Text("¡Dispositivo agitado!",
 87                        color = MaterialTheme.colorScheme.error,
 88                        style = MaterialTheme.typography.labelLarge)
 89                }
 90            }
 91        }
 92
 93        Card(modifier = Modifier.fillMaxWidth()) {
 94            Column(modifier = Modifier.padding(16.dp)) {
 95                Text("Luz ambiental", style = MaterialTheme.typography.titleMedium)
 96                Spacer(Modifier.height(8.dp))
 97                Text("${"%.1f".format(luzAmbiental)} lux")
 98                // Clasificación cualitativa de la iluminación
 99                val descripcion = when {
100                    luzAmbiental < 10   -> "Oscuridad (noche)"
101                    luzAmbiental < 100  -> "Interior con poca luz"
102                    luzAmbiental < 1000 -> "Interior bien iluminado"
103                    luzAmbiental < 10000 -> "Exterior nublado"
104                    else                -> "Luz solar directa"
105                }
106                Text(descripcion, color = MaterialTheme.colorScheme.onSurfaceVariant,
107                    style = MaterialTheme.typography.bodySmall)
108            }
109        }
110    }
111}

2. GPS y Localización#

La localización del dispositivo se obtiene a través del FusedLocationProviderClient de Google Play Services. Este cliente combina GPS, WiFi, Bluetooth y redes móviles para proporcionar la localización más precisa posible con el menor consumo de batería.

FusedLocationProviderClient vs LocationManager: LocationManager es la API nativa de Android (sin Google Play Services). FusedLocationProviderClient es el enfoque recomendado por Google en casi todos los dispositivos Android con Play Services. LocationManager se usa cuando se trabaja con dispositivos sin Play Services (emuladores sin Google, algunos dispositivos chinos, Android TV).

Permisos de localización#

ACCESS_COARSE_LOCATION    → Aproximación por red (~100-300 metros)
ACCESS_FINE_LOCATION      → GPS preciso (~10 metros, requiere COARSE también)
ACCESS_BACKGROUND_LOCATION → Solo cuando la app está en segundo plano (muy restringido)

ACCESS_BACKGROUND_LOCATION requiere justificación especial ante Google Play y debe solicitarse después de que ACCESS_FINE_LOCATION ya ha sido concedido.

LocationDataSource con Flow#

 1// data/datasource/local/LocationDataSource.kt
 2class LocationDataSource(private val context: Context) {
 3
 4    // FusedLocationProviderClient: cliente de Google Play Services para localización
 5    private val fusedClient = LocationServices.getFusedLocationProviderClient(context)
 6
 7    // Obtener la última localización conocida (puede ser null si nunca se ha obtenido)
 8    // Es rápido pero puede estar desactualizada
 9    suspend fun obtenerUltimaLocalizacion(): Location? {
10        return try {
11            fusedClient.lastLocation.await()   // await() de play-services-tasks-ktx
12        } catch (e: SecurityException) {
13            null   // permiso no concedido
14        }
15    }
16
17    // Recibir actualizaciones de localización continuas como Flow
18    @SuppressLint("MissingPermission")   // el permiso se verifica antes de llamar a esta función
19    fun observarLocalizacion(): Flow<Location> = callbackFlow {
20
21        val solicitud = LocationRequest.Builder(
22            Priority.PRIORITY_HIGH_ACCURACY,   // GPS de alta precisión
23            5_000L                              // intervalo deseado: 5 segundos
24        )
25        .setMinUpdateIntervalMillis(2_000L)     // intervalo mínimo: 2 segundos
26        .build()
27
28        val callback = object : LocationCallback() {
29            override fun onLocationResult(resultado: LocationResult) {
30                resultado.lastLocation?.let { location ->
31                    trySend(location)
32                }
33            }
34        }
35
36        fusedClient.requestLocationUpdates(
37            solicitud,
38            callback,
39            Looper.getMainLooper()
40        ).addOnFailureListener { e ->
41            close(e)   // si falla la solicitud, cerrar el Flow con error
42        }
43
44        awaitClose {
45            // CRÍTICO: eliminar las actualizaciones al cancelar el Flow
46            fusedClient.removeLocationUpdates(callback)
47        }
48    }
49}

LocationViewModel#

 1// ui/mapa/LocationViewModel.kt
 2class LocationViewModel(
 3    application: Application
 4) : AndroidViewModel(application) {
 5
 6    private val locationDataSource = LocationDataSource(application)
 7
 8    data class LocalizacionUiState(
 9        val latitud: Double = 0.0,
10        val longitud: Double = 0.0,
11        val precision: Float = 0f,    // metros
12        val tieneDatos: Boolean = false,
13        val error: String? = null
14    )
15
16    private val _uiState = MutableStateFlow(LocalizacionUiState())
17    val uiState: StateFlow<LocalizacionUiState> = _uiState.asStateFlow()
18
19    private val _tienePermiso = MutableStateFlow(false)
20    val tienePermiso: StateFlow<Boolean> = _tienePermiso.asStateFlow()
21
22    fun actualizarEstadoPermiso(concedido: Boolean) {
23        _tienePermiso.value = concedido
24        if (concedido) iniciarActualizaciones()
25    }
26
27    private var jobLocalizacion: Job? = null
28
29    private fun iniciarActualizaciones() {
30        jobLocalizacion?.cancel()
31        jobLocalizacion = viewModelScope.launch {
32            try {
33                locationDataSource.observarLocalizacion()
34                    .collect { location ->
35                        _uiState.update {
36                            it.copy(
37                                latitud = location.latitude,
38                                longitud = location.longitude,
39                                precision = location.accuracy,
40                                tieneDatos = true,
41                                error = null
42                            )
43                        }
44                    }
45            } catch (e: Exception) {
46                _uiState.update { it.copy(error = e.message) }
47            }
48        }
49    }
50
51    fun detenerActualizaciones() {
52        jobLocalizacion?.cancel()
53        jobLocalizacion = null
54    }
55
56    override fun onCleared() {
57        super.onCleared()
58        detenerActualizaciones()
59    }
60
61    companion object {
62        val Factory: ViewModelProvider.Factory = viewModelFactory {
63            initializer {
64                LocationViewModel(
65                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
66                )
67            }
68        }
69    }
70}

PantallaLocalizacion con gestión de permisos#

  1@Composable
  2fun PantallaLocalizacion(
  3    viewModel: LocationViewModel = viewModel(factory = LocationViewModel.Factory)
  4) {
  5    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
  6    val tienePermiso by viewModel.tienePermiso.collectAsStateWithLifecycle()
  7
  8    // Launcher de permiso de localización
  9    val launcherPermiso = rememberLauncherForActivityResult(
 10        ActivityResultContracts.RequestMultiplePermissions()
 11    ) { resultados ->
 12        val concedido = resultados[Manifest.permission.ACCESS_FINE_LOCATION] == true
 13                     || resultados[Manifest.permission.ACCESS_COARSE_LOCATION] == true
 14        viewModel.actualizarEstadoPermiso(concedido)
 15    }
 16
 17    // Comprobar el permiso al entrar en la pantalla
 18    val context = LocalContext.current
 19    LaunchedEffect(Unit) {
 20        val tienePermisoActual = ContextCompat.checkSelfPermission(
 21            context, Manifest.permission.ACCESS_FINE_LOCATION
 22        ) == PackageManager.PERMISSION_GRANTED
 23        if (tienePermisoActual) {
 24            viewModel.actualizarEstadoPermiso(true)
 25        } else {
 26            launcherPermiso.launch(arrayOf(
 27                Manifest.permission.ACCESS_FINE_LOCATION,
 28                Manifest.permission.ACCESS_COARSE_LOCATION
 29            ))
 30        }
 31    }
 32
 33    // Detener las actualizaciones cuando se sale de la pantalla
 34    DisposableEffect(Unit) {
 35        onDispose { viewModel.detenerActualizaciones() }
 36    }
 37
 38    Scaffold(topBar = { TopAppBar(title = { Text("Mi localización") }) }) { padding ->
 39        Column(
 40            modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
 41            verticalArrangement = Arrangement.spacedBy(16.dp)
 42        ) {
 43            when {
 44                !tienePermiso -> {
 45                    Text("Se necesita permiso de localización para mostrar tu posición.")
 46                    Button(onClick = {
 47                        launcherPermiso.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
 48                    }) { Text("Conceder permiso") }
 49                }
 50
 51                !uiState.tieneDatos -> {
 52                    Row(verticalAlignment = Alignment.CenterVertically) {
 53                        CircularProgressIndicator(modifier = Modifier.size(24.dp))
 54                        Spacer(Modifier.width(12.dp))
 55                        Text("Obteniendo localización...")
 56                    }
 57                }
 58
 59                else -> {
 60                    Card(modifier = Modifier.fillMaxWidth()) {
 61                        Column(modifier = Modifier.padding(16.dp)) {
 62                            Text("Posición actual", style = MaterialTheme.typography.titleMedium)
 63                            Spacer(Modifier.height(12.dp))
 64                            Text("Latitud:   ${"%.6f".format(uiState.latitud)}")
 65                            Text("Longitud:  ${"%.6f".format(uiState.longitud)}")
 66                            Text("Precisión: ${"%.0f".format(uiState.precision)} metros",
 67                                color = MaterialTheme.colorScheme.onSurfaceVariant,
 68                                style = MaterialTheme.typography.bodySmall)
 69                        }
 70                    }
 71
 72                    // Enlace a Google Maps con la posición actual
 73                    val uriMaps = Uri.parse(
 74                        "geo:${uiState.latitud},${uiState.longitud}?" +
 75                        "q=${uiState.latitud},${uiState.longitud}(Mi%20posición)"
 76                    )
 77                    val intentMaps = Intent(Intent.ACTION_VIEW, uriMaps).apply {
 78                        setPackage("com.google.android.apps.maps")
 79                    }
 80                    val puedeAbrirMaps = LocalContext.current.packageManager
 81                        .resolveActivity(intentMaps, 0) != null
 82
 83                    if (puedeAbrirMaps) {
 84                        val ctx = LocalContext.current
 85                        Button(onClick = { ctx.startActivity(intentMaps) }) {
 86                            Icon(Icons.Default.Map, contentDescription = null)
 87                            Spacer(Modifier.width(8.dp))
 88                            Text("Ver en Google Maps")
 89                        }
 90                    }
 91
 92                    uiState.error?.let { mensajeError ->
 93                        Text("Error: $mensajeError",
 94                            color = MaterialTheme.colorScheme.error,
 95                            style = MaterialTheme.typography.bodySmall)
 96                    }
 97                }
 98            }
 99        }
100    }
101}

3. Notificaciones locales#

Las notificaciones locales se generan directamente desde la app, sin necesidad de un servidor. Son útiles para recordatorios, alertas de progreso de tareas y comunicaciones con el usuario cuando la app está en segundo plano.

Componentes de una notificación#

┌─────────────────────────────────────────────────────┐
│  [Icono] Título de la notificación                  │
│           Texto descriptivo de la notificación      │
│                             [Acción 1][Acción 2]    │
└─────────────────────────────────────────────────────┘
Elemento Descripción
Canal (NotificationChannel) Agrupa notificaciones por categoría (obligatorio en API 26+)
Builder (NotificationCompat.Builder) Construye el contenido de la notificación
PendingIntent Acción que ocurre al pulsar la notificación (abrir pantalla)
NotificationManager Publica y gestiona las notificaciones

Gestor de notificaciones reutilizable#

  1// data/notifications/NotificationManager.kt
  2class AppNotificationManager(private val context: Context) {
  3
  4    companion object {
  5        // IDs de canales — deben ser únicos en la app
  6        const val CANAL_RECORDATORIOS = "canal_recordatorios"
  7        const val CANAL_DESCARGAS     = "canal_descargas"
  8
  9        // IDs de notificaciones — usados para actualizar o cancelar
 10        const val ID_RECORDATORIO     = 1001
 11        const val ID_DESCARGA         = 1002
 12    }
 13
 14    // Crear los canales al iniciar la app — idempotente (se puede llamar varias veces)
 15    fun crearCanales() {
 16        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
 17            val notificationManager = context.getSystemService(
 18                Context.NOTIFICATION_SERVICE
 19            ) as android.app.NotificationManager
 20
 21            // Canal para recordatorios — importancia alta (muestra heads-up)
 22            val canalRecordatorios = NotificationChannel(
 23                CANAL_RECORDATORIOS,
 24                "Recordatorios de AppFlix",
 25                NotificationManager.IMPORTANCE_DEFAULT
 26            ).apply {
 27                description = "Notificaciones de recordatorio de películas pendientes"
 28                enableVibration(true)
 29                setShowBadge(true)   // mostrar badge en el ícono de la app
 30            }
 31
 32            // Canal para descargas — importancia baja (sin sonido, solo en barra)
 33            val canalDescargas = NotificationChannel(
 34                CANAL_DESCARGAS,
 35                "Descargas",
 36                NotificationManager.IMPORTANCE_LOW
 37            ).apply {
 38                description = "Progreso de descargas de contenido"
 39            }
 40
 41            notificationManager.createNotificationChannels(
 42                listOf(canalRecordatorios, canalDescargas)
 43            )
 44        }
 45    }
 46
 47    // Notificación simple con PendingIntent para abrir la app
 48    fun mostrarRecordatorio(titulo: String, mensaje: String, peliculaId: Int = -1) {
 49        // Verificar permiso en API 33+ antes de llamar a esta función
 50        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
 51            ContextCompat.checkSelfPermission(
 52                context, Manifest.permission.POST_NOTIFICATIONS
 53            ) != PackageManager.PERMISSION_GRANTED
 54        ) return
 55
 56        // Intent que abre la app (o una pantalla específica) al pulsar la notificación
 57        val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
 58            ?.apply {
 59                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
 60                if (peliculaId > 0) putExtra("peliculaId", peliculaId)
 61            }
 62
 63        // PendingIntent: envuelve el Intent con permisos para que el sistema lo ejecute
 64        // FLAG_IMMUTABLE: el contenido no puede modificarse (obligatorio en API 31+)
 65        val pendingIntent = PendingIntent.getActivity(
 66            context,
 67            peliculaId,    // requestCode — único por notificación
 68            intent,
 69            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
 70        )
 71
 72        val notificacion = NotificationCompat.Builder(context, CANAL_RECORDATORIOS)
 73            .setSmallIcon(R.drawable.ic_notification)         // ícono en la barra de estado
 74            .setContentTitle(titulo)
 75            .setContentText(mensaje)
 76            .setStyle(NotificationCompat.BigTextStyle().bigText(mensaje))  // texto expandido
 77            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
 78            .setContentIntent(pendingIntent)
 79            .setAutoCancel(true)     // desaparecer al pulsar
 80            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
 81            .build()
 82
 83        NotificationManagerCompat.from(context).notify(ID_RECORDATORIO, notificacion)
 84    }
 85
 86    // Notificación con acciones (botones)
 87    fun mostrarNotificacionConAcciones(
 88        titulo: String,
 89        mensaje: String,
 90        peliculaId: Int
 91    ) {
 92        // Intent para la acción "Ver película"
 93        val intentVer = Intent(context, MainActivity::class.java).apply {
 94            putExtra("peliculaId", peliculaId)
 95            flags = Intent.FLAG_ACTIVITY_NEW_TASK
 96        }
 97        val pendingIntentVer = PendingIntent.getActivity(
 98            context, peliculaId * 10,
 99            intentVer,
100            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
101        )
102
103        // Intent para la acción "Marcar visto" (BroadcastReceiver — ver nota)
104        val intentMarcar = Intent("com.ejemplo.appflix.MARCAR_VISTO").apply {
105            putExtra("peliculaId", peliculaId)
106        }
107        val pendingIntentMarcar = PendingIntent.getBroadcast(
108            context, peliculaId * 10 + 1,
109            intentMarcar,
110            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
111        )
112
113        val notificacion = NotificationCompat.Builder(context, CANAL_RECORDATORIOS)
114            .setSmallIcon(R.drawable.ic_notification)
115            .setContentTitle(titulo)
116            .setContentText(mensaje)
117            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
118            .setAutoCancel(true)
119            // Acciones: aparecen como botones al expandir la notificación
120            .addAction(
121                R.drawable.ic_play,
122                "Ver película",
123                pendingIntentVer
124            )
125            .addAction(
126                R.drawable.ic_check,
127                "Marcar como vista",
128                pendingIntentMarcar
129            )
130            .build()
131
132        NotificationManagerCompat.from(context).notify(peliculaId, notificacion)
133    }
134
135    // Notificación de progreso (para descargas o sincronizaciones)
136    fun mostrarProgreso(titulo: String, progreso: Int, indeterminado: Boolean = false) {
137        val notificacion = NotificationCompat.Builder(context, CANAL_DESCARGAS)
138            .setSmallIcon(R.drawable.ic_download)
139            .setContentTitle(titulo)
140            .setContentText(if (indeterminado) "Procesando..." else "$progreso%")
141            // setProgress(max, progreso, indeterminado)
142            .setProgress(100, progreso, indeterminado)
143            .setOngoing(true)       // no se puede descartar deslizando
144            .setOnlyAlertOnce(true) // solo el primer sonido de alerta
145            .build()
146
147        NotificationManagerCompat.from(context).notify(ID_DESCARGA, notificacion)
148    }
149
150    fun cancelarNotificacion(id: Int) {
151        NotificationManagerCompat.from(context).cancel(id)
152    }
153}

Solicitar permiso de notificaciones en API 33+#

 1@Composable
 2fun PantallaAjusteNotificaciones() {
 3    val context = LocalContext.current
 4    var tienePermiso by remember {
 5        mutableStateOf(
 6            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
 7                ContextCompat.checkSelfPermission(
 8                    context, Manifest.permission.POST_NOTIFICATIONS
 9                ) == PackageManager.PERMISSION_GRANTED
10            } else {
11                true   // en API < 33 no se necesita permiso
12            }
13        )
14    }
15
16    val launcher = rememberLauncherForActivityResult(
17        ActivityResultContracts.RequestPermission()
18    ) { concedido ->
19        tienePermiso = concedido
20    }
21
22    Column(modifier = Modifier.padding(16.dp)) {
23        if (tienePermiso) {
24            Text("Las notificaciones están activadas.", color = MaterialTheme.colorScheme.primary)
25            Button(onClick = {
26                // Probar la notificación
27                val manager = AppNotificationManager(context)
28                manager.mostrarRecordatorio(
29                    titulo = "¡No te olvides!",
30                    mensaje = "Tienes películas pendientes en tu lista de AppFlix."
31                )
32            }) {
33                Text("Enviar notificación de prueba")
34            }
35        } else {
36            Text("Las notificaciones están desactivadas.")
37            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
38                Button(onClick = {
39                    launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
40                }) {
41                    Text("Activar notificaciones")
42                }
43            }
44        }
45    }
46}

4. WorkManager: tareas programadas y notificaciones en segundo plano#

WorkManager es la solución recomendada para ejecutar tareas en segundo plano que deben completarse aunque el usuario cierre la app o el dispositivo se reinicie. Es ideal para sincronizaciones periódicas, envío de analíticas y notificaciones programadas.

Cuándo usar WorkManager#

Escenario Herramienta
Sincronización periódica con la API WorkManager ✅
Tarea que debe ejecutarse aunque la app esté cerrada WorkManager ✅
Notificación programada (recordatorio) WorkManager ✅
Tarea que ocurre mientras la app está abierta viewModelScope.launch
Tarea de larga duración en segundo plano inmediata Foreground Service

Worker: la unidad de trabajo#

 1// data/workers/SincronizacionWorker.kt
 2class SincronizacionWorker(
 3    context: Context,
 4    params: WorkerParameters
 5) : CoroutineWorker(context, params) {
 6
 7    // doWork() se ejecuta en un hilo de background automáticamente
 8    // Debe retornar Result.success(), Result.failure() o Result.retry()
 9    override suspend fun doWork(): Result {
10        return try {
11            // Acceder al AppContainer a través del Application
12            val app = applicationContext as AppFlixApplication
13            app.container.peliculasRepository.sincronizar()
14
15            // Mostrar notificación al completar la sincronización
16            val notifManager = AppNotificationManager(applicationContext)
17            notifManager.mostrarRecordatorio(
18                titulo = "AppFlix actualizado",
19                mensaje = "El catálogo se ha sincronizado correctamente."
20            )
21
22            Result.success()
23        } catch (e: Exception) {
24            // Si falla, reintentar hasta 3 veces (por defecto en WorkManager)
25            if (runAttemptCount < 3) Result.retry()
26            else Result.failure()
27        }
28    }
29}
30
31// Worker para un recordatorio programado
32class RecordatorioWorker(
33    context: Context,
34    params: WorkerParameters
35) : CoroutineWorker(context, params) {
36
37    override suspend fun doWork(): Result {
38        // Leer los datos pasados al crear el Work Request
39        val tituloPelicula = inputData.getString("titulo_pelicula") ?: "una película"
40        val peliculaId = inputData.getInt("pelicula_id", -1)
41
42        val notifManager = AppNotificationManager(applicationContext)
43        notifManager.mostrarRecordatorio(
44            titulo = "Recordatorio de AppFlix",
45            mensaje = "No olvides ver: $tituloPelicula",
46            peliculaId = peliculaId
47        )
48
49        return Result.success()
50    }
51}

Programar trabajo con WorkManager#

 1// Función para programar la sincronización periódica — llamar desde Application.onCreate()
 2fun programarSincronizacionPeriodica(context: Context) {
 3    // PeriodicWorkRequest: se ejecuta cada 24 horas
 4    val solicitudSync = PeriodicWorkRequestBuilder<SincronizacionWorker>(
 5        repeatInterval = 24,
 6        repeatIntervalTimeUnit = TimeUnit.HOURS
 7    )
 8    .setConstraints(
 9        Constraints.Builder()
10            .setRequiredNetworkType(NetworkType.CONNECTED)   // solo con red
11            .setRequiresBatteryNotLow(true)                 // no con batería baja
12            .build()
13    )
14    .build()
15
16    // enqueueUniquePeriodicWork: garantiza que solo hay una tarea de sincronización activa
17    // KEEP: si ya existe, no hacer nada; UPDATE: reemplazar con la nueva configuración
18    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
19        "sync_appflix",
20        ExistingPeriodicWorkPolicy.KEEP,
21        solicitudSync
22    )
23}
24
25// Programar un recordatorio puntual (OneTimeWorkRequest)
26fun programarRecordatorio(context: Context, tituloPelicula: String, peliculaId: Int, delayMinutos: Long) {
27    val datos = workDataOf(
28        "titulo_pelicula" to tituloPelicula,
29        "pelicula_id" to peliculaId
30    )
31
32    val solicitudRecordatorio = OneTimeWorkRequestBuilder<RecordatorioWorker>()
33        .setInputData(datos)
34        .setInitialDelay(delayMinutos, TimeUnit.MINUTES)
35        .addTag("recordatorio_$peliculaId")   // tag para poder cancelarlo después
36        .build()
37
38    WorkManager.getInstance(context).enqueue(solicitudRecordatorio)
39}
40
41// Cancelar un recordatorio específico por tag
42fun cancelarRecordatorio(context: Context, peliculaId: Int) {
43    WorkManager.getInstance(context).cancelAllWorkByTag("recordatorio_$peliculaId")
44}

Observar el estado de WorkManager en el ViewModel#

 1// Observar el estado de la tarea de sincronización en la UI
 2class AjustesViewModel(application: Application) : AndroidViewModel(application) {
 3
 4    // WorkManager proporciona LiveData/Flow del estado de cada tarea
 5    val estadoSincronizacion: StateFlow<WorkInfo.State?> =
 6        WorkManager.getInstance(application)
 7            .getWorkInfosForUniqueWorkFlow("sync_appflix")
 8            .map { listaWorkInfo ->
 9                listaWorkInfo.firstOrNull()?.state
10            }
11            .stateIn(
12                scope = viewModelScope,
13                started = SharingStarted.WhileSubscribed(5_000),
14                initialValue = null
15            )
16}
17
18// En el Composable
19@Composable
20fun EstadoSincronizacion(viewModel: AjustesViewModel) {
21    val estado by viewModel.estadoSincronizacion.collectAsStateWithLifecycle()
22
23    val (icono, texto) = when (estado) {
24        WorkInfo.State.RUNNING   -> Icons.Default.Sync to "Sincronizando..."
25        WorkInfo.State.SUCCEEDED -> Icons.Default.CheckCircle to "Sincronizado"
26        WorkInfo.State.FAILED    -> Icons.Default.Error to "Error en sincronización"
27        WorkInfo.State.ENQUEUED  -> Icons.Default.Schedule to "En espera"
28        else                     -> Icons.Default.CloudOff to "Sin sincronización programada"
29    }
30
31    Row(
32        verticalAlignment = Alignment.CenterVertically,
33        horizontalArrangement = Arrangement.spacedBy(8.dp)
34    ) {
35        Icon(icono, contentDescription = null)
36        Text(texto)
37    }
38}

5. Inicializar canales y WorkManager en Application#

Tanto los canales de notificación como la sincronización periódica deben configurarse al iniciar la app. El lugar correcto es Application.onCreate():

 1// AppFlixApplication.kt — actualización con notificaciones y WorkManager
 2class AppFlixApplication : Application(), SingletonImageLoader.Factory {
 3
 4    lateinit var container: AppContainer
 5
 6    override fun onCreate() {
 7        super.onCreate()
 8        container = DefaultAppContainer(this)
 9
10        // Crear los canales de notificación (idempotente — se puede llamar varias veces)
11        AppNotificationManager(this).crearCanales()
12
13        // Programar la sincronización periódica del catálogo
14        programarSincronizacionPeriodica(this)
15    }
16
17    override fun newImageLoader(context: PlatformContext): ImageLoader {
18        return ImageLoader.Builder(context)
19            .components {
20                add(OkHttpNetworkFetcherFactory(
21                    callFactory = { container.okHttpClient }
22                ))
23            }
24            .crossfade(true)
25            .build()
26    }
27}

Diagrama de flujo: sensores, localización y notificaciones en AppFlix#

                    AppFlixApplication.onCreate()
                ┌────────┴────────┐
          crearCanales()    programarSync()
                │                 │
      NotificationChannel    PeriodicWorkRequest
      (creado una vez)       (cada 24h con red)
                          SincronizacionWorker.doWork()
                    repository.sincronizar() + mostrarRecordatorio()


    Pantalla Sensores                     Pantalla Localización
         │                                        │
  SensorViewModel                        LocationViewModel
  callbackFlow(SensorEventListener)      callbackFlow(LocationCallback)
         │                                        │
  Acelerómetro → detectar agitación     GPS → latitud/longitud
  Luz → clasificar iluminación          → intent a Google Maps

Estructura de paquetes al final de T8#

com.ejemplo.appflix/
├── data/
│   ├── datasource/local/
│   │   ├── SensorDataSource.kt          ← callbackFlow sobre SensorEventListener
│   │   └── LocationDataSource.kt        ← callbackFlow sobre LocationCallback
│   ├── notifications/
│   │   └── AppNotificationManager.kt    ← canales, builder, PendingIntent
│   └── workers/
│       ├── SincronizacionWorker.kt      ← CoroutineWorker periódico
│       └── RecordatorioWorker.kt        ← CoroutineWorker puntual
└── ui/
    ├── sensores/
    │   ├── PantallaSensores.kt
    │   └── SensorViewModel.kt
    ├── localizacion/
    │   ├── PantallaLocalizacion.kt
    │   └── LocationViewModel.kt
    └── ajustes/
        ├── PantallaAjustes.kt
        └── AjustesViewModel.kt

Actividades prácticas#

Actividad 8.1 — Dashboard de sensores (4 h) Crear una pantalla en AppFlix que muestre en tiempo real los datos del acelerómetro, del sensor de luz y de la proximidad. Implementar el detector de agitación y mostrar una notificación local cuando el dispositivo se agita más de 3 veces seguidas. Verificar con callbackFlow que el sensor se des-registra al salir de la pantalla.

Actividad 8.2 — Localización y mapa (4 h) Añadir una pantalla de localización que muestre las coordenadas GPS actuales y abra Google Maps con la posición. Gestionar correctamente el ciclo de vida (detener actualizaciones al salir, reanudar al volver). Demostrar en clase que el permiso se solicita correctamente y que si se deniega se muestra una explicación adecuada.

Actividad 8.3 — Hito vertebrador H8 (4 h) Integrar WorkManager en AppFlix: sincronización automática del catálogo cada 24 horas con condición de red disponible, y posibilidad de programar un recordatorio para una película concreta (“Recuérdame ver esta película en X minutos”). Mostrar el estado de la última sincronización en la pantalla de ajustes.


Pruebas de evaluación#

Prueba T8.1 — Análisis de sensores y permisos (20 min) Se proporciona una lista de 8 funcionalidades de apps (contador de pasos, brújula, detector de caída, ajuste de brillo automático, “agitar para barajar”, GPS, frecuencia cardíaca, notificaciones). El alumno debe indicar qué sensor usaría, si necesita permiso y qué delay de muestreo usaría (NORMAL, GAME, FASTEST).

Prueba T8.2 — Diseño de sistema de notificaciones (20 min) Enunciado: “App de tareas pendientes: el usuario puede crear tareas con fecha límite y quiere recibir una notificación 30 minutos antes. Las notificaciones deben tener botones ‘Completar’ y ‘Posponer 10 min’.” El alumno diseña en papel el NotificationChannel, el Worker, el PendingIntent de cada acción y el flujo completo.

Prueba T8.3 — Defensa oral (5 min por alumno) El profesor pregunta: "¿Por qué se usa callbackFlow y no flow { } para los sensores?", "¿Qué pasaría si no llamas a awaitClose { sensorManager.unregisterListener(...) }?", "¿Cuándo usarías WorkManager en lugar de viewModelScope.launch?"


Referencias#

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