Saltar al contenido principal

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.

Fran Cobos 11 min de lectura 2181 palabras

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.

Reparto de Mezclas - App Android Offline-First con AWS DynamoDB para COVAP


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_STICKY y LocationTrackingService que combina location|connectedDevice como 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í:

  1. Caché local: todos los datos de ruta, tickets y peso se guardan en SQLite con PersistentCache
  2. Cola persistente: las operaciones de sincronización se encolan en QueueService
  3. SyncDataService: intenta sincronizar con DynamoDB. Si el timeout (2s) se agota, cae a caché local
  4. Fusión multi-dispositivo: cuando dos dispositivos sincronizan la misma ruta, HojaRutaMergeUtil hace merge por intersección de claves (sk), conservando ambos tickets
  5. 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óduloFuncionalidadDetalle
CargaBáscula Bluetooth + HaversineGeovalidación en planta, 4 lecturas para estabilidad
Hoja de rutaFusión multi-dispositivoHojaRutaMergeUtil por intersección de sk
DescargaTicket + Carta de porteImpresión térmica ESC/POS con QR cifrado
PesoControl por GPS en segundo planoTelemetría peso+GPS cada minuto con debounce
FallbackSin básculaDescarga ‘vacía’ a 0 kg para no bloquear entrega
AuditoríaModificación con marca ‘provisional’Registro completo de cambios
ConfirmaciónEscaneo QR diarioAnti-manipulación horaria
NotificaciónRetraso en rutaDuración + motivo hacia central
NavegaciónDeep link a Google MapsNavegación GPS al socio
Auto-updateAPK sideloadConsulta 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étricaValor
TecnologíaKotlin 1.9.22 + Java 17
ArquitecturaMVVM con ViewModel singleton
Activities por feature6 (carga, hoja de ruta, descarga, tickets, carta de porte, configuración)
Base de datos localSQLite (PersistentCache + QueueService)
Base de datos remotaAWS DynamoDB (single-table, PK/SK)
Protocolo BluetoothRFCOMM SPP
Protocolo impresiónESC/POS (GS v 0, ISO-8859-1)
CifradoAES-256-CBC/PBKDF2 (65.536 iteraciones)
Tiempo de desarrollo2 meses
Arranque app< 2s en gama media
Lectura de básculaCada 300ms
Timeout sync DynamoDB2s con fallback a caché local
Test fusión multi-dispositivoVerificado con Robolectric
Dispositivos gestionadosFlota de camiones con Android

Lo que aprendí

  1. 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.
  2. El mutex de hardware salva el día — sin BluetoothConnectionManager con Mutex, las lecturas concurrentes entre pantalla y servicio de fondo causarían datos corruptos.
  3. La fusión por intersección de claves es robusta — HojaRutaMergeUtil verificado con tests Robolectric es la forma más fiable de garantizar que dos dispositivos no pierden datos al sincronizar.
  4. ESC/POS crudo es la única opción cuando la librería no soporta lo que necesitas. Enviar bytes directos con GS v 0 y ISO-8859-1 es tedioso pero fiable al 100%.
  5. El NetworkChangeReceiver serializado es obligatorio — sin serialización, los múltiples cambios de red disparan sincronizaciones concurrentes que pueden corromper la cola.
  6. 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:

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.