← Todos los artículos
Técnico

BlueDrop por dentro: guardar la ubicación en los milisegundos que da un BroadcastReceiver

Cómo funciona una app de Android que guarda dónde aparcas cuando el móvil se desconecta del Bluetooth del coche. Un receptor que tiene que terminar enseguida, un foreground service para el trabajo lento, desconexiones duplicadas, un aparcamiento sin GPS que acababa en el océano, el cambio de Google Maps a OpenStreetMap y Clean Architecture en 10 módulos Gradle.

BlueDrop guarda dónde has aparcado en cuanto el móvil se desconecta del Bluetooth del coche, sin pulsar nada. Qué hace y cómo se usa está en el artículo para usuarios. Este cuenta cómo está hecha.

La idea se explica en una línea: escuchar la desconexión, pedir la ubicación y guardarla. El problema es que Android no te deja hacer casi nada en el momento en que llega esa desconexión. La app está cerrada, el móvil va en el bolsillo y el sistema te da muy poco margen antes de matar el proceso. La mayor parte de la arquitectura existe para sobrevivir a ese momento.

La forma de la app

Es una app nativa en Kotlin 2.0 con Jetpack Compose y Material 3. Por debajo: Hilt para la inyección de dependencias, Coroutines y Flow, Room para los aparcamientos y DataStore para los ajustes. La ubicación sale de Fused Location, las fotos de CameraX, el widget está hecho con Glance y el mapa con osmdroid.

Está repartida en 10 módulos Gradle siguiendo Clean Architecture con MVVM en la capa de presentación:

Diagrama de módulos:  depende de los cuatro módulos de feature, y features y módulos core apuntan todos a :core:domain

  • :core:domain es Kotlin puro, sin ninguna dependencia de Android. Tiene el modelo ParkingRecord, las interfaces de los repositorios y nueve casos de uso.
  • :core:data, :core:bluetooth, :core:location y :core:ui son la infraestructura: Room y DataStore, el receptor de Bluetooth, el servicio de ubicación y la geocodificación, y el tema con los textos en seis idiomas.
  • :feature:map, :feature:history, :feature:stats y :feature:settings son las cuatro pestañas, cada una con su pantalla en Compose y su ViewModel.
  • :app junta todo: la navegación, la pantalla de bienvenida y el widget.

Todo depende hacia el dominio y las features no se conocen entre sí. Para un proyecto de este tamaño puede parecer mucho. Lo que gano es que la lógica (cuándo un aparcamiento está abierto, cuánto duró, cuándo pedir una valoración en Play Store) se prueba con JUnit en la JVM, sin emulador y en segundos.

Del Bluetooth al aparcamiento guardado

Este es el camino completo desde que el coche se apaga hasta que la interfaz se entera:

Cargando diagrama...

El receptor: decidir rápido y delegar

La desconexión llega como un broadcast del sistema, ACTION_ACL_DISCONNECTED, a un BroadcastReceiver declarado en el manifiesto. Así Android lo despierta aunque la app no esté abierta. El precio es que onReceive() tiene que volver enseguida: mientras se ejecuta, el proceso solo vive porque el sistema le deja, y en cuanto vuelve puede matarlo.

El receptor necesita leer dos ajustes de DataStore antes de decidir nada: qué dispositivos están vigilados y si la detección está activa. Son lecturas suspend, así que usa goAsync(), que alarga un poco la vida del broadcast para trabajo asíncrono corto:

val pendingResult = goAsync()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

scope.launch {
    try {
        val monitoredMacs = settingsRepository.getMonitoredDeviceMacs().first()
        val trackingEnabled = settingsRepository.isTrackingEnabled().first()

        if (!trackingEnabled || !BluetoothDeviceHelper.isMonitoredDevice(device.address, monitoredMacs)) {
            return@launch
        }

        when (intent.action) {
            BluetoothDevice.ACTION_ACL_DISCONNECTED -> handleDisconnect(context, device)
            BluetoothDevice.ACTION_ACL_CONNECTED -> handleConnect(context, device)
        }
    } finally {
        pendingResult.finish()
        scope.cancel()
    }
}

Lo que no hace el receptor es lo lento: pedir la ubicación, que puede tardar segundos, buscar la dirección y escribir en la base de datos. Eso lo delega en un foreground service y vuelve.

El servicio: lo primero, avisar a Android

Un foreground service de tipo location puede usar la ubicación con la app en segundo plano, pero a cambio tiene que mostrar una notificación desde el primer momento. Por eso onStartCommand empieza llamando a startForeground(), antes que nada:

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    startForeground(
        NotificationHelper.NOTIF_ID_SERVICE,
        NotificationHelper.buildServiceNotification(this)
    )

    val deviceName = intent?.getStringExtra("device_name") ?: "Unknown"
    val deviceMac = intent?.getStringExtra("device_mac") ?: run {
        stopSelf()
        return START_NOT_STICKY
    }

    scope.launch {
        captureAndSave(deviceName, deviceMac)
        stopSelf()
    }

    return START_NOT_STICKY
}

captureAndSave hace una sola petición a getCurrentLocation con prioridad BALANCED_POWER_ACCURACY, resuelve la dirección, guarda el registro con el caso de uso, avisa al widget con un broadcast propio y lanza la notificación de "Aparcamiento guardado". Luego el servicio se para solo. START_NOT_STICKY porque, si el sistema lo mata a medias, no tiene sentido relanzarlo más tarde con una ubicación que ya no es la del coche.

Desconexiones duplicadas

Algunos equipos de Bluetooth mandan dos desconexiones seguidas, y cada una guardaba su propio aparcamiento. La solución es un debounce por dispositivo: se recuerda la última vez que cada MAC disparó una captura y se ignora todo lo que llegue en los 30 segundos siguientes.

private suspend fun handleDisconnect(context: Context, device: BluetoothDevice) {
    val now = System.currentTimeMillis()
    val macKey = device.address.lowercase()
    val lastTrigger = lastTriggerByMac[macKey] ?: 0L
    if (now - lastTrigger < DEBOUNCE_MS) return
    lastTriggerByMac[macKey] = now
    // ...arranca el servicio
}

Es por dispositivo y no global porque la app vigila una lista de MACs. Si llevas el móvil conectado a la moto y al intercomunicador del casco y te bajas, son dos desconexiones legítimas de dos dispositivos distintos, y un debounce global se comería una.

El mapa vive en memoria, en el companion object del receptor. Si Android matara el proceso justo entre las dos desconexiones, se perdería y saldría un duplicado. Es poco probable, porque la segunda desconexión llega casi pegada a la primera y el servicio ya está en marcha, y para eso no compensaba guardarlo en disco.

Volver al coche

El mismo receptor escucha también ACTION_ACL_CONNECTED. Cuando el móvil se reconecta a un dispositivo vigilado, el caso de uso RecordArrivalUseCase busca el último aparcamiento abierto de esa MAC y le pone hora de llegada y duración:

suspend operator fun invoke(deviceMac: String): ParkingRecord? {
    val record = repository.getLatestOpenRecord(deviceMac) ?: return null
    val now = System.currentTimeMillis()
    val updated = record.copy(
        arrivalTimestamp = now,
        durationMinutes = maxOf(0, ((now - record.timestamp) / 60_000).toInt())
    )
    repository.update(updated)
    return updated
}

El maxOf(0, ...) está por si el reloj del móvil cambia entre una cosa y otra, por ejemplo al sincronizar la hora. Una duración negativa rompería las estadísticas. Tiene su test.

No siempre hay reconexión. Puedes ir a por el coche sin el móvil, o tener el Bluetooth apagado al arrancar. Para esos casos, CloseParkingSessionManuallyUseCase cierra la sesión desde el historial, con la hora actual o una elegida a mano.

Sin GPS: el aparcamiento que acababa en el océano

El plan original para la ubicación tenía tres niveles: pedir alta precisión, si no la red, si no la última posición conocida de hace menos de cinco minutos, y solo si todo fallaba guardar sin ubicación. Lo que acabó en la app es más simple: una única llamada a getCurrentLocation y, si devuelve null o lanza una excepción, el registro se guarda con hasGpsFix = false.

Guardarlo aunque no haya ubicación es a propósito. En un parking subterráneo es normal no tener señal, y un aparcamiento con la hora y el coche pero sin coordenadas sigue siendo más útil que nada. El problema fue cómo lo guardé: latitude y longitude son Double no nulos, así que el registro sin GPS lleva 0.0, 0.0. Ese punto existe y está en el golfo de Guinea, frente a la costa de África. Cualquier código que pinte un aparcamiento sin mirar antes hasGpsFix lo manda allí.

Me pasó al construir la edición manual. Al entrar en modo edición sobre un aparcamiento sin GPS, la cámara se movía a la última posición buena conocida, pero el pin seguía en (0, 0). No se veía, no se podía arrastrar y, si guardabas sin tocar nada, se volvía a guardar (0, 0). El arreglo fue mover también el pin y la posición editada:

if (!parking.hasGpsFix) {
    lastKnownGoodLocation?.let {
        val fallbackPoint = GeoPoint(it.latitude, it.longitude)
        marker.position = fallbackPoint
        editedPosition = fallbackPoint
        mapView.controller.animateTo(fallbackPoint)
        mapView.invalidate()
    }
}

En el mismo cambio salió otro fallo. Al guardar una posición escrita a mano se guardaba también la dirección que venía precargada en el formulario, que era la del sitio antiguo. Ahora, si el usuario no ha tocado la dirección, se pasa null y el ViewModel la vuelve a calcular a partir de las coordenadas nuevas.

La lección de fondo es que usar un valor válido como marca de "no hay dato" es una trampa, y (0, 0) lo es. Unas coordenadas que admitan nulos me habrían obligado a tratar el caso en cada sitio que pinta un aparcamiento, y el compilador me habría avisado de los que faltaban. Con un Boolean aparte, depende de que nadie se olvide de mirarlo.

La dirección la saca AddressResolver, que separé del servicio precisamente para que la captura automática y la edición manual usen el mismo código. El Geocoder de Android es opcional (Geocoder.isPresent()), puede fallar sin red y en Android 13 cambió a una API con callback. Por eso el resolvedor devuelve String? y nunca lanza: una dirección null significa "enseña las coordenadas", no un error.

De Google Maps a OpenStreetMap

El mapa empezó con Google Maps Compose. Funcionaba, pero dependía de una API key y de una cuenta de facturación en Google Cloud. Para una app gratuita y sin anuncios no quería ninguna de las dos cosas: ni gestionar la clave ni arriesgarme a una factura si la app se usaba más de lo previsto.

Lo cambié por osmdroid, que pinta OpenStreetMap. No tiene integración nativa con Compose, así que el MapView va dentro de un AndroidView. Con eso tuve que gestionar a mano cosas que en Compose son automáticas, como mover el marcador o invalidar el mapa cuando cambia el estado. El pin también es propio: el marcador por defecto de osmdroid es verde y no pegaba con el tema.

Lo que no cambié es la navegación. Navegar al coche abre Google Maps con la ruta a pie (google.navigation:...&mode=w), y si no está instalado, la misma ruta en la web. Para llevarte andando hasta el coche, la app de mapas que ya tiene el usuario lo hace mejor que cualquier cosa que yo montara.

El widget

El widget está hecho con Glance, que permite escribir widgets con una API parecida a Compose. Enseña la dirección del último aparcamiento, hace cuánto fue y un botón para navegar. Para refrescarse escucha el mismo broadcast PARKING_SAVED que manda el servicio al guardar. Así se actualiza en cuanto aparcas, sin esperar al ciclo de refresco del sistema.

Tests

Hay 47 tests unitarios con JUnit y MockK. Los de dominio cubren los casos de uso: la llegada (incluida la duración nunca negativa), el cierre manual, la edición de ubicación y cuándo pedir la valoración en Play Store. Los de los ViewModel simulan los casos de uso con MockK y comprueban lo que recibe la interfaz. En el mapa, qué aparcamiento se enseña, cuál es la última posición con GPS y cuándo se recalcula la dirección. En las estadísticas, que solo cuenten las sesiones del mes y que las zonas se agrupen por dirección. También están probados el emparejamiento de MACs sin distinguir mayúsculas y el repositorio de Room.

Lo que no está automatizado es justo el principio de todo: que un coche de verdad se desconecte. Ese broadcast solo lo puede mandar el sistema, así que esa parte se prueba a mano, con el móvil y el coche, siguiendo los logs del receptor con adb logcat -s BLUEDROP_BT.

Lo que me llevo

Casi todo lo difícil de BlueDrop estaba en el borde con Android, no en la lógica. Que el receptor vuelva enseguida, que el servicio avise antes de hacer nada, que una desconexión duplicada no cuente dos veces, que una ubicación que no llega no rompa el guardado. La lógica de verdad, abrir y cerrar aparcamientos y calcular duraciones, cabe en un módulo de Kotlin puro y se prueba sin emulador. Separar las dos cosas es lo que me ha dejado cambiar el mapa entero sin tocar una sola regla de negocio.

Está en Google Play.