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:\
- Crea un proyecto nuevo en Android Studio con la plantilla Empty Activity (Kotlin + Jetpack Compose).
- Utiliza como paquete
edu.apellido1Nombre.excursia- 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:
- El framework nunca sube de capa. El
DataSourcees el único que conoceSensorEvent,LocationoWorkInfo. Traduce esos tipos a modelos propios de la app (Vector3,Posicion), que son los que viajan hacia arriba. Así elViewModelno depende de Google Play Services y puede probarse sin dispositivo. - 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 elViewModelni en elComposable. - 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), yLibro, 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 mapeoLibroDto.toLibro(), aplicada dentro delRemoteDataSource.
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
Librolleva las anotaciones@Entityy@ColumnInfoyLibroDtono: no se crea una tercera clase para la base de datos. Excursia no persiste nada todavía, peroPosicionestá 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_NOTIFICATIONSes de API 33 yACTIVITY_RECOGNITIONde API 29, fijarminSdk = 26permite trabajar con canales sin comprobaciones de versión. Todas las comprobacionesBuild.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) ycore-ktx(vacío desde 1.19.0). Seguir declarándolos no rompe la compilación, pero es código heredado: usawork-runtimeycore.
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_SENSORSha quedado obsoleto. Para apps contargetSdk36 (Android 16) o superior, las APIs que antes exigíanBODY_SENSORSoBODY_SENSORS_BACKGROUNDrequieren ahora los permisos granulares deandroid.permissions.health— por ejemploREAD_HEART_RATEen lugar deBODY_SENSORS, yREAD_HEALTH_DATA_IN_BACKGROUNDen lugar deBODY_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 deandroid.permissions.healthdesde Android 16).
Un sensor puede no existir en el dispositivo.
getDefaultSensor()devuelvenully 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é
callbackFlowy noflow { }? Dentro de unflow { }solo puede emitirse conemit()desde la propia corrutina, yonSensorChanged()se ejecuta en otro hilo (el del sensor).callbackFlowcrea internamente un canal que sí admite emisiones externas (trySend) y ofreceawaitClosepara 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
EntornoViewModelextiendeViewModely noAndroidViewModel: no necesita elContextporque quien lo necesita (elSensorManager) está encapsulado en elDataSource, construido una sola vez en elAppContainer. 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.
FusedLocationProviderClientfrente aLocationManager.LocationManageres la API nativa de Android y funciona sin Play Services.FusedLocationProviderClientes la opción recomendada por Google en dispositivos con Play Services, porque fusiona proveedores y gestiona la batería.LocationManagersigue 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 planoDesde 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
LibroyLibroDto. 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 conLibro.
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/catchy noresolveActivity()? Desde Android 11 (API 30) rige la visibilidad de paquetes:resolveActivity()devuelvenullpara apps que no se hayan declarado en un bloque<queries>del manifiesto, aunque estén instaladas. CapturarActivityNotFoundExceptiones 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_checkeic_snooze. Pueden crearse con File → New → Vector Asset eligiendo iconos de Material (por ejemplohiking,doneysnooze). 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
PeriodicWorkRequestes 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íaAlarmManager.
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, elas ExcursiaApplicationde los factories lanzaráClassCastExceptionen 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" }yimplementation("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
Excursiacon la plantilla Empty Activity (Compose),package namecom.ejemplo.excursiayminSdk = 26. Comprueba que la app arranca en el emulador antes de tocar nada más. - Añade al catálogo
libs.versions.tomly albuild.gradle.ktslas dependencias del apartado 1: BOM de Compose,lifecycle-runtime-compose,navigation-compose,play-services-location,kotlinx-coroutines-play-services,work-runtimeycore. Verifica en el panel Gradle que no aparecen conflictos de versiones y recuerda quework-runtime-ktxycore-ktxya no deben declararse. - Declara en el
AndroidManifest.xmllos permisos de localización yPOST_NOTIFICATIONS, y la clase.ExcursiaApplicationen la etiqueta<application>. Si la olvidas, las factorías de los ViewModels fallarán conClassCastExceptionen cuanto abras una pantalla. - Crea el esqueleto de paquetes (
data/model,data/datasource/local,data/repository,data/notificaciones,data/workers,ui/…) y las clasesExcursiaApplicationyDefaultAppContainervacías, para ir rellenándolas conforme avances. - Escribe el modelo
Vector3endata/model. Fíjate en que no importa nada deandroid.hardware: es la señal de que el modelo está limpio. - Implementa
SensorDataSourcecon la función privada genéricaobservarSensor()y las tres funciones públicas (acelerómetro, luz y proximidad). Añade temporalmente unLog.d()dentro deawaitClosepara comprobar más adelante que el listener se libera. - Añade
EntornoRepositorycon el umbral de agitación y elenum NivelLuz. Discute en clase por qué el umbral está aquí y no en el ViewModel. - Implementa
EntornoViewModelconstateInyWhileSubscribed(5_000), yPantallaEntornocon la tarjeta reutilizableTarjetaSensor. 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.ddeawaitClosesolo aparece al abandonar la pantalla. - Crea el modelo
Posiciony, dentro deLocationDataSource, la función privada de mapeoLocation.toPosicion(). Comprueba que ninguna clase fuera del DataSource importaandroid.location.Location: es la misma regla que aplicaste conLibroDtoen el Tema 5. - Implementa
observarPosicion()concallbackFlowyobtenerUltimaPosicion()conawait(). Si el IDE no reconoceawait(), falta la dependenciakotlinx-coroutines-play-services. - Añade
RutaRepositorycondistanciaEntre()y elRutaViewModelcon suRutaUiState. Presta atención ainiciarSeguimiento(): debe ser idempotente, o acabarás con dos suscripciones al GPS. - Implementa
PantallaRutacon elrememberLauncherForActivityResultde permisos múltiples y elDisposableEffectque 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
NotificadorExcursiacompleto y llama acrearCanales()desdeExcursiaApplication.onCreate(). Comprueba en Ajustes del sistema → Aplicaciones → Excursia → Notificaciones que aparecen los dos canales con la importancia correcta. - Añade
AccionesNotificacionReceivery 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 dePantallaAvisos(aviso de prueba). Prueba a denegar el permiso: no debe publicarse nada ni lanzarse ninguna excepción. - Escribe
RecordatorioWorkeryHidratacionWorker, recuperando elNotificadorExcursiadesde el contenedor a través deapplicationContext as ExcursiaApplication. - Implementa
TrabajosDataSourceyRecordatoriosRepository, y completaAvisosViewModelyPantallaAvisoscon elSwitchde 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
DefaultAppContainercon las cuatro dependencias, escribeNavegacionExcursiacon las rutas type-safe y conectaMainActivity. 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
ComposableniViewModelimportaandroid.hardware.*,android.location.*,com.google.android.gms.*niandroidx.work.*. - Todos los
callbackFlowliberan su recurso enawaitClose. - La app se comporta correctamente sin permisos, sin sensores y sin señal GPS.
Referencias#
- Descripción general de sensores — Android Developers
- Sensores de movimiento — Android Developers
- Sensores de posición (brújula y orientación) — Android Developers
- Descripción general de localización — Android Developers
- Solicitar actualizaciones de localización — Android Developers
- Descripción general de notificaciones — Android Developers
- Crear y gestionar canales de notificación — Android Developers
- Descripción general de WorkManager — Android Developers
- Cambios de comportamiento en Android 16 (permisos
BODY_SENSORS) — Android Developers - Releases de WorkManager — Android Developers
- Releases de androidx.core — Android Developers
- Releases de Lifecycle — Android Developers
- Mapeo de versiones del BOM de Compose — Android Developers
- Notas de versión de Google Play Services — Google Developers
- Tema 5 — Retrofit2 y la API de Open Library: DTOs, mapeadores y modelo
- Anexo B4-A1 — Referencia: Permisos en Android moderno, FileProvider y WorkManager avanzado