Caso Real: Reparto de Mezclas — App Android Offline-First con DynamoDB
App Android offline-first para la logística de reparto de piensos de COVAP: carga en planta con báscula Bluetooth, hoja de ruta por socios, fusión multi-dispositivo, impresión térmica ESC/POS con QR cifrado y sincronización automática a AWS DynamoDB.
Tabla de contenidos
Resumen
Desarrollé Reparto de Mezclas para COVAP: una aplicación Android offline-first que gestiona toda la logística de reparto de piensos en camión. Desde la carga en planta con báscula Bluetooth, pasando por hojas de ruta por socio con fusión multi-dispositivo, hasta descargas con ticket y carta de porte con impresión térmica directa. Todo sincroniza automáticamente con AWS DynamoDB y funciona sin conexión. Arranque < 2s en gama media, lectura de báscula cada 300ms, sincronización con timeout 2s y fallback a caché local.
![]()
El problema
COVAP necesitaba digitalizar la logística de reparto de piensos en camión. El proceso manual era:
- Peso en papel — el operador anotaba el peso de cada saco a mano después de pasarlo por la báscula
- Hoja de ruta física — cada socio tenía una hoja de papel que el conductor rellenaba
- Sin control de peso — no había forma de verificar que el peso real coincidiera con el esperado
- Sin sincronización — al llegar a destino, los datos había que introducirlos manualmente en el sistema
- Tickets a mano — la carta de porte y el ticket de descarga se generaban en papel
Lo que me pidieron: “Queremos que el conductor cargue el camión con la báscula, siga la ruta por socio, descargue con ticket automático e imprima la carta de porte, todo desde el móvil. Y que funcione sin conexión en el camión.”
La arquitectura que elegí
┌──────────────────────────────────────────────────────────────────┐
│ CAMIÓN │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ APP ANDROID (Kotlin 1.9.22 + Java 17) │ │
│ │ │ │
│ │ Pantalla de carga → Báscula Bluetooth (RFCOMM SPP) │ │
│ │ Hoja de ruta → Por socio, con estado de descarga │ │
│ │ Descarga → Ticket + Carta de porte + Impresión térmica │ │
│ │ │ │
│ │ SQLite local (PersistentCache + QueueService) │ │
│ │ SyncDataService (timeout 2s → encola → caché) │ │
│ │ BluetoothConnectionManager (singleton + Mutex) │ │
│ │ LocationTrackingService (foreground + START_STICKY) │ │
│ └──────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ │ AWS DynamoDB (PK/SK, EU-WEST-1) │
│ │ Sync automático con fallback a caché │
│ │ │
├─────────────────────────┼────────────────────────────────────────┤
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ SERVICIO BACKGROUND │ │
│ │ LocationTrackingService (geovalidación Haversine) │ │
│ │ Telemetría peso+GPS cada minuto │ │
│ │ Crashlytics + Analytics │ │
│ │ NetworkChangeReceiver (serializado) │ │
│ │ AppForegroundTracker (omite re-sync en teardown) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ HTTPS (cleartext solo a host interno)
▼
┌──────────────────────────────────────────────────────────────────┐
│ LOGÍSTICA CENTRAL │
│ Peso en vivo con debounce + guard de concurrencia │
│ Notificación de retrasos (duración + motivo) │
│ Confirmación diaria con escaneo QR │
│ Anti-manipulación horaria │
└──────────────────────────────────────────────────────────────────┘
Por qué Android nativo (Kotlin) y no React Native o Flutter
La app necesita integrarse con hardware Bluetooth a bajo nivel (báscula e impresora térmica) y necesita comportamiento foreground preciso. Kotlin nativo me dio:
- Bluetooth RFCOMM SPP directamente con el API de Android, sin capas intermedias que añadan latencia
- Foreground Service con
START_STICKYyLocationTrackingServiceque combinalocation|connectedDevicecomo required permission - ViewBinding para acceso tipo-safe a las vistas sin necesidad de findViewById
- MVVM con ViewModel singleton para mantener estado entre Activities (carga, hoja de ruta, descarga, tickets, carta de porte, configuración)
- Coroutines con Dispatchers.IO/Main para separar operaciones de red/disk del hilo principal
Flutter o React Native habrían necesitado plugins nativos para cada una de estas integraciones, añadiendo complejidad y posibles fallos de comunicación con el hardware.
Offline-first con AWS DynamoDB + SQLite
No se puede confiar en la conexión del camión. El sistema funciona así:
- Caché local: todos los datos de ruta, tickets y peso se guardan en SQLite con
PersistentCache - Cola persistente: las operaciones de sincronización se encolan en
QueueService - SyncDataService: intenta sincronizar con DynamoDB. Si el timeout (2s) se agota, cae a caché local
- Fusión multi-dispositivo: cuando dos dispositivos sincronizan la misma ruta,
HojaRutaMergeUtilhace merge por intersección de claves (sk), conservando ambos tickets - Verificación con tests Robolectric: los tests de fusión garantizan que ningún ticket se pierde en el merge
Integración de hardware: báscula e impresora por Bluetooth
Báscula Bluetooth (RFCOMM SPP)
La conexión con la báscula requiere estabilidad:
// BluetoothConnectionManager: singleton con Mutex
object BluetoothConnectionManager {
private val connectionMutex = Mutex()
private var bluetoothSocket: BluetoothSocket? = null
suspend fun readWeight(): Double = withContext(Dispatchers.IO) {
connectionMutex.withLock {
// Lectura serializada: solo un hilo lee a la vez
val raw = readFromDevice()
// Tolerancia abs() para báscula descalibrada
abs(raw).coerceIn(MIN_WEIGHT, MAX_WEIGHT)
}
}
}
Estabilidad: 4 lecturas consecutivas para validar consistencia. Si la báscula devuelve valores inestables, se aplica tolerancia abs() y se usa la lectura de referencia.
Impresora térmica ESC/POS
La impresora (Bixolon/Zebra) usa el protocolo ESC/POS. La particularidad es que tercap.report no soporta imágenes en su API, así que el QR se tiene que rasterizar y enviar como bytes crudos:
// Generación del ticket con QR rasterizado
fun buildTicket(weight: Double, qrData: ByteArray): ByteArray {
val header = ESC.POS.header(GS_V_0, ISO_8859_1)
val qrRaster = rasterizeQr(qrData) // Convertir QR a bytes de imagen raster
val center = centerInWidth(qrRaster, ticketWidth)
val qrCentered = centerQr(qrRaster)
val encryptedQr = encryptQr(qrData, AES_256_CBC, PBKDF2_65536)
return header + weightLine + qrCentered + footer
}
Formato: GS v 0, ISO-8859-1, bytes ESC/POS directos con QR raster centrado y cifrado AES-256-CBC/PBKDF2 compatible con el backend ASP.NET de Etiquetas Mezclas.
Sincronización offline-first: el corazón del sistema
SyncDataService
El servicio de sincronización es el componente más crítico. La cobertura en un camión es inestable: zonas rurales, túneles, zonas muertas.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Acción │────▶│ SyncData │────▶│ ¿Con │
│ local │ │ Service │ │ conexión? │
│ completada │ │ │ └──────┬──────┘
└──────────────┘ └──────────────┘ │
┌─────────┴─────────┐
│ │
SÍ ──▶│ DynamoDB │
NO ──▶│ Encolar + │
│ Caché local │
└──────────────────┘
Timeout 2s: Si la petición a DynamoDB tarda más de 2 segundos, se considera fallida. La operación se encola y cae a caché local. Al recuperar conexión, el NetworkChangeReceiver serializado procesa la cola pendiente.
Fusión multi-dispositivo (HojaRutaMergeUtil)
Cuando dos conductores trabajan sobre la misma ruta (uno por camión, otro de respaldo), sus datos deben fusionarse sin pérdida:
- Merge por intersección de claves (
sk): cada ticket tiene una clave única - Si un ticket existe tanto en local como remoto, se conservan ambos con marcas de origen
- Los tests con Robolectric verifican que la fusión es correcta
- La fusión es idempotente: sincronizar múltiples veces no duplica datos
AppForegroundTracker
El NetworkChangeReceiver está serializado y el AppForegroundTracker omite el re-sync cuando la app pasa a background (teardown). Esto evita sincronizaciones innecesarias cuando el usuario no está usando la app.
Problemas reales que encontré (y cómo los resolví)
1. Sync offline-first sin perder datos con cobertura inestable
La cobertura del camión es el problema número uno. Sin solución, los datos de descarga se pierden en zonas muertas.
Solución: SyncDataService con caché local + cola y timeout 2s a la nube. El NetworkChangeReceiver serializado procesa la cola cuando hay cobertura. Los datos nunca se pierden porque SQLite persiste todo.
2. Fusión de tickets concurrentes entre dos dispositivos sobre la misma ruta
Dos conductores pueden trabajar sobre la misma ruta simultáneamente. Si sus descargas se fusionan mal, se pierden tickets o se duplican pesos.
Solución: HojaRutaMergeUtil con merge por intersección de claves (sk), verificado con tests Robolectric. Conserva tickets locales y remotos. Sin pérdida de datos.
3. Condiciones de carrera en la lectura de la báscula entre pantalla y servicio de fondo
La lectura de la báscula ocurre tanto en la pantalla como en el servicio de fondo (LocationTrackingService). Sin sincronización, los dos lectores pueden interferir.
Solución: BluetoothConnectionManager singleton con Mutex de conexión y lectura serializada. Solo un hile lee a la vez. El servicio de fondo usa los mismos datos del singleton.
4. Estabilidad de librería heredada (connection pool closed)
La app usa una librería Bluetooth heredada que cierra el pool de conexiones inesperadamente, causando fallos en la lectura de báscula.
Solución: encapsulación en BluetoothConnectionManager con manejo de reconexión y re-inicialización del socket cuando el pool se cierra. Logging defensivo para detectar el fallo.
5. Peso de referencia fiable con báscula descalibrada y GPS inestable
Si la báscula está descalibrada, el peso registrado es incorrecto. Si el GPS es inestable, la geovalidación por Haversine en planta falla.
Solución: peso control con fallback en cascada. Validación de frescura distinta por fase (30 min al inicio de la carga, 5 min al final). La geovalidación Haversine verifica que el dispositivo está en planta. Si la báscula falla, modo fallback a descarga ‘vacía’ a 0 kg.
6. Imprimir QR en térmica sin soporte de imágenes en tercap.report
La librería tercap.report no soporta imágenes en su API de impresión. Pero el QR debe aparecer en el ticket.
Solución: bytes ESC/POS directos (GS v 0, ISO-8859-1). Se rasteriza el QR como imagen de ancho fijo, se centra en el ticket y se envían los comandos de impresión crudos. El QR se cifra con AES-256-CBC/PBKDF2 compatible con el backend ASP.NET.
Funcionalidades clave
| Módulo | Funcionalidad | Detalle |
|---|---|---|
| Carga | Báscula Bluetooth + Haversine | Geovalidación en planta, 4 lecturas para estabilidad |
| Hoja de ruta | Fusión multi-dispositivo | HojaRutaMergeUtil por intersección de sk |
| Descarga | Ticket + Carta de porte | Impresión térmica ESC/POS con QR cifrado |
| Peso | Control por GPS en segundo plano | Telemetría peso+GPS cada minuto con debounce |
| Fallback | Sin báscula | Descarga ‘vacía’ a 0 kg para no bloquear entrega |
| Auditoría | Modificación con marca ‘provisional’ | Registro completo de cambios |
| Confirmación | Escaneo QR diario | Anti-manipulación horaria |
| Notificación | Retraso en ruta | Duración + motivo hacia central |
| Navegación | Deep link a Google Maps | Navegación GPS al socio |
| Auto-update | APK sideload | Consulta de versión en DynamoDB |
Despliegue y monitoreo
- Backend: AWS DynamoDB (PK/SK, single-table design, EU-WEST-1)
- App: APK sideload para auto-update de la flota (consulta de versión en DynamoDB)
- Monitoreo: Firebase Crashlytics y Analytics para crash reporting y métricas de uso
- Seguridad: HTTPS con cleartext solo al host interno (configuración Android)
- Peso en vivo: envío a central con debounce y guard de concurrencia para evitar sobrecargas
Números del proyecto
| Métrica | Valor |
|---|---|
| Tecnología | Kotlin 1.9.22 + Java 17 |
| Arquitectura | MVVM con ViewModel singleton |
| Activities por feature | 6 (carga, hoja de ruta, descarga, tickets, carta de porte, configuración) |
| Base de datos local | SQLite (PersistentCache + QueueService) |
| Base de datos remota | AWS DynamoDB (single-table, PK/SK) |
| Protocolo Bluetooth | RFCOMM SPP |
| Protocolo impresión | ESC/POS (GS v 0, ISO-8859-1) |
| Cifrado | AES-256-CBC/PBKDF2 (65.536 iteraciones) |
| Tiempo de desarrollo | 2 meses |
| Arranque app | < 2s en gama media |
| Lectura de báscula | Cada 300ms |
| Timeout sync DynamoDB | 2s con fallback a caché local |
| Test fusión multi-dispositivo | Verificado con Robolectric |
| Dispositivos gestionados | Flota de camiones con Android |
Lo que aprendí
- Offline-first no es un extra, es el requisito principal — en un camión, la conexión es el primer obstáculo. Todo debe diseñarse desde el principio para fallar y recuperarse.
- El mutex de hardware salva el día — sin
BluetoothConnectionManagerconMutex, las lecturas concurrentes entre pantalla y servicio de fondo causarían datos corruptos. - La fusión por intersección de claves es robusta —
HojaRutaMergeUtilverificado con tests Robolectric es la forma más fiable de garantizar que dos dispositivos no pierden datos al sincronizar. - ESC/POS crudo es la única opción cuando la librería no soporta lo que necesitas. Enviar bytes directos con
GS v 0yISO-8859-1es tedioso pero fiable al 100%. - El NetworkChangeReceiver serializado es obligatorio — sin serialización, los múltiples cambios de red disparan sincronizaciones concurrentes que pueden corromper la cola.
- El modo fallback salva entregas — poder descargar a 0 kg cuando la báscula falla evita que el conductor pierda un día de trabajo entero.
¿Necesitas algo similar?
Si tu empresa necesita una app móvil offline-first para logística, gestión de flotas con lectura de hardware Bluetooth, o cualquier proyecto que requiera sincronización robusta sin conexión constante, hablemos.
Más casos reales: