Tema 8. Sensores, GPS y Notificaciones

  • Bloque: B4 — Multimedia en Android
  • Duración aproximada: 6 horas
  • Aplicación de referencia: Excursia — proyecto nuevo e independiente que se construye íntegramente en este tema.
  • RA2 — Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
  • RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA2-d Se han desarrollado aplicaciones que hacen uso de las funcionalidades proporcionadas por los sensores.
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.

0. La aplicación del tema: Excursia#

Hasta ahora el hilo conductor de los apuntes ha sido AppDummy. En este tema se abandona ese proyecto y se crea uno nuevo desde cero, porque el hardware del dispositivo (sensores y GPS) y el sistema de notificaciones se entienden mucho mejor en una app pequeña donde todo lo que hay dentro pertenece a este tema.

La app Excursia es un cuaderno de campo digital: acompaña al usuario durante una salida al monte y le informa de lo que “siente” el teléfono — cómo se mueve, cuánta luz hay, dónde está — y le avisa mediante notificaciones aunque la app no esté en primer plano.

Este tema da comienzo al desarrollo práctico guiado del tema 8 del módulo PMDM. Construirás la primera versión de Excursia, la app que acompaña al usuario durante una salida al monte, siguiendo los desarrollos guiados de la documentación.
Sigue los siguientes requisitos para la creación del proyecto:\

  1. Crea un proyecto nuevo en Android Studio con la plantilla Empty Activity (Kotlin + Jetpack Compose).
  2. Utiliza como paquete edu.apellido1Nombre.excursia
  3. Inicializa un repositorio Git desde el primer momento (VCS ▸ Enable Version Control Integration) y haz commits frecuentes a medida que avances.

Pantallas de Excursia#

Pantalla Contenido Bloque técnico
Entorno Acelerómetro en los tres ejes, detección de agitación, luz ambiental (lux) con interpretación cualitativa y sensor de proximidad. SensorManager
Ruta Posición GPS en tiempo real (latitud, longitud, precisión, altitud) y salto a la app de mapas del dispositivo. FusedLocationProviderClient
Avisos Permiso de notificaciones, aviso de prueba, recordatorio puntual (“avísame en X minutos”) y recordatorios periódicos de hidratación mientras la ruta está activa. NotificationCompat + WorkManager

Arquitectura#

Excursia mantiene exactamente el mismo flujo de datos que el resto de los apuntes, sin capa de dominio y sin framework de inyección de dependencias (nada de Hilt ni Koin: el contenedor se escribe a mano, como en los temas anteriores):

UI (Composables)  ⇄  ViewModel  ⇄  Repository  ⇄  DataSource  ⇄  Framework Android
                                                                 (SensorManager,
                                                                  FusedLocationProviderClient,
                                                                  NotificationManager,
                                                                  WorkManager)

Tres ideas que conviene fijar antes de escribir una sola línea:

  1. El framework nunca sube de capa. El DataSource es el único que conoce SensorEvent, Location o WorkInfo. Traduce esos tipos a modelos propios de la app (Vector3, Posicion), que son los que viajan hacia arriba. Así el ViewModel no depende de Google Play Services y puede probarse sin dispositivo.
  2. La regla de negocio vive en el repositorio. Como no hay capa de dominio, decisiones del tipo "¿a partir de qué aceleración consideramos que el móvil se está agitando?" se implementan en el Repository, no en el ViewModel ni en el Composable.
  3. Se mantiene la separación DTO ↔ modelo introducido en el Tema 5. Al trabajar con Retrofit2 y la API de Open Library se distinguieron dos clases muy distintas: LibroDto, que refleja literalmente el JSON que devuelve el servidor (con @SerializedName, campos anulables y nombres en inglés), y Libro, el modelo de la aplicación —que además, es la entidad de Room— con el que trabajan repositorio, ViewModel y UI. Entre ambas, la función de mapeo LibroDto.toLibro(), aplicada dentro del RemoteDataSource.

En el Tema 8 la fuente externa no es una API ni una base de datos, sino el propio framework de Android: SensorEvent y Location desempeñan exactamente el papel que allí tenía LibroDto. La diferencia es que no hay que escribirlos, porque los proporciona el sistema; el resto del patrón es idéntico, incluida la regla de oro: el DTO no sale del DataSource.

Elemento Tema 5 (Retrofit2 + Room) Tema 8 (sensores y GPS)
Dato externo (DTO) LibroDto, BusquedaResponseDto en data/datasource/remote/dto/ SensorEvent y Location, que aporta el propio Android
Función de mapeo LibroDto.toLibro(), List<LibroDto>.toLibros() (LibroMapper.kt) Location.toPosicion() y la lambda transformar de SensorDataSource
Quién aplica el mapeo RemoteDataSource LocationDataSource, SensorDataSource
Modelo de la app Libro en data/model/ (anotado @Entity para Room) Posicion, Vector3 en data/model/

Recuérda la asimetría del Tema 5, que aquí se respeta: el DTO es exclusivo de la fuente remota, mientras que el modelo se reutiliza para Room. Por eso Libro lleva las anotaciones @Entity y @ColumnInfo y LibroDto no: no se crea una tercera clase para la base de datos. Excursia no persiste nada todavía, pero Posicion está preparada para seguir el mismo camino.

Creación del proyecto#

En Android Studio: New Project → Empty Activity (Compose).

Parámetro Valor
Name Excursia
Package name com.ejemplo.excursia
Minimum SDK API 26 (Android 8.0) — los canales de notificación existen desde API 26
Build configuration language Kotlin DSL (build.gradle.kts)

Aunque POST_NOTIFICATIONS es de API 33 y ACTIVITY_RECOGNITION de API 29, fijar minSdk = 26 permite trabajar con canales sin comprobaciones de versión. Todas las comprobaciones Build.VERSION.SDK_INT >= ... que aparecen en el tema son para APIs posteriores a la mínima.


1. Dependencias y permisos#

Dependencias#

1[versions]
2// ...
3coreKtx = "1.19.0"
4kotlin = "2.4.10"
5composeBom = "2026.08.00"
1// ─── build.gradle.kts (Project: Excursia) ────────────────────────────────────────────────────────
2
3plugins {
4    alias(libs.plugins.android.application) apply false
5    alias(libs.plugins.kotlin.compose) apply false
6    id("org.jetbrains.kotlin.plugin.serialization") version libs.versions.kotlin apply false
7}
 1// ─── build.gradle.kts (módulo :app) ──────────────────────────────────────────────────────────────
 2
 3plugins {
 4    alias(libs.plugins.android.application)
 5    alias(libs.plugins.kotlin.compose)
 6    id("org.jetbrains.kotlin.plugin.serialization")
 7}
 8
 9// ...
10
11dependencies {
12    // ...
13
14    // Material Design icons
15    implementation("androidx.compose.material:material-icons-extended")
16
17    // ─── Ciclo de vida y ViewModel ───────────────────────────────────────────
18    // lifecycle-runtime-compose aporta collectAsStateWithLifecycle()
19    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
20    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
21
22    // ─── Navegación entre las tres pantallas ─────────────────────────────────
23    implementation("androidx.navigation:navigation-compose:2.9.8")
24
25    // ─── Localización: FusedLocationProviderClient ───────────────────────────
26    // Requiere Google Play Services (presente en la mayoría de dispositivos y
27    // en los emuladores con imagen "Google APIs" / "Google Play")
28    implementation("com.google.android.gms:play-services-location:21.4.0")
29
30    // Convierte las Task<T> de Play Services en funciones suspend: .await()
31    // Debe usar la misma versión de kotlinx.coroutines que el resto del proyecto
32    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0")
33
34    // ─── Tareas en segundo plano y notificaciones programadas ────────────────
35    // OJO: desde WorkManager 2.9 el artefacto work-runtime-ktx está VACÍO;
36    // CoroutineWorker y las extensiones Kotlin viven en work-runtime
37    implementation("androidx.work:work-runtime:2.11.2")
38}

Versiones comprobadas en agosto de 2026. Dos artefactos han cambiado de nombre respecto a lo que se ve en muchos tutoriales antiguos: work-runtime-ktx (vacío desde 2.9.0) y core-ktx (vacío desde 1.19.0). Seguir declarándolos no rompe la compilación, pero es código heredado: usa work-runtime y core.

Permisos en AndroidManifest.xml#

 1<!-- ─── Sensores ─────────────────────────────────────────────────────────── -->
 2<!-- La mayoría de sensores NO requieren permiso: acelerómetro, giroscopio,
 3     luz, proximidad, campo magnético... Excursia, tal y como se construye en
 4     este tema, no necesita ningún permiso de sensor. -->
 5
 6<!-- Solo para el contador de pasos (API 29+), necesario en el reto final -->
 7<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
 8
 9<!-- ─── Localización ─────────────────────────────────────────────────────── -->
10<!-- Solicitar únicamente la precisión que la app realmente necesita -->
11<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- red: ~100-300 m -->
12<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />   <!-- GPS: ~10 m -->
13
14<!-- Solo si la app necesitara localización en SEGUNDO PLANO (muy restrictivo:
15     exige justificación ante Google Play y solicitarse DESPUÉS de FINE) -->
16<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> -->
17
18<!-- ─── Notificaciones ───────────────────────────────────────────────────── -->
19<!-- Obligatorio a partir de API 33 para poder publicar notificaciones -->
20<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

BODY_SENSORS ha quedado obsoleto. Para apps con targetSdk 36 (Android 16) o superior, las APIs que antes exigían BODY_SENSORS o BODY_SENSORS_BACKGROUND requieren ahora los permisos granulares de android.permissions.health — por ejemplo READ_HEART_RATE en lugar de BODY_SENSORS, y READ_HEALTH_DATA_IN_BACKGROUND en lugar de BODY_SENSORS_BACKGROUND. Son los mismos permisos que utiliza Health Connect. Excursia no accede a sensores corporales, pero es un cambio que hay que conocer.

Además, hay que declarar la clase Application propia (se implementa en el apartado 6) y el receptor de las acciones de notificación (apartado 4):

 1<application
 2    android:name=".ExcursiaApplication"
 3    android:icon="@mipmap/ic_launcher"
 4    android:label="@string/app_name"
 5    android:theme="@style/Theme.Excursia">
 6
 7    <activity
 8        android:name=".MainActivity"
 9        android:exported="true">
10        <intent-filter>
11            <action android:name="android.intent.action.MAIN" />
12            <category android:name="android.intent.category.LAUNCHER" />
13        </intent-filter>
14    </activity>
15
16    <!-- Recibe las pulsaciones de los botones de las notificaciones -->
17    <receiver
18        android:name=".data.notificaciones.AccionesNotificacionReceiver"
19        android:exported="false" />
20</application>

2. Sensores del dispositivo#

Android da acceso a los sensores físicos a través del servicio SensorManager. Los sensores se agrupan en tres familias:

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

Sensores más relevantes#

Constante Categoría Datos devueltos en event.values
TYPE_ACCELEROMETER Movimiento Aceleración X, Y, Z (m/s²) — incluye la gravedad
TYPE_LINEAR_ACCELERATION Movimiento Aceleración 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, a menudo binario)
TYPE_LIGHT Entorno Iluminancia ambiental (lux)
TYPE_STEP_COUNTER Movimiento Pasos desde el último reinicio del sistema

Ningún sensor de los que usa Excursia requiere permiso. Solo lo necesitan el contador de pasos (ACTIVITY_RECOGNITION, API 29+) y los sensores corporales (permisos de android.permissions.health desde Android 16).

Un sensor puede no existir en el dispositivo. getDefaultSensor() devuelve null y la app debe reaccionar mostrando un mensaje, nunca lanzando una excepción. En el emulador se simulan desde Extended Controls → Virtual sensors.

2.1 Modelo propio: Vector3#

Antes del DataSource, se creará el modelo que se utilizará por las capas superiores:

 1package com.ejemplo.excursia.data.model
 2
 3import kotlin.math.sqrt
 4
 5// ─── data/model/Vector3.kt ───────────────────────────────────────────────────────────────────────
 6
 7/**
 8 * Lectura de un sensor de tres ejes (acelerómetro, giroscopio, campo magnético).
 9 *
10 * Es el MODELO de la app, el equivalente de Libro en el Tema 5. El papel de DTO
11 * lo desempeña aquí SensorEvent, la clase del framework: igual que un LibroDto
12 * no salía del RemoteDataSource, un SensorEvent no llega nunca al ViewModel.
13 */
14data class Vector3(
15    val x: Float = 0f,
16    val y: Float = 0f,
17    val z: Float = 0f
18) {
19    /** Módulo del vector. Con el acelerómetro en reposo vale +- 9,81 m/s² (gravedad). */
20    val magnitud: Float
21        get() = sqrt(x * x + y * y + z * z)
22}

2.2 SensorDataSource: de callback a Flow#

La API de sensores es basada en callbacks (SensorEventListener), mientras que el resto de la app es reactiva (Flow / StateFlow). El puente entre ambos mundos es callbackFlow, un constructor de Flow que permite emitir valores desde un callback y, sobre todo, liberar el recurso cuando ya nadie escucha gracias a awaitClose.

 1package com.ejemplo.excursia.data.datasource.local
 2
 3import android.hardware.Sensor
 4import android.hardware.SensorEvent
 5import android.hardware.SensorEventListener
 6import android.hardware.SensorManager
 7import com.ejemplo.excursia.data.model.Vector3
 8import kotlinx.coroutines.channels.awaitClose
 9import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.flow.callbackFlow
11
12// ─── data/datasource/local/SensorDataSource.kt ───────────────────────────────────────────────────
13
14/**
15 * Única clase de Excursia que conoce el framework de sensores de Android.
16 * Convierte los callbacks de SensorManager en Flows de modelos propios.
17 */
18class SensorDataSource(private val sensorManager: SensorManager) {
19
20    /** Permite a la UI avisar de que el dispositivo carece de un sensor concreto. */
21    fun existeSensor(tipo: Int): Boolean = sensorManager.getDefaultSensor(tipo) != null
22
23    /**
24     * Flujo genérico sobre cualquier sensor.
25     * fun <T> permite mapear el SensorEvent (dato crudo del framework) a cualquier modelo propio.
26     * @param tipo constante Sensor.TYPE_*
27     * @param frecuencia SENSOR_DELAY_NORMAL (~200 ms), _GAME (~20 ms), _UI, _FASTEST
28     * @param transformar mapeo DTO → modelo: convierte el SensorEvent (dato crudo
29     *        del framework) en el modelo propio que se emitirá hacia arriba
30     *        
31     * @return Flow<T> que emite los valores del sensor, o se cierra con SensorNoDisponibleException
32     *         si el dispositivo no tiene ese sensor. Se utiliza <T> para poder mapear a cualquier
33     *         modelo propio.
34     */
35    private fun <T> observarSensor(
36        tipo: Int,
37        frecuencia: Int = SensorManager.SENSOR_DELAY_NORMAL,
38        transformar: (SensorEvent, Sensor) -> T
39    ): Flow<T> = callbackFlow {
40
41        // Si el dispositivo no tiene el sensor, el Flow se cierra sin emitir nada
42        val sensor = sensorManager.getDefaultSensor(tipo)
43        if (sensor == null) {
44            close(SensorNoDisponibleException(tipo))
45            return@callbackFlow
46        }
47
48        val listener = object : SensorEventListener {
49            override fun onSensorChanged(event: SensorEvent) {
50                // trySend: envía el valor al Flow sin suspender (estamos en un callback)
51                // Si el consumidor va lento, el valor se descarta: para sensores es
52                // el comportamiento deseado (interesa el dato más reciente).
53                trySend(transformar(event, sensor))
54            }
55
56            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
57                // SENSOR_STATUS_ACCURACY_HIGH / MEDIUM / LOW / UNRELIABLE
58                // Excursia no lo utiliza, pero una brújula debería avisar al usuario
59                // de que recalibre el móvil cuando la precisión sea UNRELIABLE.
60            }
61        }
62
63        sensorManager.registerListener(listener, sensor, frecuencia)
64
65        // awaitClose SIEMPRE al final: se ejecuta cuando el Flow se cancela porque
66        // el ViewModel muere o la pantalla deja de recolectar.
67        // Si se omite, el listener sigue activo y el sensor consume batería
68        // indefinidamente: es el error más frecuente de este tema.
69        awaitClose { sensorManager.unregisterListener(listener) }
70    }
71
72    /** Acelerómetro: X, Y, Z en m/s², incluyendo la gravedad. */
73    fun observarAcelerometro(): Flow<Vector3> =
74        observarSensor(Sensor.TYPE_ACCELEROMETER) { event, _ ->
75            Vector3(event.values[0], event.values[1], event.values[2])
76        }
77
78    /** Luz ambiental en lux. */
79    fun observarLuzAmbiental(): Flow<Float> =
80        observarSensor(Sensor.TYPE_LIGHT) { event, _ -> event.values[0] }
81
82    /**
83     * Proximidad: muchos sensores son binarios (0 = cerca, maximumRange = lejos),
84     * por eso se normaliza a Boolean en lugar de exponer los centímetros.
85     */
86    fun observarProximidad(): Flow<Boolean> =
87        observarSensor(Sensor.TYPE_PROXIMITY) { event, sensor ->
88            event.values[0] < sensor.maximumRange / 2f
89        }
90}
91
92/** Excepción propia para distinguir "sensor ausente" de cualquier otro fallo. */
93class SensorNoDisponibleException(tipo: Int) :
94    Exception("El dispositivo no dispone del sensor de tipo $tipo")

¿Por qué callbackFlow y no flow { }? Dentro de un flow { } solo puede emitirse con emit() desde la propia corrutina, y onSensorChanged() se ejecuta en otro hilo (el del sensor). callbackFlow crea internamente un canal que sí admite emisiones externas (trySend) y ofrece awaitClose para desmontar el callback. Es el patrón oficial para envolver cualquier API basada en listeners.

2.3 EntornoRepository: la regla de negocio#

El repositorio decide qué significa cada dato. Aquí se define el umbral de agitación, se filtran las emisiones repetidas y se traduce la iluminancia a una categoría comprensible.

 1package com.ejemplo.excursia.data.repository
 2
 3import android.hardware.Sensor
 4import com.ejemplo.excursia.data.datasource.local.SensorDataSource
 5import com.ejemplo.excursia.data.model.Vector3
 6import kotlinx.coroutines.flow.Flow
 7import kotlinx.coroutines.flow.distinctUntilChanged
 8import kotlinx.coroutines.flow.map
 9
10// ─── data/repository/EntornoRepository.kt ────────────────────────────────────────────────────────
11
12/** Categorías de iluminación que entiende la UI (no son datos crudos del sensor). */
13enum class NivelLuz(val descripcion: String) {
14    OSCURIDAD("Oscuridad — necesitarás frontal"),
15    PENUMBRA("Penumbra — atardeciendo o bajo cubierta"),
16    INTERIOR("Interior bien iluminado"),
17    EXTERIOR_NUBLADO("Exterior nublado"),
18    SOL_DIRECTO("Luz solar directa")
19}
20
21/**
22 * Interfaz del repositorio: el ViewModel depende de ella, no de la implementación.
23 * Facilita sustituirla por una versión falsa en pruebas.
24 */
25interface EntornoRepository {
26    val aceleracion: Flow<Vector3>
27    val luzAmbiental: Flow<Float>
28    val nivelLuz: Flow<NivelLuz>
29    val agitacion: Flow<Boolean>
30    val proximidad: Flow<Boolean>
31    fun hayAcelerometro(): Boolean
32    fun haySensorLuz(): Boolean
33    fun haySensorProximidad(): Boolean
34}
35
36class EntornoRepositoryImpl(
37    private val sensorDataSource: SensorDataSource
38) : EntornoRepository {
39
40    companion object {
41        /**
42         * Umbral de agitación en m/s². En reposo la magnitud es ≈ 9,81 (gravedad),
43         * así que 15 m/s² supone una sacudida clara e intencionada.
44         */
45        private const val UMBRAL_AGITACION = 15f
46    }
47
48    override val aceleracion: Flow<Vector3> = sensorDataSource.observarAcelerometro()
49
50    override val luzAmbiental: Flow<Float> = sensorDataSource.observarLuzAmbiental()
51
52    // Regla de negocio: clasificar la iluminancia. distinctUntilChanged evita
53    // recomponer la UI mientras el nivel siga siendo el mismo.
54    override val nivelLuz: Flow<NivelLuz> = luzAmbiental
55        .map { lux ->
56            when {
57                lux < 10f     -> NivelLuz.OSCURIDAD
58                lux < 100f    -> NivelLuz.PENUMBRA
59                lux < 1_000f  -> NivelLuz.INTERIOR
60                lux < 10_000f -> NivelLuz.EXTERIOR_NUBLADO
61                else          -> NivelLuz.SOL_DIRECTO
62            }
63        }
64        .distinctUntilChanged()
65
66    // Regla de negocio: qué se considera "agitar el dispositivo".
67    override val agitacion: Flow<Boolean> = aceleracion
68        .map { it.magnitud > UMBRAL_AGITACION }
69        .distinctUntilChanged()   // emite solo en los cambios false→true y true→false
70
71    override val proximidad: Flow<Boolean> = sensorDataSource.observarProximidad()
72
73    override fun hayAcelerometro() = sensorDataSource.existeSensor(Sensor.TYPE_ACCELEROMETER)
74    override fun haySensorLuz() = sensorDataSource.existeSensor(Sensor.TYPE_LIGHT)
75    override fun haySensorProximidad() = sensorDataSource.existeSensor(Sensor.TYPE_PROXIMITY)
76}

2.4 EntornoViewModel#

El ViewModel solo adapta los Flow del repositorio a StateFlow que la UI pueda observar con seguridad de ciclo de vida.

 1package com.ejemplo.excursia.ui.entorno
 2
 3import androidx.lifecycle.ViewModel
 4import androidx.lifecycle.ViewModelProvider
 5import androidx.lifecycle.viewModelScope
 6import androidx.lifecycle.viewmodel.initializer
 7import androidx.lifecycle.viewmodel.viewModelFactory
 8import com.ejemplo.excursia.ExcursiaApplication
 9import com.ejemplo.excursia.data.model.Vector3
10import com.ejemplo.excursia.data.repository.EntornoRepository
11import com.ejemplo.excursia.data.repository.NivelLuz
12import kotlinx.coroutines.flow.SharingStarted
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.catch
15import kotlinx.coroutines.flow.stateIn
16
17// ─── ui/entorno/EntornoViewModel.kt ──────────────────────────────────────────────────────────────
18
19class EntornoViewModel(
20    private val entornoRepository: EntornoRepository
21) : ViewModel() {
22
23    /**
24     * stateIn convierte un Flow frío en un StateFlow caliente con valor inicial.
25     * WhileSubscribed(5_000): mantiene la suscripción 5 s tras perder el último
26     * colector. Así una rotación de pantalla no des-registra y vuelve a registrar
27     * el sensor, pero salir de la pantalla sí lo libera.
28     */
29    val aceleracion: StateFlow<Vector3> = entornoRepository.aceleracion
30        .catch { emit(Vector3()) }   // si el sensor no existe, el Flow falla: se neutraliza
31        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Vector3())
32
33    val luzAmbiental: StateFlow<Float> = entornoRepository.luzAmbiental
34        .catch { emit(0f) }
35        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
36
37    val nivelLuz: StateFlow<NivelLuz> = entornoRepository.nivelLuz
38        .catch { emit(NivelLuz.INTERIOR) }
39        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NivelLuz.INTERIOR)
40
41    val agitacion: StateFlow<Boolean> = entornoRepository.agitacion
42        .catch { emit(false) }
43        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
44
45    val objetoCerca: StateFlow<Boolean> = entornoRepository.proximidad
46        .catch { emit(false) }
47        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
48
49    val hayAcelerometro: Boolean get() = entornoRepository.hayAcelerometro()
50    val haySensorLuz: Boolean get() = entornoRepository.haySensorLuz()
51    val haySensorProximidad: Boolean get() = entornoRepository.haySensorProximidad()
52
53    companion object {
54        /**
55         * Factoría manual (sin Hilt/Koin): recupera el AppContainer desde la
56         * Application y le pasa al ViewModel las dependencias que necesita.
57         */
58        val Factory: ViewModelProvider.Factory = viewModelFactory {
59            initializer {
60                val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
61                        as ExcursiaApplication
62                EntornoViewModel(app.container.entornoRepository)
63            }
64        }
65    }
66}

Nótese que EntornoViewModel extiende ViewModel y no AndroidViewModel: no necesita el Context porque quien lo necesita (el SensorManager) está encapsulado en el DataSource, construido una sola vez en el AppContainer. Es una consecuencia directa de respetar las capas.

2.5 PantallaEntorno#

  1package com.ejemplo.excursia.ui.entorno
  2
  3import androidx.compose.animation.AnimatedVisibility
  4import androidx.compose.foundation.layout.*
  5import androidx.compose.foundation.rememberScrollState
  6import androidx.compose.foundation.verticalScroll
  7import androidx.compose.material3.*
  8import androidx.compose.runtime.Composable
  9import androidx.compose.runtime.getValue
 10import androidx.compose.ui.Modifier
 11import androidx.compose.ui.unit.dp
 12import androidx.lifecycle.compose.collectAsStateWithLifecycle
 13import androidx.lifecycle.viewmodel.compose.viewModel
 14
 15// ─── ui/entorno/PantallaEntorno.kt ───────────────────────────────────────────────────────────────
 16
 17@Composable
 18fun PantallaEntorno(
 19    modifier: Modifier = Modifier,
 20    viewModel: EntornoViewModel = viewModel(factory = EntornoViewModel.Factory)
 21) {
 22    // collectAsStateWithLifecycle detiene la recolección cuando la pantalla pasa
 23    // a STOPPED: es lo que, combinado con WhileSubscribed, libera el sensor.
 24    val aceleracion by viewModel.aceleracion.collectAsStateWithLifecycle()
 25    val luz by viewModel.luzAmbiental.collectAsStateWithLifecycle()
 26    val nivelLuz by viewModel.nivelLuz.collectAsStateWithLifecycle()
 27    val agitacion by viewModel.agitacion.collectAsStateWithLifecycle()
 28    val objetoCerca by viewModel.objetoCerca.collectAsStateWithLifecycle()
 29
 30    Column(
 31        modifier = modifier
 32            .fillMaxSize()
 33            .verticalScroll(rememberScrollState())
 34            .padding(16.dp),
 35        verticalArrangement = Arrangement.spacedBy(12.dp)
 36    ) {
 37        Text("¿Qué siente el dispositivo?", style = MaterialTheme.typography.headlineSmall)
 38
 39        // ─── Acelerómetro ────────────────────────────────────────────────────
 40        TarjetaSensor(titulo = "Acelerómetro", disponible = viewModel.hayAcelerometro) {
 41            Text("X: %.2f m/s²".format(aceleracion.x))
 42            Text("Y: %.2f m/s²".format(aceleracion.y))
 43            Text("Z: %.2f m/s²".format(aceleracion.z))
 44            Text(
 45                "Magnitud: %.2f m/s²".format(aceleracion.magnitud),
 46                style = MaterialTheme.typography.bodySmall,
 47                color = MaterialTheme.colorScheme.onSurfaceVariant
 48            )
 49            AnimatedVisibility(visible = agitacion) {
 50                Text(
 51                    "¡Dispositivo agitado!",
 52                    color = MaterialTheme.colorScheme.error,
 53                    style = MaterialTheme.typography.labelLarge
 54                )
 55            }
 56        }
 57
 58        // ─── Luz ambiental ───────────────────────────────────────────────────
 59        TarjetaSensor(titulo = "Luz ambiental", disponible = viewModel.haySensorLuz) {
 60            Text("%.1f lux".format(luz), style = MaterialTheme.typography.titleLarge)
 61            Text(
 62                nivelLuz.descripcion,
 63                style = MaterialTheme.typography.bodySmall,
 64                color = MaterialTheme.colorScheme.onSurfaceVariant
 65            )
 66        }
 67
 68        // ─── Proximidad ──────────────────────────────────────────────────────
 69        TarjetaSensor(titulo = "Proximidad", disponible = viewModel.haySensorProximidad) {
 70            Text(if (objetoCerca) "Objeto cerca (móvil en el bolsillo)" else "Sin obstáculos")
 71        }
 72    }
 73}
 74
 75/**
 76 * Tarjeta reutilizable: si el sensor no existe en el dispositivo muestra un aviso
 77 * en lugar de datos vacíos. Nunca se asume que el hardware está presente.
 78 */
 79@Composable
 80private fun TarjetaSensor(
 81    titulo: String,
 82    disponible: Boolean,
 83    contenido: @Composable ColumnScope.() -> Unit
 84) {
 85    Card(modifier = Modifier.fillMaxWidth()) {
 86        Column(
 87            modifier = Modifier.padding(16.dp),
 88            verticalArrangement = Arrangement.spacedBy(4.dp)
 89        ) {
 90            Text(titulo, style = MaterialTheme.typography.titleMedium)
 91            Spacer(Modifier.height(4.dp))
 92            if (disponible) {
 93                contenido()
 94            } else {
 95                Text(
 96                    "Este dispositivo no dispone del sensor.",
 97                    style = MaterialTheme.typography.bodySmall,
 98                    color = MaterialTheme.colorScheme.error
 99                )
100            }
101        }
102    }
103}

3. GPS y localización#

La posición se obtiene con FusedLocationProviderClient de Google Play Services, que combina GPS, Wi-Fi, Bluetooth y red móvil para dar la mejor precisión posible con el menor consumo.

FusedLocationProviderClient frente a LocationManager. LocationManager es la API nativa de Android y funciona sin Play Services. FusedLocationProviderClient es la opción recomendada por Google en dispositivos con Play Services, porque fusiona proveedores y gestiona la batería. LocationManager sigue siendo la alternativa para dispositivos sin Play Services (algunos emuladores, Android TV, ciertos fabricantes).

Niveles de permiso#

ACCESS_COARSE_LOCATION      → aproximación por red (~100-300 m)
ACCESS_FINE_LOCATION        → GPS preciso (~10 m); implica solicitar también COARSE
ACCESS_BACKGROUND_LOCATION  → seguir localizando con la app en segundo plano

Desde Android 12 (API 31) el usuario puede conceder solo la precisión aproximada aunque la app pida FINE: hay que comprobar cuál de los dos permisos ha concedido realmente. ACCESS_BACKGROUND_LOCATION exige justificación ante Google Play y debe solicitarse después de que ACCESS_FINE_LOCATION ya esté concedido; Excursia no lo utiliza.

3.1 Modelo Posicion#

 1package com.ejemplo.excursia.data.model
 2
 3// ─── data/model/Posicion.kt ──────────────────────────────────────────────────────────────────────
 4
 5/**
 6 * Punto de la ruta. MODELO de la app: plano y deliberadamente independiente de
 7 * android.location.Location, que es quien hace de DTO en este tema.
 8 *
 9 * Al ser un modelo limpio se comporta igual que Libro en el Tema 5: para
10 * persistir la ruta bastaría con anotarlo con @Entity, y una hipotética API
11 * remota solo añadiría su PosicionDto con su toPosicion(), sin tocar
12 * repositorios ni ViewModels.
13 */
14data class Posicion(
15    val latitud: Double,
16    val longitud: Double,
17    val precisionMetros: Float,
18    val altitudMetros: Double,
19    val instante: Long
20)

¿Y si la ruta se guardara o se enviara? Se resolvería exactamente igual que en el Tema 5 con Libro y LibroDto. Este código no forma parte de Excursia —se muestra solo para fijar la correspondencia—, pero conviene tenerlo presente:

 1// data/model/Posicion.kt — el MODELO se reutiliza como entidad de Room
 2@Entity(tableName = "posiciones")
 3data class Posicion(
 4    @PrimaryKey(autoGenerate = true) val id: Long = 0,
 5    @ColumnInfo(name = "latitud") val latitud: Double,
 6    @ColumnInfo(name = "longitud") val longitud: Double,
 7    @ColumnInfo(name = "precision") val precisionMetros: Float,
 8    @ColumnInfo(name = "altitud") val altitudMetros: Double,
 9    @ColumnInfo(name = "instante") val instante: Long
10)
1// data/datasource/remote/dto/PosicionDto.kt — SOLO si además hubiera una API
2data class PosicionDto(
3    @SerializedName("lat") val lat: Double? = null,
4    @SerializedName("lon") val lon: Double? = null,
5    @SerializedName("acc") val accuracy: Float? = null,
6    @SerializedName("ele") val elevation: Double? = null,
7    @SerializedName("ts")  val timestamp: Long? = null
8)
 1// data/datasource/remote/dto/PosicionMapper.kt — mismo criterio que LibroMapper.kt
 2fun PosicionDto.toPosicion(): Posicion? {
 3    // El DTO llega con todos los campos anulables: si falta lo imprescindible,
 4    // se descarta el elemento en lugar de propagar datos inválidos.
 5    val latitud = lat ?: return null
 6    val longitud = lon ?: return null
 7    return Posicion(
 8        latitud = latitud,
 9        longitud = longitud,
10        precisionMetros = accuracy ?: 0f,
11        altitudMetros = elevation ?: 0.0,
12        instante = timestamp ?: 0L
13    )
14}
15
16fun List<PosicionDto>.toPosiciones(): List<Posicion> = mapNotNull { it.toPosicion() }

Los nombres del DTO (lat, acc, ts) son los que impone el servidor; los del modelo, los que resultan legibles dentro de la app. Esa es la razón de existir del DTO: que un cambio en la fuente externa no obligue a tocar repositorios, ViewModels ni UI. Para Room, en cambio, no se crea una clase aparte: se anota el propio modelo, igual que se hizo con Libro.

3.2 LocationDataSource#

 1package com.ejemplo.excursia.data.datasource.local
 2
 3import android.annotation.SuppressLint
 4import android.content.Context
 5import android.location.Location
 6import android.os.Looper
 7import com.ejemplo.excursia.data.model.Posicion
 8import com.google.android.gms.location.*
 9import kotlinx.coroutines.channels.awaitClose
10import kotlinx.coroutines.flow.Flow
11import kotlinx.coroutines.flow.callbackFlow
12import kotlinx.coroutines.tasks.await
13
14// ─── data/datasource/local/LocationDataSource.kt ─────────────────────────────────────────────────
15
16/**
17 * Única clase que conoce Google Play Services. Traduce Location → Posicion.
18 *
19 * @SuppressLint("MissingPermission"): el permiso se comprueba en la UI antes de
20 * invocar estas funciones. Es la excepción documentada, no una vía para ignorar
21 * la comprobación de permisos.
22 */
23class LocationDataSource(context: Context) {
24
25    private val fusedClient: FusedLocationProviderClient =
26        LocationServices.getFusedLocationProviderClient(context)
27
28    /**
29     * Mapeo DTO → modelo, con la misma nomenclatura que LibroDto.toLibro() del
30     * Tema 5. Location es el dato "tal y como lo entrega la fuente" y, como todo
31     * DTO, es privado: no debe salir de esta clase.
32     */
33    private fun Location.toPosicion() = Posicion(
34        latitud = latitude,
35        longitud = longitude,
36        precisionMetros = accuracy,
37        altitudMetros = altitude,
38        instante = time
39    )
40
41    /**
42     * Última posición conocida en la caché del sistema: es inmediata, pero puede
43     * estar desactualizada o ser null si el dispositivo aún no ha localizado.
44     * Útil para pintar algo mientras llega la primera lectura real.
45     */
46    @SuppressLint("MissingPermission")
47    suspend fun obtenerUltimaPosicion(): Posicion? = try {
48        // await() (kotlinx-coroutines-play-services) suspende hasta que la Task
49        // de Play Services termina, evitando anidar callbacks.
50        fusedClient.lastLocation.await()?.toPosicion()
51    } catch (e: SecurityException) {
52        null   // el permiso no estaba concedido
53    }
54
55    /**
56     * Actualizaciones continuas de posición como Flow.
57     * @param intervaloMs periodicidad deseada de las actualizaciones
58     */
59    @SuppressLint("MissingPermission")
60    fun observarPosicion(intervaloMs: Long = 5_000L): Flow<Posicion> = callbackFlow {
61
62        val solicitud = LocationRequest.Builder(
63            Priority.PRIORITY_HIGH_ACCURACY,   // usa GPS: máxima precisión, mayor gasto
64            intervaloMs                        // intervalo deseado
65        )
66            .setMinUpdateIntervalMillis(intervaloMs / 2)  // ritmo máximo aceptado
67            .setMinUpdateDistanceMeters(5f)               // ignorar cambios < 5 m
68            .setWaitForAccurateLocation(true)             // esperar a un primer fix bueno
69            .build()
70
71        val callback = object : LocationCallback() {
72            override fun onLocationResult(resultado: LocationResult) {
73                resultado.lastLocation?.let { trySend(it.toPosicion()) }
74            }
75        }
76
77        fusedClient.requestLocationUpdates(solicitud, callback, Looper.getMainLooper())
78            .addOnFailureListener { e -> close(e) }   // p. ej. GPS desactivado
79
80        // CRÍTICO: sin removeLocationUpdates el GPS sigue activo y vacía la batería
81        awaitClose { fusedClient.removeLocationUpdates(callback) }
82    }
83}

3.3 RutaRepository#

 1package com.ejemplo.excursia.data.repository
 2
 3import android.location.Location
 4import com.ejemplo.excursia.data.datasource.local.LocationDataSource
 5import com.ejemplo.excursia.data.model.Posicion
 6import kotlinx.coroutines.flow.Flow
 7
 8// ─── data/repository/RutaRepository.kt ───────────────────────────────────────────────────────────
 9
10interface RutaRepository {
11    fun observarPosicion(): Flow<Posicion>
12    suspend fun ultimaPosicionConocida(): Posicion?
13    fun distanciaEntre(origen: Posicion, destino: Posicion): Float
14}
15
16class RutaRepositoryImpl(
17    private val locationDataSource: LocationDataSource
18) : RutaRepository {
19
20    override fun observarPosicion(): Flow<Posicion> =
21        locationDataSource.observarPosicion(intervaloMs = 5_000L)
22
23    override suspend fun ultimaPosicionConocida(): Posicion? =
24        locationDataSource.obtenerUltimaPosicion()
25
26    /**
27     * Distancia en metros entre dos puntos siguiendo la curvatura terrestre.
28     * Location.distanceBetween es una función estática de utilidad: no accede al
29     * hardware ni requiere permisos, así que puede usarse con seguridad aquí.
30     */
31    override fun distanciaEntre(origen: Posicion, destino: Posicion): Float {
32        val resultado = FloatArray(1)
33        Location.distanceBetween(
34            origen.latitud, origen.longitud,
35            destino.latitud, destino.longitud,
36            resultado
37        )
38        return resultado[0]
39    }
40}

3.4 RutaViewModel#

  1package com.ejemplo.excursia.ui.ruta
  2
  3import androidx.lifecycle.ViewModel
  4import androidx.lifecycle.ViewModelProvider
  5import androidx.lifecycle.viewModelScope
  6import androidx.lifecycle.viewmodel.initializer
  7import androidx.lifecycle.viewmodel.viewModelFactory
  8import com.ejemplo.excursia.ExcursiaApplication
  9import com.ejemplo.excursia.data.model.Posicion
 10import com.ejemplo.excursia.data.repository.RutaRepository
 11import kotlinx.coroutines.Job
 12import kotlinx.coroutines.flow.MutableStateFlow
 13import kotlinx.coroutines.flow.StateFlow
 14import kotlinx.coroutines.flow.asStateFlow
 15import kotlinx.coroutines.flow.update
 16import kotlinx.coroutines.launch
 17
 18// ─── ui/ruta/RutaViewModel.kt ────────────────────────────────────────────────────────────────────
 19
 20/** Estado completo de la pantalla en una sola clase inmutable. */
 21data class RutaUiState(
 22    val permisoConcedido: Boolean = false,
 23    val precisionAproximada: Boolean = false,  // el usuario concedió solo COARSE
 24    val posicion: Posicion? = null,
 25    val distanciaRecorrida: Float = 0f,        // metros acumulados desde el inicio
 26    val error: String? = null
 27)
 28
 29class RutaViewModel(
 30    private val rutaRepository: RutaRepository
 31) : ViewModel() {
 32
 33    private val _uiState = MutableStateFlow(RutaUiState())
 34    val uiState: StateFlow<RutaUiState> = _uiState.asStateFlow()
 35
 36    private var trabajoLocalizacion: Job? = null
 37    private var posicionAnterior: Posicion? = null
 38
 39    /** La invoca la UI tras resolver el diálogo de permisos. */
 40    fun actualizarPermiso(concedidoFino: Boolean, concedidoAproximado: Boolean) {
 41        val concedido = concedidoFino || concedidoAproximado
 42        _uiState.update {
 43            it.copy(
 44                permisoConcedido = concedido,
 45                precisionAproximada = !concedidoFino && concedidoAproximado,
 46                error = if (concedido) null else "Permiso de localización denegado."
 47            )
 48        }
 49        if (concedido) iniciarSeguimiento()
 50    }
 51
 52    fun iniciarSeguimiento() {
 53        if (trabajoLocalizacion?.isActive == true) return   // evita duplicar la escucha
 54
 55        trabajoLocalizacion = viewModelScope.launch {
 56            // Pintar de inmediato la última posición cacheada, si la hay
 57            rutaRepository.ultimaPosicionConocida()?.let { cacheada ->
 58                _uiState.update { it.copy(posicion = cacheada) }
 59            }
 60
 61            try {
 62                rutaRepository.observarPosicion().collect { nueva ->
 63                    // Acumular la distancia recorrida usando el repositorio
 64                    val incremento = posicionAnterior
 65                        ?.let { rutaRepository.distanciaEntre(it, nueva) } ?: 0f
 66                    posicionAnterior = nueva
 67
 68                    _uiState.update {
 69                        it.copy(
 70                            posicion = nueva,
 71                            distanciaRecorrida = it.distanciaRecorrida + incremento,
 72                            error = null
 73                        )
 74                    }
 75                }
 76            } catch (e: Exception) {
 77                _uiState.update {
 78                    it.copy(error = e.message ?: "No se ha podido obtener la posición.")
 79                }
 80            }
 81        }
 82    }
 83
 84    fun detenerSeguimiento() {
 85        trabajoLocalizacion?.cancel()   // cancela el Flow → dispara awaitClose
 86        trabajoLocalizacion = null
 87    }
 88
 89    // onCleared se ejecuta al destruirse definitivamente el ViewModel.
 90    // viewModelScope ya se cancela solo, pero se deja explícito por claridad.
 91    override fun onCleared() {
 92        super.onCleared()
 93        detenerSeguimiento()
 94    }
 95
 96    companion object {
 97        val Factory: ViewModelProvider.Factory = viewModelFactory {
 98            initializer {
 99                val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
100                        as ExcursiaApplication
101                RutaViewModel(app.container.rutaRepository)
102            }
103        }
104    }
105}

3.5 PantallaRuta y gestión del permiso#

  1package com.ejemplo.excursia.ui.ruta
  2
  3import android.Manifest
  4import android.content.ActivityNotFoundException
  5import android.content.Intent
  6import android.content.pm.PackageManager
  7import androidx.activity.compose.rememberLauncherForActivityResult
  8import androidx.activity.result.contract.ActivityResultContracts
  9import androidx.compose.foundation.layout.*
 10import androidx.compose.material.icons.Icons
 11import androidx.compose.material.icons.filled.Map
 12import androidx.compose.material3.*
 13import androidx.compose.runtime.*
 14import androidx.compose.ui.Alignment
 15import androidx.compose.ui.Modifier
 16import androidx.compose.ui.platform.LocalContext
 17import androidx.compose.ui.unit.dp
 18import androidx.core.content.ContextCompat
 19import androidx.core.net.toUri
 20import androidx.lifecycle.compose.collectAsStateWithLifecycle
 21import androidx.lifecycle.viewmodel.compose.viewModel
 22
 23// ─── ui/ruta/PantallaRuta.kt ─────────────────────────────────────────────────────────────────────
 24
 25@Composable
 26fun PantallaRuta(
 27    modifier: Modifier = Modifier,
 28    viewModel: RutaViewModel = viewModel(factory = RutaViewModel.Factory)
 29) {
 30    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 31    val context = LocalContext.current
 32
 33    // Launcher para solicitar VARIOS permisos a la vez (FINE + COARSE).
 34    // El sistema muestra un único diálogo con las dos opciones de precisión.
 35    val launcherPermisos = rememberLauncherForActivityResult(
 36        ActivityResultContracts.RequestMultiplePermissions()
 37    ) { resultados ->
 38        viewModel.actualizarPermiso(
 39            concedidoFino = resultados[Manifest.permission.ACCESS_FINE_LOCATION] == true,
 40            concedidoAproximado = resultados[Manifest.permission.ACCESS_COARSE_LOCATION] == true
 41        )
 42    }
 43
 44    // Al entrar en la pantalla: si el permiso ya estaba concedido se arranca el
 45    // seguimiento; si no, se solicita. LaunchedEffect(Unit) se ejecuta una sola vez.
 46    LaunchedEffect(Unit) {
 47        val fino = ContextCompat.checkSelfPermission(
 48            context, Manifest.permission.ACCESS_FINE_LOCATION
 49        ) == PackageManager.PERMISSION_GRANTED
 50        val aproximado = ContextCompat.checkSelfPermission(
 51            context, Manifest.permission.ACCESS_COARSE_LOCATION
 52        ) == PackageManager.PERMISSION_GRANTED
 53
 54        if (fino || aproximado) {
 55            viewModel.actualizarPermiso(fino, aproximado)
 56        } else {
 57            launcherPermisos.launch(
 58                arrayOf(
 59                    Manifest.permission.ACCESS_FINE_LOCATION,
 60                    Manifest.permission.ACCESS_COARSE_LOCATION
 61                )
 62            )
 63        }
 64    }
 65
 66    // Al salir de la pantalla se detiene el GPS: ahorro de batería inmediato.
 67    DisposableEffect(Unit) {
 68        onDispose { viewModel.detenerSeguimiento() }
 69    }
 70
 71    Column(
 72        modifier = modifier.fillMaxSize().padding(16.dp),
 73        verticalArrangement = Arrangement.spacedBy(12.dp)
 74    ) {
 75        when {
 76            !uiState.permisoConcedido -> {
 77                Text("Excursia necesita tu localización para registrar la ruta.")
 78                Text(
 79                    "Sin este permiso solo podrás usar la pantalla de Entorno.",
 80                    style = MaterialTheme.typography.bodySmall,
 81                    color = MaterialTheme.colorScheme.onSurfaceVariant
 82                )
 83                Button(onClick = {
 84                    launcherPermisos.launch(
 85                        arrayOf(
 86                            Manifest.permission.ACCESS_FINE_LOCATION,
 87                            Manifest.permission.ACCESS_COARSE_LOCATION
 88                        )
 89                    )
 90                }) { Text("Conceder permiso") }
 91            }
 92
 93            uiState.posicion == null -> {
 94                Row(verticalAlignment = Alignment.CenterVertically) {
 95                    CircularProgressIndicator(modifier = Modifier.size(24.dp))
 96                    Spacer(Modifier.width(12.dp))
 97                    Text("Buscando señal GPS…")
 98                }
 99            }
100
101            else -> {
102                val posicion = uiState.posicion!!
103
104                if (uiState.precisionAproximada) {
105                    Text(
106                        "Precisión aproximada: has concedido solo la ubicación " +
107                        "aproximada, los datos tendrán un error de cientos de metros.",
108                        style = MaterialTheme.typography.bodySmall,
109                        color = MaterialTheme.colorScheme.error
110                    )
111                }
112
113                Card(modifier = Modifier.fillMaxWidth()) {
114                    Column(
115                        modifier = Modifier.padding(16.dp),
116                        verticalArrangement = Arrangement.spacedBy(4.dp)
117                    ) {
118                        Text("Posición actual", style = MaterialTheme.typography.titleMedium)
119                        Spacer(Modifier.height(8.dp))
120                        Text("Latitud:   %.6f".format(posicion.latitud))
121                        Text("Longitud:  %.6f".format(posicion.longitud))
122                        Text("Altitud:   %.0f m".format(posicion.altitudMetros))
123                        Text(
124                            "Precisión: ±%.0f m".format(posicion.precisionMetros),
125                            style = MaterialTheme.typography.bodySmall,
126                            color = MaterialTheme.colorScheme.onSurfaceVariant
127                        )
128                    }
129                }
130
131                Card(modifier = Modifier.fillMaxWidth()) {
132                    Column(modifier = Modifier.padding(16.dp)) {
133                        Text("Distancia recorrida", style = MaterialTheme.typography.titleMedium)
134                        Text(
135                            "%.0f m".format(uiState.distanciaRecorrida),
136                            style = MaterialTheme.typography.headlineMedium
137                        )
138                    }
139                }
140
141                // Intent implícito con el esquema geo: cualquier app de mapas
142                // instalada puede atenderlo. No se fuerza Google Maps con
143                // setPackage(...): si el usuario usa otra app, también funciona.
144                Button(onClick = {
145                    val uri = ("geo:${posicion.latitud},${posicion.longitud}?q=" +
146                            "${posicion.latitud},${posicion.longitud}(Mi posición)").toUri()
147                    val intent = Intent(Intent.ACTION_VIEW, uri)
148                    // Si no hay ninguna app capaz de abrir el Intent se lanza
149                    // ActivityNotFoundException: hay que capturarla siempre.
150                    try {
151                        context.startActivity(intent)
152                    } catch (e: ActivityNotFoundException) {
153                        // En una app real: mostrar un Snackbar informando al usuario
154                    }
155                }) {
156                    Icon(Icons.Default.Map, contentDescription = null)
157                    Spacer(Modifier.width(8.dp))
158                    Text("Ver en el mapa")
159                }
160
161                uiState.error?.let { mensaje ->
162                    Text(
163                        "Error: $mensaje",
164                        color = MaterialTheme.colorScheme.error,
165                        style = MaterialTheme.typography.bodySmall
166                    )
167                }
168            }
169        }
170    }
171}

¿Por qué try/catch y no resolveActivity()? Desde Android 11 (API 30) rige la visibilidad de paquetes: resolveActivity() devuelve null para apps que no se hayan declarado en un bloque <queries> del manifiesto, aunque estén instaladas. Capturar ActivityNotFoundException es más simple y siempre correcto.

Probar el GPS en el emulador: Extended Controls (…) → Location. Se puede fijar un punto concreto, importar un archivo GPX/KML o reproducir una ruta a velocidad configurable, lo que permite verificar el cálculo de distancia sin salir del aula.


4. Notificaciones locales#

Las notificaciones locales las genera la propia aplicación, sin servidor. Excursia las usa para avisar al excursionista aunque tenga el móvil guardado.

┌─────────────────────────────────────────────────────┐
│  [Icono] Título de la notificación                  │
│           Texto descriptivo de la notificación      │
│                          [Ya está][Posponer 10 min] │
└─────────────────────────────────────────────────────┘
Elemento Descripción
Canal (NotificationChannel) Agrupa notificaciones por categoría; obligatorio desde API 26. El usuario puede silenciar canales por separado.
Builder (NotificationCompat.Builder) Construye el contenido: icono, título, texto, estilo, prioridad.
PendingIntent Acción diferida que el sistema ejecutará en nombre de la app al pulsar.
NotificationManagerCompat Publica, actualiza y cancela las notificaciones.

4.1 NotificadorExcursia#

  1package com.ejemplo.excursia.data.notificaciones
  2
  3import android.Manifest
  4import android.app.NotificationChannel
  5import android.app.NotificationManager
  6import android.app.PendingIntent
  7import android.content.Context
  8import android.content.Intent
  9import android.content.pm.PackageManager
 10import android.os.Build
 11import androidx.core.app.NotificationCompat
 12import androidx.core.app.NotificationManagerCompat
 13import androidx.core.content.ContextCompat
 14import com.ejemplo.excursia.MainActivity
 15import com.ejemplo.excursia.R
 16
 17// ─── data/notificaciones/NotificadorExcursia.kt ──────────────────────────────────────────────────
 18
 19/**
 20 * Centraliza toda la construcción de notificaciones de Excursia.
 21 * Ninguna otra clase debe instanciar NotificationCompat.Builder.
 22 */
 23class NotificadorExcursia(private val context: Context) {
 24
 25    companion object {
 26        // Identificadores de canal: cadenas únicas dentro de la app
 27        const val CANAL_ALERTAS = "canal_alertas"
 28        const val CANAL_RUTA = "canal_ruta"
 29
 30        // Identificadores de notificación: enteros usados para actualizar o cancelar
 31        const val ID_AVISO = 1001
 32        const val ID_RUTA_EN_CURSO = 1002
 33        const val ID_HIDRATACION = 1003
 34    }
 35
 36    private val gestor = NotificationManagerCompat.from(context)
 37
 38    /**
 39     * Crea los canales. Es idempotente: volver a crearlos no duplica nada.
 40     * Debe llamarse en Application.onCreate(), antes de publicar nada.
 41     */
 42    fun crearCanales() {
 43        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
 44
 45        val canalAlertas = NotificationChannel(
 46            CANAL_ALERTAS,
 47            "Alertas de ruta",                       // nombre visible en Ajustes
 48            NotificationManager.IMPORTANCE_DEFAULT   // sonido + posible heads-up
 49        ).apply {
 50            description = "Recordatorios de hidratación, descanso y avisos de la salida"
 51            enableVibration(true)
 52            setShowBadge(true)
 53        }
 54
 55        val canalRuta = NotificationChannel(
 56            CANAL_RUTA,
 57            "Ruta en curso",
 58            NotificationManager.IMPORTANCE_LOW    // silenciosa: solo barra de estado
 59        ).apply {
 60            description = "Estado de la ruta que se está registrando"
 61        }
 62
 63        gestor.createNotificationChannels(listOf(canalAlertas, canalRuta))
 64    }
 65
 66    /**
 67     * Desde API 33 publicar sin POST_NOTIFICATIONS concedido no lanza excepción:
 68     * simplemente no se muestra nada. Comprobarlo antes evita perseguir fantasmas.
 69     */
 70    fun puedeNotificar(): Boolean =
 71        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
 72            ContextCompat.checkSelfPermission(
 73                context, Manifest.permission.POST_NOTIFICATIONS
 74            ) == PackageManager.PERMISSION_GRANTED
 75        } else true
 76
 77    /** PendingIntent que abre la app al pulsar el cuerpo de la notificación. */
 78    private fun intentAbrirApp(codigo: Int): PendingIntent {
 79        val intent = Intent(context, MainActivity::class.java).apply {
 80            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
 81        }
 82        // FLAG_IMMUTABLE es obligatorio desde API 31: impide que otra app
 83        // modifique el Intent que se ejecuta con los permisos de Excursia.
 84        return PendingIntent.getActivity(
 85            context, codigo, intent,
 86            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
 87        )
 88    }
 89
 90    /** Aviso simple: título + texto expandible. */
 91    fun mostrarAviso(titulo: String, mensaje: String, id: Int = ID_AVISO) {
 92        if (!puedeNotificar()) return
 93
 94        val notificacion = NotificationCompat.Builder(context, CANAL_ALERTAS)
 95            .setSmallIcon(R.drawable.ic_notificacion)   // debe ser monocromo
 96            .setContentTitle(titulo)
 97            .setContentText(mensaje)
 98            .setStyle(NotificationCompat.BigTextStyle().bigText(mensaje))
 99            .setPriority(NotificationCompat.PRIORITY_DEFAULT)  // compatibilidad < API 26
100            .setContentIntent(intentAbrirApp(id))
101            .setAutoCancel(true)        // se cierra al pulsarla
102            .build()
103
104        gestor.notify(id, notificacion)
105    }
106
107    /** Recordatorio de hidratación con dos botones de acción. */
108    fun mostrarRecordatorioHidratacion() {
109        if (!puedeNotificar()) return
110
111        // Cada acción es un PendingIntent a un BroadcastReceiver: se ejecuta sin
112        // abrir la app, que es justo lo que se espera de un botón de notificación.
113        val pendingHecho = PendingIntent.getBroadcast(
114            context, 10,
115            Intent(context, AccionesNotificacionReceiver::class.java).apply {
116                action = AccionesNotificacionReceiver.ACCION_HECHO
117            },
118            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
119        )
120
121        val pendingPosponer = PendingIntent.getBroadcast(
122            context, 11,
123            Intent(context, AccionesNotificacionReceiver::class.java).apply {
124                action = AccionesNotificacionReceiver.ACCION_POSPONER
125            },
126            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
127        )
128
129        val notificacion = NotificationCompat.Builder(context, CANAL_ALERTAS)
130            .setSmallIcon(R.drawable.ic_notificacion)
131            .setContentTitle("Hora de beber agua")
132            .setContentText("Llevas un rato caminando: hidrátate antes de continuar.")
133            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
134            .setContentIntent(intentAbrirApp(ID_HIDRATACION))
135            .setAutoCancel(true)
136            .addAction(R.drawable.ic_check, "Ya está", pendingHecho)
137            .addAction(R.drawable.ic_snooze, "Posponer 10 min", pendingPosponer)
138            .build()
139
140        gestor.notify(ID_HIDRATACION, notificacion)
141    }
142
143    /**
144     * Notificación persistente mientras la ruta está activa.
145     * setOngoing(true): el usuario no puede descartarla deslizando.
146     * setOnlyAlertOnce(true): solo suena la primera vez, aunque se actualice.
147     */
148    fun mostrarRutaEnCurso(metrosRecorridos: Float) {
149        if (!puedeNotificar()) return
150
151        val notificacion = NotificationCompat.Builder(context, CANAL_RUTA)
152            .setSmallIcon(R.drawable.ic_notificacion)
153            .setContentTitle("Ruta en curso")
154            .setContentText("Distancia: %.0f m".format(metrosRecorridos))
155            .setOngoing(true)
156            .setOnlyAlertOnce(true)
157            .setContentIntent(intentAbrirApp(ID_RUTA_EN_CURSO))
158            .build()
159
160        gestor.notify(ID_RUTA_EN_CURSO, notificacion)
161    }
162
163    fun cancelar(id: Int) = gestor.cancel(id)
164}

El icono de setSmallIcon() debe ser monocromo con transparencia (Android lo tiñe con el color del sistema). Un PNG a color se verá como una mancha blanca. Se genera con File → New → Image Asset → Notification Icons.

El código anterior necesita tres drawables en res/drawable/: ic_notificacion, ic_check e ic_snooze. Pueden crearse con File → New → Vector Asset eligiendo iconos de Material (por ejemplo hiking, done y snooze). Los iconos de las acciones (addAction) no se muestran en Android 7.0 o superior en los teléfonos, pero siguen siendo obligatorios en la firma del método y sí aparecen en Wear OS.

4.2 AccionesNotificacionReceiver#

Los botones de una notificación no deben abrir la app: se resuelven en un BroadcastReceiver.

 1package com.ejemplo.excursia.data.notificaciones
 2
 3import android.content.BroadcastReceiver
 4import android.content.Context
 5import android.content.Intent
 6import com.ejemplo.excursia.ExcursiaApplication
 7
 8// ─── data/notificaciones/AccionesNotificacionReceiver.kt ─────────────────────────────────────────
 9
10/**
11 * Atiende las pulsaciones de los botones de las notificaciones.
12 * Declarado en el AndroidManifest con android:exported="false" porque solo
13 * Excursia debe poder invocarlo.
14 */
15class AccionesNotificacionReceiver : BroadcastReceiver() {
16
17    companion object {
18        const val ACCION_HECHO = "com.ejemplo.excursia.ACCION_HECHO"
19        const val ACCION_POSPONER = "com.ejemplo.excursia.ACCION_POSPONER"
20    }
21
22    override fun onReceive(context: Context, intent: Intent) {
23        val app = context.applicationContext as ExcursiaApplication
24        val notificador = app.container.notificador
25
26        when (intent.action) {
27            ACCION_HECHO -> {
28                notificador.cancelar(NotificadorExcursia.ID_HIDRATACION)
29            }
30
31            ACCION_POSPONER -> {
32                notificador.cancelar(NotificadorExcursia.ID_HIDRATACION)
33                // onReceive se ejecuta en el hilo principal y dispone de pocos
34                // segundos: no se puede lanzar trabajo largo aquí. Se delega en
35                // WorkManager, que sí puede ejecutarse más tarde y en segundo plano.
36                app.container.recordatoriosRepository.posponerHidratacion(minutos = 10)
37            }
38        }
39    }
40}

4.3 Solicitar el permiso de notificaciones (API 33+)#

 1package com.ejemplo.excursia.ui.avisos
 2
 3import android.Manifest
 4import android.content.pm.PackageManager
 5import android.os.Build
 6import androidx.activity.compose.rememberLauncherForActivityResult
 7import androidx.activity.result.contract.ActivityResultContracts
 8import androidx.compose.runtime.*
 9import androidx.compose.ui.platform.LocalContext
10import androidx.core.content.ContextCompat
11
12// ─── ui/avisos/PermisoNotificaciones.kt ──────────────────────────────────────────────────────────
13
14/**
15 * Encapsula el estado del permiso POST_NOTIFICATIONS.
16 * @return par (tienePermiso, solicitarPermiso)
17 */
18@Composable
19fun recordarPermisoNotificaciones(): Pair<Boolean, () -> Unit> {
20    val context = LocalContext.current
21
22    var concedido by remember {
23        mutableStateOf(
24            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
25                ContextCompat.checkSelfPermission(
26                    context, Manifest.permission.POST_NOTIFICATIONS
27                ) == PackageManager.PERMISSION_GRANTED
28            } else true   // por debajo de API 33 no existe el permiso
29        )
30    }
31
32    val launcher = rememberLauncherForActivityResult(
33        ActivityResultContracts.RequestPermission()
34    ) { resultado -> concedido = resultado }
35
36    val solicitar: () -> Unit = {
37        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
38            launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
39        }
40    }
41
42    return concedido to solicitar
43}

5. WorkManager: recordatorios y tareas en segundo plano#

WorkManager ejecuta trabajo que debe completarse aunque el usuario cierre la app o reinicie el dispositivo. Es la herramienta perfecta para programar los recordatorios de Excursia.

Escenario Herramienta
Recordatorio dentro de X minutos OneTimeWorkRequest
Recordatorio que se repite cada 30 min durante la ruta PeriodicWorkRequest
Sincronización periódica con una API PeriodicWorkRequest
Tarea mientras la app está abierta y visible viewModelScope.launch
Seguimiento GPS continuo y visible al usuario Foreground Service

El intervalo mínimo de un PeriodicWorkRequest es de 15 minutos, y el sistema puede retrasar la ejecución para agrupar tareas y ahorrar batería. WorkManager garantiza que el trabajo se ejecutará, no que lo hará en un instante exacto. Para un aviso a una hora concreta (una alarma) la API adecuada sería AlarmManager.

5.1 Los Worker#

 1package com.ejemplo.excursia.data.workers
 2
 3import android.content.Context
 4import androidx.work.CoroutineWorker
 5import androidx.work.WorkerParameters
 6import com.ejemplo.excursia.ExcursiaApplication
 7
 8// ─── data/workers/RecordatorioWorker.kt ──────────────────────────────────────────────────────────
 9
10/**
11 * Recordatorio puntual: "avísame dentro de X minutos".
12 * CoroutineWorker ejecuta doWork() en Dispatchers.Default y admite código suspend.
13 *
14 * Un Worker se instancia por el sistema, no por el AppContainer: para acceder a
15 * las dependencias se recupera el contenedor a través del applicationContext.
16 */
17class RecordatorioWorker(
18    context: Context,
19    params: WorkerParameters
20) : CoroutineWorker(context, params) {
21
22    companion object {
23        const val CLAVE_TITULO = "titulo"
24        const val CLAVE_MENSAJE = "mensaje"
25    }
26
27    override suspend fun doWork(): Result {
28        // inputData transporta datos primitivos (no objetos) hasta el Worker
29        val titulo = inputData.getString(CLAVE_TITULO) ?: "Recordatorio de Excursia"
30        val mensaje = inputData.getString(CLAVE_MENSAJE) ?: "Tienes un aviso pendiente."
31
32        val notificador = (applicationContext as ExcursiaApplication).container.notificador
33
34        return try {
35            notificador.mostrarAviso(titulo, mensaje)
36            Result.success()
37        } catch (e: Exception) {
38            // retry: WorkManager reintentará aplicando backoff exponencial
39            if (runAttemptCount < 3) Result.retry() else Result.failure()
40        }
41    }
42}
43
44/** Recordatorio periódico de hidratación mientras la ruta está activa. */
45class HidratacionWorker(
46    context: Context,
47    params: WorkerParameters
48) : CoroutineWorker(context, params) {
49
50    override suspend fun doWork(): Result {
51        val app = applicationContext as ExcursiaApplication
52        app.container.notificador.mostrarRecordatorioHidratacion()
53        return Result.success()
54    }
55}

5.2 TrabajosDataSource: WorkManager encapsulado#

WorkManager es un framework, por lo que se accede a él desde un DataSource, igual que a los sensores o al GPS.

 1package com.ejemplo.excursia.data.datasource.local
 2
 3import android.content.Context
 4import androidx.work.*
 5import com.ejemplo.excursia.data.workers.HidratacionWorker
 6import com.ejemplo.excursia.data.workers.RecordatorioWorker
 7import kotlinx.coroutines.flow.Flow
 8import kotlinx.coroutines.flow.map
 9import java.util.concurrent.TimeUnit
10
11// ─── data/datasource/local/TrabajosDataSource.kt ─────────────────────────────────────────────────
12
13class TrabajosDataSource(context: Context) {
14
15    companion object {
16        const val TRABAJO_HIDRATACION = "hidratacion_periodica"
17        const val ETIQUETA_RECORDATORIO = "recordatorio_puntual"
18    }
19
20    private val workManager = WorkManager.getInstance(context)
21
22    /** Programa el aviso periódico de hidratación (mínimo permitido: 15 min). */
23    fun programarHidratacion(cadaMinutos: Long) {
24        val solicitud = PeriodicWorkRequestBuilder<HidratacionWorker>(
25            repeatInterval = cadaMinutos,
26            repeatIntervalTimeUnit = TimeUnit.MINUTES
27        )
28            .setConstraints(
29                Constraints.Builder()
30                    .setRequiresBatteryNotLow(true)   // no molestar con la batería baja
31                    .build()
32            )
33            .build()
34
35        // enqueueUniquePeriodicWork garantiza una única instancia con ese nombre.
36        // UPDATE: si ya existía, se sustituye por la nueva configuración
37        // (KEEP la habría conservado ignorando el nuevo intervalo).
38        workManager.enqueueUniquePeriodicWork(
39            TRABAJO_HIDRATACION,
40            ExistingPeriodicWorkPolicy.UPDATE,
41            solicitud
42        )
43    }
44
45    fun cancelarHidratacion() = workManager.cancelUniqueWork(TRABAJO_HIDRATACION)
46
47    /** Programa un aviso único que se disparará pasados N minutos. */
48    fun programarRecordatorio(titulo: String, mensaje: String, dentroDeMinutos: Long) {
49        val datos = workDataOf(
50            RecordatorioWorker.CLAVE_TITULO to titulo,
51            RecordatorioWorker.CLAVE_MENSAJE to mensaje
52        )
53
54        val solicitud = OneTimeWorkRequestBuilder<RecordatorioWorker>()
55            .setInputData(datos)
56            .setInitialDelay(dentroDeMinutos, TimeUnit.MINUTES)
57            .addTag(ETIQUETA_RECORDATORIO)   // permite cancelarlos todos a la vez
58            .build()
59
60        workManager.enqueue(solicitud)
61    }
62
63    fun cancelarRecordatorios() = workManager.cancelAllWorkByTag(ETIQUETA_RECORDATORIO)
64
65    /** Estado del trabajo periódico como Flow, para observarlo desde la UI. */
66    fun observarEstadoHidratacion(): Flow<WorkInfo.State?> =
67        workManager.getWorkInfosForUniqueWorkFlow(TRABAJO_HIDRATACION)
68            .map { lista -> lista.firstOrNull()?.state }
69}

5.3 RecordatoriosRepository#

 1package com.ejemplo.excursia.data.repository
 2
 3import androidx.work.Operation
 4import androidx.work.WorkInfo
 5import com.ejemplo.excursia.data.datasource.local.TrabajosDataSource
 6import kotlinx.coroutines.flow.Flow
 7
 8// ─── data/repository/RecordatoriosRepository.kt ──────────────────────────────────────────────────
 9
10interface RecordatoriosRepository {
11    val estadoHidratacion: Flow<WorkInfo.State?>
12    fun activarHidratacion()
13    fun desactivarHidratacion() : Operation // Devuelve la operación de cancelación para poder observar su resultado
14    fun posponerHidratacion(minutos: Long)
15    fun programarAviso(mensaje: String, dentroDeMinutos: Long)
16}
17
18class RecordatoriosRepositoryImpl(
19    private val trabajosDataSource: TrabajosDataSource
20) : RecordatoriosRepository {
21
22    companion object {
23        /** Regla de negocio de Excursia: recordar la hidratación cada 30 minutos. */
24        private const val INTERVALO_HIDRATACION_MIN = 30L
25    }
26
27    override val estadoHidratacion: Flow<WorkInfo.State?> =
28        trabajosDataSource.observarEstadoHidratacion()
29
30    override fun activarHidratacion() =
31        trabajosDataSource.programarHidratacion(INTERVALO_HIDRATACION_MIN)
32
33    override fun desactivarHidratacion() =
34        trabajosDataSource.cancelarHidratacion()
35
36    override fun posponerHidratacion(minutos: Long) =
37        trabajosDataSource.programarRecordatorio(
38            titulo = "Hora de beber agua",
39            mensaje = "Recordatorio pospuesto: hidrátate ahora.",
40            dentroDeMinutos = minutos
41        )
42
43    override fun programarAviso(mensaje: String, dentroDeMinutos: Long) =
44        trabajosDataSource.programarRecordatorio(
45            titulo = "Recordatorio de Excursia",
46            mensaje = mensaje,
47            dentroDeMinutos = dentroDeMinutos
48        )
49}

5.4 AvisosViewModel y PantallaAvisos#

 1package com.ejemplo.excursia.ui.avisos
 2
 3import androidx.lifecycle.ViewModel
 4import androidx.lifecycle.ViewModelProvider
 5import androidx.lifecycle.viewModelScope
 6import androidx.lifecycle.viewmodel.initializer
 7import androidx.lifecycle.viewmodel.viewModelFactory
 8import androidx.work.WorkInfo
 9import com.ejemplo.excursia.ExcursiaApplication
10import com.ejemplo.excursia.data.notificaciones.NotificadorExcursia
11import com.ejemplo.excursia.data.repository.RecordatoriosRepository
12import kotlinx.coroutines.flow.SharingStarted
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.stateIn
15
16// ─── ui/avisos/AvisosViewModel.kt ────────────────────────────────────────────────────────────────
17
18class AvisosViewModel(
19    private val recordatoriosRepository: RecordatoriosRepository,
20    private val notificador: NotificadorExcursia
21) : ViewModel() {
22
23    val estadoHidratacion: StateFlow<WorkInfo.State?> =
24        recordatoriosRepository.estadoHidratacion
25            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
26
27    fun enviarAvisoDePrueba() =
28        notificador.mostrarAviso(
29            titulo = "Excursia funciona",
30            mensaje = "Si ves este mensaje, las notificaciones están correctamente configuradas."
31        )
32
33    fun activarHidratacion() = recordatoriosRepository.activarHidratacion()
34    fun desactivarHidratacion() = recordatoriosRepository.desactivarHidratacion()
35    fun programarAviso(minutos: Long) =
36        recordatoriosRepository.programarAviso(
37            mensaje = "Es el momento de dar la vuelta para volver con luz.",
38            dentroDeMinutos = minutos
39        )
40
41    companion object {
42        val Factory: ViewModelProvider.Factory = viewModelFactory {
43            initializer {
44                val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
45                        as ExcursiaApplication
46                AvisosViewModel(
47                    app.container.recordatoriosRepository,
48                    app.container.notificador
49                )
50            }
51        }
52    }
53}
 1package com.ejemplo.excursia.ui.avisos
 2
 3import androidx.compose.foundation.layout.*
 4import androidx.compose.material3.*
 5import androidx.compose.runtime.*
 6import androidx.compose.ui.Modifier
 7import androidx.compose.ui.unit.dp
 8import androidx.lifecycle.compose.collectAsStateWithLifecycle
 9import androidx.lifecycle.viewmodel.compose.viewModel
10import androidx.work.WorkInfo
11
12// ─── ui/avisos/PantallaAvisos.kt ─────────────────────────────────────────────────────────────────
13
14@Composable
15fun PantallaAvisos(
16    modifier: Modifier = Modifier,
17    viewModel: AvisosViewModel = viewModel(factory = AvisosViewModel.Factory)
18) {
19    val (tienePermiso, solicitarPermiso) = recordarPermisoNotificaciones()
20    val estado by viewModel.estadoHidratacion.collectAsStateWithLifecycle()
21
22    Column(
23        modifier = modifier.fillMaxSize().padding(16.dp),
24        verticalArrangement = Arrangement.spacedBy(12.dp)
25    ) {
26        Text("Avisos", style = MaterialTheme.typography.headlineSmall)
27
28        if (!tienePermiso) {
29            Card(modifier = Modifier.fillMaxWidth()) {
30                Column(
31                    modifier = Modifier.padding(16.dp),
32                    verticalArrangement = Arrangement.spacedBy(8.dp)
33                ) {
34                    Text("Las notificaciones están desactivadas.")
35                    Text(
36                        "Excursia no podrá avisarte durante la ruta.",
37                        style = MaterialTheme.typography.bodySmall
38                    )
39                    Button(onClick = solicitarPermiso) { Text("Activar notificaciones") }
40                }
41            }
42            return@Column   // sin permiso no tiene sentido mostrar el resto
43        }
44
45        Button(
46            onClick = { viewModel.enviarAvisoDePrueba() },
47            modifier = Modifier.fillMaxWidth()
48        ) { Text("Enviar aviso de prueba") }
49
50        Button(
51            onClick = { viewModel.programarAviso(minutos = 15) },
52            modifier = Modifier.fillMaxWidth()
53        ) { Text("Avísame dentro de 15 minutos") }
54
55        HorizontalDivider()
56
57        // ─── Recordatorio periódico ──────────────────────────────────────────
58        val hidratacionActiva = estado == WorkInfo.State.ENQUEUED ||
59                estado == WorkInfo.State.RUNNING
60
61        ListItem(
62            headlineContent = { Text("Recordar hidratación cada 30 min") },
63            supportingContent = {
64                Text(
65                    when (estado) {
66                        WorkInfo.State.ENQUEUED -> "Programado, esperando el momento"
67                        WorkInfo.State.RUNNING  -> "Ejecutándose ahora"
68                        WorkInfo.State.CANCELLED, null -> "Desactivado"
69                        else -> "Estado: $estado"
70                    }
71                )
72            },
73            trailingContent = {
74                Switch(
75                    checked = hidratacionActiva,
76                    onCheckedChange = { activar ->
77                        if (activar) viewModel.activarHidratacion()
78                        else viewModel.desactivarHidratacion()
79                    }
80                )
81            }
82        )
83    }
84}

6. Application, AppContainer y navegación#

6.1 Contenedor de dependencias#

Sin Hilt ni Koin, la construcción de objetos se centraliza en un AppContainer creado una única vez en la Application. Es inyección de dependencias manual: cada ViewModel recibe lo que necesita a través de su factoría.

 1package com.ejemplo.excursia.data
 2
 3import android.content.Context
 4import android.hardware.SensorManager
 5import androidx.core.content.getSystemService
 6import com.ejemplo.excursia.data.datasource.local.LocationDataSource
 7import com.ejemplo.excursia.data.datasource.local.SensorDataSource
 8import com.ejemplo.excursia.data.datasource.local.TrabajosDataSource
 9import com.ejemplo.excursia.data.notificaciones.NotificadorExcursia
10import com.ejemplo.excursia.data.repository.*
11
12// ─── data/AppContainer.kt ────────────────────────────────────────────────────────────────────────
13
14interface AppContainer {
15    val entornoRepository: EntornoRepository
16    val rutaRepository: RutaRepository
17    val recordatoriosRepository: RecordatoriosRepository
18    val notificador: NotificadorExcursia
19}
20
21class DefaultAppContainer(private val context: Context) : AppContainer {
22
23    // by lazy: cada dependencia se construye la primera vez que se pide y se
24    // reutiliza a partir de entonces (una sola instancia por proceso).
25    private val sensorManager: SensorManager by lazy {
26        // getSystemService<T>() de androidx.core evita el cast manual
27        checkNotNull(context.getSystemService<SensorManager>()) {
28            "SensorManager no disponible"
29        }
30    }
31
32    override val entornoRepository: EntornoRepository by lazy {
33        EntornoRepositoryImpl(SensorDataSource(sensorManager))
34    }
35
36    override val rutaRepository: RutaRepository by lazy {
37        RutaRepositoryImpl(LocationDataSource(context))
38    }
39
40    override val recordatoriosRepository: RecordatoriosRepository by lazy {
41        RecordatoriosRepositoryImpl(TrabajosDataSource(context))
42    }
43
44    override val notificador: NotificadorExcursia by lazy {
45        NotificadorExcursia(context)
46    }
47}

6.2 ExcursiaApplication#

 1package com.ejemplo.excursia
 2
 3import android.app.Application
 4import com.ejemplo.excursia.data.AppContainer
 5import com.ejemplo.excursia.data.DefaultAppContainer
 6
 7// ─── ExcursiaApplication.kt ──────────────────────────────────────────────────────────────────────
 8
 9class ExcursiaApplication : Application() {
10
11    lateinit var container: AppContainer
12        private set
13
14    override fun onCreate() {
15        super.onCreate()
16
17        // 1. Contenedor de dependencias, disponible para todas las factorías
18        container = DefaultAppContainer(this)
19
20        // 2. Canales de notificación: deben existir ANTES de publicar la primera
21        //    notificación. Es idempotente, así que puede hacerse en cada arranque.
22        container.notificador.crearCanales()
23    }
24}

Recordar declarar android:name=".ExcursiaApplication" en el <application> del manifiest. Si se olvida, el as ExcursiaApplication de los factories lanzará ClassCastException en tiempo de ejecución.

6.3 MainActivity#

 1package com.ejemplo.excursia
 2
 3import android.os.Bundle
 4import androidx.activity.ComponentActivity
 5import androidx.activity.compose.setContent
 6import androidx.activity.enableEdgeToEdge
 7import com.ejemplo.excursia.ui.navegacion.NavegacionExcursia
 8import com.ejemplo.excursia.ui.theme.ExcursiaTheme
 9
10// ─── MainActivity.kt ─────────────────────────────────────────────────────────────────────────────
11
12class MainActivity : ComponentActivity() {
13    override fun onCreate(savedInstanceState: Bundle?) {
14        super.onCreate(savedInstanceState)
15        enableEdgeToEdge()
16        setContent {
17            ExcursiaTheme {
18                NavegacionExcursia()
19            }
20        }
21    }
22}

6.4 Navegación entre las tres pantallas#

 1package com.ejemplo.excursia.ui.navegacion
 2
 3import androidx.compose.foundation.layout.padding
 4import androidx.compose.material.icons.Icons
 5import androidx.compose.material.icons.filled.*
 6import androidx.compose.material3.*
 7import androidx.compose.runtime.Composable
 8import androidx.compose.runtime.getValue
 9import androidx.compose.ui.Modifier
10import androidx.compose.ui.graphics.vector.ImageVector
11import androidx.navigation.NavDestination.Companion.hasRoute
12import androidx.navigation.NavDestination.Companion.hierarchy
13import androidx.navigation.compose.NavHost
14import androidx.navigation.compose.*
15import com.ejemplo.excursia.ui.avisos.PantallaAvisos
16import com.ejemplo.excursia.ui.entorno.PantallaEntorno
17import com.ejemplo.excursia.ui.ruta.PantallaRuta
18import kotlinx.serialization.Serializable
19
20// ─── ui/navegacion/NavegacionExcursia.kt ─────────────────────────────────────────────────────────
21
22// Rutas type-safe (Navigation Compose 2.8+): objetos serializables en lugar de
23// cadenas, de modo que el compilador detecta los errores de navegación.
24@Serializable object Entorno
25@Serializable object Ruta
26@Serializable object Avisos
27
28private data class Destino(
29    val titulo: String,
30    val icono: ImageVector,
31    val ruta: Any
32)
33
34@OptIn(ExperimentalMaterial3Api::class)
35@Composable
36fun NavegacionExcursia() {
37    val navController = rememberNavController()
38
39    val destinos = listOf(
40        Destino("Entorno", Icons.Default.Sensors, Entorno),
41        Destino("Ruta", Icons.Default.Place, Ruta),
42        Destino("Avisos", Icons.Default.Notifications, Avisos)
43    )
44
45    val entradaActual by navController.currentBackStackEntryAsState()
46    val destinoActual = entradaActual?.destination
47
48    Scaffold(
49        topBar = { TopAppBar(title = { Text("Excursia") }) },
50        bottomBar = {
51            NavigationBar {
52                destinos.forEach { destino ->
53                    NavigationBarItem(
54                        selected = destinoActual?.hierarchy?.any {
55                            it.hasRoute(destino.ruta::class)
56                        } == true,
57                        onClick = {
58                            navController.navigate(destino.ruta) {
59                                // Evita apilar copias de la misma pantalla
60                                launchSingleTop = true
61                                restoreState = true
62                                popUpTo(Entorno) { saveState = true }
63                            }
64                        },
65                        icon = { Icon(destino.icono, contentDescription = destino.titulo) },
66                        label = { Text(destino.titulo) }
67                    )
68                }
69            }
70        }
71    ) { padding ->
72        NavHost(navController = navController, startDestination = Entorno) {
73            composable<Entorno> { PantallaEntorno(Modifier.padding(padding)) }
74            composable<Ruta> { PantallaRuta(Modifier.padding(padding)) }
75            composable<Avisos> { PantallaAvisos(Modifier.padding(padding)) }
76        }
77    }
78}

Las rutas type-safe necesitan el plugin de serialización de Kotlin: plugins { kotlin("plugin.serialization") version "2.4.0" } y implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0"). Si el proyecto ya lo incorporó al trabajar con Retrofit2 en el Bloque 3, no hay que añadir nada más.


Diagrama de flujo de Excursia#

                        ExcursiaApplication.onCreate()
                    ┌──────────────┴──────────────┐
            DefaultAppContainer            notificador.crearCanales()
        (DataSources + Repositorios)       (CANAL_ALERTAS, CANAL_RUTA)


  Pantalla ENTORNO            Pantalla RUTA              Pantalla AVISOS
        │                          │                            │
  EntornoViewModel           RutaViewModel               AvisosViewModel
        │                          │                            │
  EntornoRepository          RutaRepository            RecordatoriosRepository
  (umbral agitación,         (distancia entre           (intervalo 30 min)
   niveles de luz)            posiciones)                       │
        │                          │                            │
  SensorDataSource           LocationDataSource         TrabajosDataSource
  callbackFlow sobre         callbackFlow sobre         WorkManager
  SensorEventListener        LocationCallback                   │
        │                          │                    ┌───────┴────────┐
  SensorManager              FusedLocationProvider   HidratacionWorker  RecordatorioWorker
                                                          │                    │
                                                    NotificadorExcursia (NotificationCompat)
                                              AccionesNotificacionReceiver
                                              ("Ya está" / "Posponer 10 min")

Estructura de paquetes de Excursia#

com.ejemplo.excursia/
├── ExcursiaApplication.kt              ← container + canales de notificación
├── MainActivity.kt                     ← setContent { ExcursiaTheme { NavegacionExcursia() } }
├── data/
│   ├── AppContainer.kt                 ← inyección de dependencias manual
│   ├── model/
│   │   ├── Vector3.kt                  ← modelo: lectura de sensor de 3 ejes
│   │   └── Posicion.kt                 ← modelo: punto de la ruta
│   │                                     (el papel de DTO lo hacen SensorEvent y Location)
│   ├── datasource/
│   │   └── local/                      ← no hay remote/dto/ en este tema: los DTOs
│   │       │                             (SensorEvent, Location) los aporta Android
│   │       ├── SensorDataSource.kt     ← callbackFlow sobre SensorEventListener
│   │       ├── LocationDataSource.kt   ← callbackFlow + Location.toPosicion()
│   │       └── TrabajosDataSource.kt   ← WorkManager encapsulado
│   ├── repository/
│   │   ├── EntornoRepository.kt        ← umbral de agitación, niveles de luz
│   │   ├── RutaRepository.kt           ← distancia entre posiciones
│   │   └── RecordatoriosRepository.kt  ← política de recordatorios
│   ├── notificaciones/
│   │   ├── NotificadorExcursia.kt      ← canales, builder, PendingIntent
│   │   └── AccionesNotificacionReceiver.kt
│   └── workers/
│       └── RecordatorioWorker.kt       ← RecordatorioWorker + HidratacionWorker
└── ui/
    ├── navegacion/NavegacionExcursia.kt
    ├── entorno/  PantallaEntorno.kt + EntornoViewModel.kt
    ├── ruta/     PantallaRuta.kt + RutaViewModel.kt
    ├── avisos/   PantallaAvisos.kt + AvisosViewModel.kt + PermisoNotificaciones.kt
    └── theme/    (generado por Android Studio)

Desarrollo práctico guiado: Excursia 💻#

  • Crea el proyecto Excursia con la plantilla Empty Activity (Compose), package name com.ejemplo.excursia y minSdk = 26. Comprueba que la app arranca en el emulador antes de tocar nada más.
  • Añade al catálogo libs.versions.toml y al build.gradle.kts las dependencias del apartado 1: BOM de Compose, lifecycle-runtime-compose, navigation-compose, play-services-location, kotlinx-coroutines-play-services, work-runtime y core. Verifica en el panel Gradle que no aparecen conflictos de versiones y recuerda que work-runtime-ktx y core-ktx ya no deben declararse.
  • Declara en el AndroidManifest.xml los permisos de localización y POST_NOTIFICATIONS, y la clase .ExcursiaApplication en la etiqueta <application>. Si la olvidas, las factorías de los ViewModels fallarán con ClassCastException en cuanto abras una pantalla.
  • Crea el esqueleto de paquetes (data/model, data/datasource/local, data/repository, data/notificaciones, data/workers, ui/…) y las clases ExcursiaApplication y DefaultAppContainer vacías, para ir rellenándolas conforme avances.
  • Escribe el modelo Vector3 en data/model. Fíjate en que no importa nada de android.hardware: es la señal de que el modelo está limpio.
  • Implementa SensorDataSource con la función privada genérica observarSensor() y las tres funciones públicas (acelerómetro, luz y proximidad). Añade temporalmente un Log.d() dentro de awaitClose para comprobar más adelante que el listener se libera.
  • Añade EntornoRepository con el umbral de agitación y el enum NivelLuz. Discute en clase por qué el umbral está aquí y no en el ViewModel.
  • Implementa EntornoViewModel con stateIn y WhileSubscribed(5_000), y PantallaEntorno con la tarjeta reutilizable TarjetaSensor. Prueba los tres caminos: sensor presente, sensor ausente (desactívalo en Extended Controls → Virtual sensors) y rotación de pantalla, que no debe provocar un ciclo de baja y alta del sensor.
  • Mueve los valores del acelerómetro desde Virtual sensors y confirma en el Logcat que el Log.d de awaitClose solo aparece al abandonar la pantalla.
  • Crea el modelo Posicion y, dentro de LocationDataSource, la función privada de mapeo Location.toPosicion(). Comprueba que ninguna clase fuera del DataSource importa android.location.Location: es la misma regla que aplicaste con LibroDto en el Tema 5.
  • Implementa observarPosicion() con callbackFlow y obtenerUltimaPosicion() con await(). Si el IDE no reconoce await(), falta la dependencia kotlinx-coroutines-play-services.
  • Añade RutaRepository con distanciaEntre() y el RutaViewModel con su RutaUiState. Presta atención a iniciarSeguimiento(): debe ser idempotente, o acabarás con dos suscripciones al GPS.
  • Implementa PantallaRuta con el rememberLauncherForActivityResult de permisos múltiples y el DisposableEffect que detiene el seguimiento. Prueba los cuatro caminos: permiso concedido con precisión fina, concedido solo aproximado, denegado, y denegado permanentemente (“No volver a preguntar”).
  • Carga una ruta en Extended Controls → Location, reprodúcela y verifica que la distancia recorrida se acumula y que al cambiar de pestaña deja de crecer.
  • Genera los tres drawables monocromos (ic_notificacion, ic_check, ic_snooze) con File → New → Vector Asset.
  • Implementa NotificadorExcursia completo y llama a crearCanales() desde ExcursiaApplication.onCreate(). Comprueba en Ajustes del sistema → Aplicaciones → Excursia → Notificaciones que aparecen los dos canales con la importancia correcta.
  • Añade AccionesNotificacionReceiver y su declaración <receiver> en el manifiesto. Sin ella los botones no harán nada y no verás ningún error.
  • Implementa recordarPermisoNotificaciones() y la primera mitad de PantallaAvisos (aviso de prueba). Prueba a denegar el permiso: no debe publicarse nada ni lanzarse ninguna excepción.
  • Escribe RecordatorioWorker y HidratacionWorker, recuperando el NotificadorExcursia desde el contenedor a través de applicationContext as ExcursiaApplication.
  • Implementa TrabajosDataSource y RecordatoriosRepository, y completa AvisosViewModel y PantallaAvisos con el Switch de hidratación y el botón “Avísame dentro de 15 minutos”.
  • Programa un aviso, cierra la app desde la lista de recientes y espera: la notificación debe llegar igualmente. Después pulsa “Posponer 10 min” y confirma en App Inspection → Background Task Inspector que se ha encolado un nuevo OneTimeWorkRequest.
  • Completa DefaultAppContainer con las cuatro dependencias, escribe NavegacionExcursia con las rutas type-safe y conecta MainActivity. Navega varias veces entre las tres pantallas y comprueba que el estado se conserva y que el GPS y los sensores se detienen al salir de sus respectivas pantallas.
  • La app compila sin advertencias de código deprecated.
  • Ningún Composable ni ViewModel importa android.hardware.*, android.location.*, com.google.android.gms.* ni androidx.work.*.
  • Todos los callbackFlow liberan su recurso en awaitClose.
  • La app se comporta correctamente sin permisos, sin sensores y sin señal GPS.

Referencias#

Calendar  Última modificación: jueves, 17 de septiembre de 2026