App móvil Android¶
La app móvil WorkDone es la herramienta de campo del personal de limpieza. Corre en celulares Android, lee tags NFC pegados en cada sanitario y registra los trabajos que se hacen. Está diseñada offline-first: opera al 100% sin conexión y sincroniza con el backend cuando hay red. Es parte del producto WorkDone de LubecaTech (cliente piloto: Corpal, Córdoba).
Esta página describe para qué sirve, su arquitectura, los flujos de UI principales y cómo registra un trabajo de punta a punta. Para entender cómo viajan los datos al backend ver Sincronización; para las entidades, Modelo de datos.
Para qué sirve y quién la usa¶
Desde la v2, la app maneja 3 roles y cada uno ve una variante distinta de la misma app:
| Rol | Qué hace | Pantalla principal | Historial |
|---|---|---|---|
OPERADOR_LIMPIEZA |
Limpia sanitarios | Main de limpieza (verde/gris) | "Mis limpiezas" |
SUPERVISOR |
Controla (trabajo tipo SUPERVISION) |
Main de control (textos "Controlando:") | "Mis controles" |
ADMIN |
Gestiona tags NFC (alta/asignación) | Gestión NFC | — |
El rol llega en la respuesta de login y en GET /api/v1/app/me. Si viene null, el default seguro es OPERADOR_LIMPIEZA.
La operación cotidiana es un tap
El gesto central de toda la app es apoyar el celular sobre la placa NFC del sanitario. Un tap abre el trabajo (INGRESO), otro tap lo cierra (EGRESO). Todo lo demás es soporte de ese gesto.
Stack y arquitectura¶
La app es 100% nativa Android, Kotlin + Jetpack Compose.
| Capa | Tecnología |
|---|---|
| Lenguaje | Kotlin 2.2.10+ |
| UI | Jetpack Compose (Material3) |
| Persistencia local | Room (SQLite, schema 5, migraciones SQL explícitas) |
| HTTP/JSON | Retrofit + OkHttp + Moshi |
| Sync en background | WorkManager (periódico) |
| Sesión/config | DataStore (Preferences) |
| Inyección de dependencias | Hilt |
| Asíncrono | Coroutines + Flow/StateFlow |
| Min SDK | 26 (Android 8.0, por estabilidad NFC) |
Arquitectura (Clean lite + MVVM)¶
El flujo de dependencias es ui → domain (use cases) → data (repositorios).
flowchart LR
UI["ui (Compose)<br/>observa StateFlow"] --> DOMAIN["domain<br/>use cases"]
DOMAIN --> DATA["data<br/>repositorios"]
DATA --> ROOM[("Room<br/>(single source of truth)")]
DATA --> API["WorkDoneApi<br/>(Retrofit)"]
ROOM -.->|Flow| UI
API -.->|sync| ROOM
Reglas de arquitectura que importan para entender los flujos:
- Single source of truth: Room. Los Composables observan
Flowdel DAO, nunca del API directamente. - MVVM con StateFlow desde el ViewModel hacia la UI. No se usa LiveData.
- La UI es optimista: al tapear, la app asume éxito y pinta verde/gris al instante. Si el server después rechaza, se reconcilia (ver Flujo B).
- El timestamp del teléfono al momento del tap es la verdad. Se captura
Instant.now()antes de cualquier procesamiento.
Estados de UI principales¶
La pantalla principal (Main) es una máquina de estados visual. El componente central es un círculo de color que comunica el estado de un vistazo.
stateDiagram-v2
[*] --> GRIS
GRIS --> VERDE: tap INGRESO
VERDE --> RESUMEN: tap EGRESO (confirmado)
RESUMEN --> GRIS: 4 seg / "Continuar"
note right of GRIS
"Acercá el celular a una placa NFC"
NFC en foreground escuchando
end note
note right of VERDE
"Limpiando: AEP-T-B12..."
timer hh:mm:ss corriendo
end note
note right of RESUMEN
"Trabajo completado"
sanitario + duración
end note
| Estado | Color | Significado | Texto |
|---|---|---|---|
| GRIS | #9C9C99 (⊙) |
No estoy limpiando | "Acercá el celular a una placa NFC" |
| VERDE | #1B9464 (✓, con pulso) |
Limpiando | "Limpiando: AEP-T-B12 - Baño Hombres Terminal A" + timer |
| AZUL/RESUMEN | #1168A8 (✓✓) |
Post-egreso transitorio (4 seg) | "Trabajo completado" + duración |
Los tres estados, tal como los ve el operario en el celular:
Izquierda a derecha: GRIS ("Acercá el celular al sanitario", esperando un tap) · VERDE ("Limpiando: AEP-T-B12 - Baño Hombres - Terminal A" con timer corriendo) · RESUMEN/AZUL ("Trabajo registrado", AEP-T-B12, "Duración: 14 min 32 s", transitorio 4 seg antes de volver a GRIS). Arriba a la izquierda el menú (drawer); arriba a la derecha el operario logueado.
Variante SUPERVISOR
Para el rol SUPERVISOR los estados son idénticos pero los textos dicen "Controlando:" en vez de "Limpiando:", el trabajo es tipo SUPERVISION y el historial es "Mis controles".
Indicador de red¶
Un chip discreto abajo de la pantalla muestra el estado de conexión sin bloquear nunca la operación:
- 🟢
Conectado+ hora del último sync (ej. "sync: 14:32") - 🟡
Sincronizando...+ spinner - 🔴
Sin conexión+ cantidad de pendientes (ej. "🔴 Sin conexión · 3 pendientes")
Flujos de UI¶
Login¶
La app abre con un Splash (1 seg) que valida el refresh_token y decide si va a Login o directo a Main.

Pantalla de login. Por defecto pide usuario + PIN (4 dígitos); el link "¿Primera vez en este dispositivo? Usar contraseña" cambia a password completa.
flowchart TD
SPLASH[Splash 1s<br/>valida refresh_token] --> HAS{¿Hay refresh<br/>token en device?}
HAS -->|Sí| PIN[Login pide PIN]
HAS -->|No| PASS[Login pide contraseña completa]
PASS --> OK[Login OK<br/>guarda refresh_token + rol]
PIN --> OK
OK --> MAIN[Main GRIS]
PIN -.->|5 fallos| BLOQ[Bloqueo 10 min<br/>con countdown]
- Por defecto pide PIN (más rápido). Si el operario nunca se logueó en ese device, pide password completa; después, el siguiente login alcanza con PIN.
- 5 intentos fallidos → bloqueo de 10 min con mensaje claro y countdown (el backend throttlea 5 fallos por usuario+device → 429 por 10 min).
Flujo A: Tap exitoso INGRESO → EGRESO¶
Este es el camino feliz: el operario llega al sanitario, tapea para entrar, limpia, y tapea de nuevo para salir.
flowchart TD
G1[GRIS] -->|tap tag| C1[Generar client_uuid]
C1 --> R1[Insertar en trabajo_pendiente Room]
R1 --> V1[UI optimista → VERDE<br/>inicia timer]
V1 --> S1[Dispatch sync inmediato]
S1 --> V2[VERDE con timer]
V2 -->|tap de nuevo<br/>mismo sanitario| EV["Pantalla '¿Cómo quedó<br/>el sanitario?' (v2)"]
EV -->|Confirmar| C2[Persiste EGRESO + eventos]
C2 --> RES[RESUMEN 4 seg]
RES --> G2[GRIS]
En v2, el EGRESO no se persiste directo: abre la pantalla de selección de eventos (ver más abajo).
Flujo de cierre con eventos (v2)¶
Al tapear el EGRESO, EvaluarTapUseCase detecta que es una salida pero no persiste todavía. Aparece la pantalla "¿Cómo quedó el sanitario?" (SeleccionEventosScreen):

Pantalla de cierre con eventos. "Cerrando: AEP-T-B12" arriba; multiselect de eventos ("Sin novedad", "Papel agotado", "Dispenser roto"). "Confirmar y finalizar" queda deshabilitado hasta elegir al menos un evento.
- Multiselect de eventos de limpieza filtrados por rol (catálogo
evento_limpieza_local). - Exclusividad de
SIN_NOVEDAD: seleccionarlo deselecciona y bloquea al resto. - Observación opcional por cada evento no-exclusivo.
- Back = cancelar → el trabajo sigue ABIERTO, no se persiste el EGRESO.
- Confirmar →
ConfirmarCierreUseCasepersiste el EGRESO + el snapshot de eventos (trabajo_pendiente+trabajo_pendiente_evento) en una sola transacción.
stateDiagram-v2
[*] --> Eventos: tap EGRESO detectado
Eventos --> Verde: Back (cancela, trabajo sigue abierto)
Eventos --> Confirmado: Confirmar (transacción)
Confirmado --> Resumen: EGRESO + eventos persistidos
Resumen --> [*]
note right of Eventos
Multiselect filtrado por rol
SIN_NOVEDAD es exclusivo
Observación opcional por evento
end note
Red de seguridad
Si el operario abandona la pantalla de eventos y tapea otro sanitario, el anterior se cierra con EGRESO_CIERRE_AUTOMATICO (sin eventos).
Flujo B: Reinterpretación del server¶
La UI es optimista, así que a veces el server corrige lo que la app asumió. Caso típico: el operario olvidó marcar la salida en un baño hace una hora.
flowchart TD
V[VERDE en baño viejo<br/>sin egreso hace 1h] -->|tapea NUEVO sanitario| OPT[UI optimista:<br/>asume INGRESO, verde nuevo]
OPT --> SYNC[Sync]
SYNC --> SRV[Server responde:<br/>cerró el anterior con EGRESO_CIERRE_AUTOMATICO<br/>+ creó el nuevo INGRESO]
SRV --> REC[App reconcilia:<br/>snackbar 'Se cerró automáticamente AEP-T-B11'<br/>historial refleja ambos eventos<br/>UI queda VERDE con el nuevo sanitario]
Flujo C: Tap offline¶
Sin red, el flujo es idéntico de cara al operario; la diferencia es la resolución del tag y la cola.
flowchart TD
G[GRIS · 🔴 Sin conexión] -->|tap| UUID[Generar client_uuid]
UUID --> RES{Resolver tag<br/>contra cache local}
RES -->|tag conocido| OK[Insertar trabajo_pendiente<br/>con sanitario_id_resuelto]
RES -->|tag desconocido| NULL[Insertar con<br/>sanitario_id_resuelto = NULL]
OK --> VERDE[UI optimista → VERDE<br/>📤 Pendiente de sincronizar]
NULL --> VERDE
VERDE --> WIFI[Vuelve el wifi:<br/>NetworkCallback.onAvailable dispara sync]
WIFI --> DRENA[Cola se procesa →<br/>trabajo_sincronizado · 🟢 Conectado]
Flujo D: Cambio de operario en el device¶
Al cerrar sesión, la app invalida el refresh_token y limpia el SessionStore, pero NO limpia trabajo_pendiente: la cola del operario anterior se preserva y se sigue enviando con su operario_id. El nuevo operario no ve esos trabajos en SU historial (filtro local por operario_id actual).
Flujo F: Gestión NFC del ADMIN¶
El rol ADMIN no limpia: su pantalla principal es Gestión NFC. Apoya el celular sobre un tag, la app resuelve su estado y ofrece acciones.
stateDiagram-v2
[*] --> Lee: apoya celular sobre tag
Lee --> EN_STOCK
Lee --> ASIGNADO
Lee --> BAJA
Lee --> NO_REGISTRADO
EN_STOCK --> [*]: Alta en stock / Asignar a sanitario
ASIGNADO --> [*]: Desasignar / Reasignar
BAJA --> [*]: solo informativo (re-alta en BackOffice web)
NO_REGISTRADO --> [*]: Alta en stock
Las acciones son optimistas y encolan en nfc_evento_pendiente. Al sincronizar: OK → purga la cola; CONFLICTO_ASIGNACION → revierte el optimismo y avisa (si dos devices asignaron al mismo sanitario offline, gana el primero en sincronizar). Si el chip es ilegible, un snackbar avisa "tag sin URL WorkDone".
Otras pantallas¶
-
Historial: top 50 del operario actual, agrupado por día, con pull-to-refresh que dispara sync. Badges según estado: ✓ "Egreso normal", ⚠ "Cierre automático", 🔄 "Sincronizando...", ❓ "Tag no resuelto".

Card del historial: rango horario (08:15 – 08:42), sanitario (AEP-T-B12) y duración (27m 10s), badge "✓ Egreso normal" y los códigos de eventos registrados (PAPEL_AGOTADO · DISPENSER_ROTO). - Drawer (menú): Mi historial, Sincronizar ahora, Configuración, Cerrar sesión. - Configuración: versión de la app, servidor (read-only), rol actual, cantidad de eventos en cola (trabajos + gestión NFC), "Forzar sincronización".

Pantalla de Configuración: versión (0.1.0), servidor read-only, rol (OPERADOR_LIMPIEZA), eventos en cola (3), datos de sistema/batería/red/dispositivo y el botón "Forzar sincronización". - Mi ruta de hoy (v2.1): capa de planificación informativa con paradas ordenadas. Nunca bloquea el tap. - Registrar sin placa (v2.1): opción no prominente en el drawer para cuando la placa no lee. Elegir sanitario del catálogo → motivo obligatorio → flujo idéntico al tap (
modo_registro=MANUAL).
El flujo de tap NFC en detalle¶
Qué pasa exactamente cuando el operario apoya el teléfono sobre el tag:
flowchart TD
TAP[Operario apoya celular sobre el tag] --> READ["NfcReaderManager<br/>(enableReaderMode, foreground)"]
READ --> NDEF["Leer URL del NDEF<br/>Ndef.get(tag)"]
NDEF --> REGEX["Extraer UUID<br/>regex /t/([a-f0-9-]+)$"]
REGEX --> UUID["⚡ Generar client_uuid<br/>+ capturar Instant.now()"]
UUID --> ROOM["Persistir en Room<br/>ANTES de cualquier HTTP"]
ROOM --> EVAL["EvaluarTapUseCase<br/>¿INGRESO o EGRESO?"]
EVAL --> OPT["UI optimista<br/>(VERDE / pantalla eventos)"]
OPT --> SYNC["Dispatch sync"]
Detalles técnicos clave:
- Se usa
NfcAdapter.enableReaderMode()en foreground (más estable que intent filters durante uso continuo). Tech filter: solo NDEF / NDEF Formatable. ModoFLAG_READER_NFC_A(NTAG21x = Type 2). La URL del tag tiene la formahttps://workdone.lubeca.tech/t/{uuid}. - Idempotencia (CRÍTICO): al detectar el tap se genera
client_uuidconUUID.randomUUID()inmediatamente, y se persiste en Room antes de cualquier I/O HTTP. El UUID sobrevive aunque la app crashee, el device se reinicie o el sync falle 100 veces. Es lo único que garantiza que nada se pierda ni se duplique. - El timestamp del teléfono al momento del tap es la verdad (
ts_local_device). Se captura antes de procesar. - Nunca se bloquea el hilo principal: el I/O del NFC corre en
viewModelScopeconDispatchers.IO.
Una tarjeta de pago/acceso NO sirve como tag
El reader solo entiende tags NDEF con la URL WorkDone. Una contactless de pago hace ruido de lectura pero no pasa nada: no tiene la URL.
Comportamientos transversales del tap¶
- Ventana de silencio anti-rebote: tras procesar un tap del tag X, la app ignora lecturas del mismo tag unos segundos (config remota
silencio_cliente_seg, default 8) para evitar el doble-tap accidental. La lectura ignorada muestra un snackbar "Ya registrado hace un momento". Un tag distinto durante la ventana se procesa normal (baños contiguos es un caso real). - Confirmación de egreso corto: si un EGRESO resuelve con duración menor a un umbral (config remota
confirmar_egreso_min, default 2 min), aparece un diálogo "Llevás MM:SS — ¿terminás la limpieza?" con Terminar o Seguir limpiando. Atrapa el doble-tap al entrar. En ese punto nada se escribió aún.
Estados de un trabajo (ingreso → egreso → cierre automático)¶
Un trabajo nace con un INGRESO y se cierra de dos maneras: por un EGRESO explícito del operario, o por cierre automático del server cuando el operario tapeó otro sanitario sin haber cerrado el anterior.
stateDiagram-v2
[*] --> INGRESO: tap 1 (abre trabajo)
INGRESO --> EGRESO: tap 2 mismo sanitario + confirma eventos
INGRESO --> EGRESO_CIERRE_AUTOMATICO: tapea OTRO sanitario sin cerrar
EGRESO --> [*]
EGRESO_CIERRE_AUTOMATICO --> [*]
note right of EGRESO
Egreso normal (con eventos)
Badge ✓ en historial
end note
note right of EGRESO_CIERRE_AUTOMATICO
Cierre forzado por el server
Badge ⚠ en historial
end note
En el historial, los estados se distinguen por badge:
| Estado / modo | Badge | Significado |
|---|---|---|
EGRESO + ONLINE/OFFLINE_SYNC |
✓ Egreso normal | Cierre completo con eventos |
EGRESO_CIERRE_AUTOMATICO |
⚠ Cierre automático | El server lo cerró solo |
en trabajo_pendiente |
🔄 Sincronizando... | Todavía en cola |
OFFLINE_PENDIENTE_RESOLUCION |
❓ Tag no resuelto | Tag desconocido offline |
Cómo sincroniza (resumen)¶
La sincronización es el corazón del sistema y está manejada por SyncRepository + SyncWorker (WorkManager). Resumen de cara a la app:
- La app encola localmente cada trabajo y cada gestión NFC en Room (
trabajo_pendiente,nfc_evento_pendiente) y dispara un push por lotes aPOST /api/v1/app/sync. - El sync se dispara: inmediatamente tras cada tap, periódicamente vía WorkManager, y al recuperar red (
NetworkCallback.onAvailable). - El backend responde
deltas(catálogos),trabajos_procesadosynfc_eventos_procesados, y la app reconcilia el estado local (purga la cola, mueve atrabajo_sincronizado, aplica cierres automáticos). - La cola es persistente: los trabajos sobreviven a reinicios de la app, del device y a actualizaciones.
Detalle completo del protocolo
El contrato exacto del sync (request/response, estados, idempotencia, reconciliación) está en Sincronización. Para las entidades locales y del server, ver Modelo de datos.
Comportamientos no obvios¶
Esto es lo que el operario o soporte ve en la cancha y que parece magia si no conocés el porqué. Toques que se ignoran solos, diálogos que aparecen de la nada, círculos que cambian de color sin que nadie haga nada, logins que se traban. Nada de eso es un bug: cada uno tiene una razón. Acá están, una por una, con el archivo donde vive cada regla.
Ventana de silencio anti-rebote (lado cliente)¶
El MainViewModel guarda en memoria un mapa tag → timestamp del último tap (ultimoTapPorTag). Si el operario apoya el celular sobre el mismo tag de nuevo dentro de silencioClienteSeg (config remota silencio_cliente_seg, default 8s), la segunda lectura se descarta: emite un toast "Ya registrado hace un momento" y no crea nada (no hay INGRESO, no hay EGRESO, no toca disco).
val ultimo = ultimoTapPorTag[uuidTagLeido]
if (ultimo != null && ahora - ultimo < cfg.silencioClienteSeg * 1000L) {
_tapIgnorado.tryEmit(Unit) // toast "Ya registrado hace un momento"
return@launch
}
Clave: el silencio es por tag. Apoyar el celular sobre un tag distinto (baños contiguos, un caso real) se procesa normal — no hay silencio global.
Es una segunda red de seguridad, no la única
Esta ventana es el filtro del cliente. El server tiene su propio anti-rebote y puede devolver RECHAZADO_REBOTE (ver más abajo). Las dos capas trabajan juntas. El detalle de la idempotencia completa está en Sincronización.
Fuente: MainViewModel.kt:136-243.
Confirmación de egreso corto¶
Si entre el INGRESO y el EGRESO pasaron menos de confirmarEgresoMin (config remota confirmar_egreso_min, default 2 min), antes de cerrar aparece un diálogo "Llevás MM:SS — ¿terminás la limpieza?". Esto atrapa el doble-tap al entrar (el operario tapeó dos veces seguidas pensando que la primera no leyó).
| Botón | Qué hace |
|---|---|
| Terminar | Sigue al flujo normal de eventos (abrirSeleccionEventos) |
| Seguir limpiando | Cancela la evaluación, el trabajo sigue ABIERTO y no persiste nada |
En este punto nada se escribió todavía a la cola: el client_uuid del EGRESO sigue solo en memoria (cierrePendiente). "Seguir limpiando" simplemente lo descarta.
Fuente: MainViewModel.kt:315-342.
Reconciliación silenciosa del server¶
Un observer combina dos flows: el trabajo abierto del operario (espejo local del server, observeAbiertoDeOperario) + la cantidad de pendientes en cola (observeCount). Cada vez que cambian, llama a reconcileFromServer.
flowchart TD
OBS["Observer combine<br/>trabajo abierto local + count pendientes"] --> GUARD{¿cola vacía?<br/>pendienteCount == 0}
GUARD -->|No, hay pendientes| KEEP["No toca nada<br/>la UI optimista manda"]
GUARD -->|Sí, sync limpio| REC["reconcileFromServer ajusta UI"]
REC --> A["abierto=null + UI VERDE → GRIS<br/>(el server cerró el trabajo)"]
REC --> B["abierto!=null + UI GRIS → VERDE"]
REC --> C["server reinterpretó el sanitario<br/>→ repinta VERDE con el nuevo"]
Lo no obvio: solo reconcilia si la cola está vacía (if (pendienteCount > 0) return). Mientras haya taps sin sincronizar, la UI optimista manda y el server no la pisa. Y cuando sí reconcilia (ej. el server cerró el trabajo con EGRESO_CIERRE_AUTOMATICO), el cambio GRIS↔VERDE pasa sin avisarle al usuario — solo queda registrado en logs (Log.i(TAG, "Reconcile: ...")). El operario ve el círculo cambiar de color "solo".
Fuente: MainViewModel.kt:207-220,485-521. Para entender qué reinterpreta el server (cierres automáticos, etc.), ver Sincronización.
Bloqueo de login por PIN¶
Tras 5 intentos fallidos, el backend throttlea por usuario + device y devuelve HTTP 429 con header Retry-After (en segundos; default 600s = 10 min si el header no viene). La app mapea eso a AuthResult.Bloqueado(retry) y arranca un countdown en memoria (un Job en el LoginViewModel).
// AuthRepository.kt
429 -> {
val retry = response.headers()["Retry-After"]?.toIntOrNull() ?: DEFAULT_BLOQUEO_SEG // 600
AuthResult.Bloqueado(retry)
}
El bloqueo lo impone el server, no la app
El countdown del LoginViewModel (iniciarCountdownBloqueo) es solo visual y vive en memoria: si el operario mata la app y la reabre, el countdown se reinicia visualmente, pero el server sigue bloqueando hasta que pase su ventana real. No es un candado que se saltea cerrando la app.
Fuente: AuthRepository.kt:91-99, LoginViewModel.kt:106-119.
PIN vs password: lo decide el refresh_token¶
La app prefiere PIN (más rápido, 4 dígitos). La decisión de qué pedir depende del refresh_token guardado en el device:
flowchart TD
START["Login"] --> HAS{¿hay refresh_token<br/>en el device?}
HAS -->|Sí| PIN["Pide PIN (4 dígitos)<br/>modoPassword = false"]
HAS -->|No| PASS["Pide password (≥6)<br/>modoPassword = true"]
PIN -->|server responde 409<br/>PIN_NO_CONFIGURADO| AUTO["La pantalla cambia sola<br/>a modo password"]
- PIN: validación local
^\d{4}$(exactamente 4 dígitos). - Password: mínimo 6 caracteres (
MIN_PASSWORD = 6). - Si el operario intenta por PIN pero el server responde
409 PIN_NO_CONFIGURADO, la app cambia sola la pantalla a modo password (modoPassword = true, limpia el PIN tipeado) — sin que el operario tenga que tocar nada.
Fuente: LoginViewModel.kt:122-135 (auto-switch en aplicarResultado), flujo de Splash documentado arriba en Login.
Registro sin placa (contingencia manual)¶
Cuando la placa no lee (rota, ausente, ilegible), el operario tiene una salida desde el drawer: elige el sanitario del catálogo + un motivo obligatorio. El motivo es un catálogo fijo que no viaja por sync (MOTIVOS_CONTINGENCIA):

Pantalla "Registrar sin placa": buscador de sanitario y la lista del catálogo (AEP-T-B12 — Baño Hombres, AEP-T-B13 — Baño Mujeres). Tras elegir uno se pide el motivo obligatorio.
| Código | Cuándo |
|---|---|
PLACA_ROTA |
La placa está físicamente dañada |
PLACA_AUSENTE |
No hay placa pegada |
NO_LEE |
La placa está pero el celular no la lee |
OTRO |
Cualquier otro caso |
Lo no obvio: sigue exactamente el mismo flujo que un tap (INGRESO→VERDE / EGRESO→eventos), incluida la ventana de silencio anti-rebote, pero indexada por sanitario con la key manual:$sanitarioId en vez del UUID del tag. El trabajo viaja al server con manual=true y sin uuidTagLeido (cadena vacía, sin UUID de tag).
Si el motivo es inválido, el server devuelve MANUAL_INVALIDO — un rechazo terminal: se purga de la cola sin reintentar.
Fuente: RegistrarManualViewModel.kt, MainViewModel.kt:268-290, EvaluarTapUseCase.kt:74-76.
Device no registrado vs device revocado¶
Los dos llegan como HTTP 401 durante el sync, pero la app los trata de forma opuesta — y la diferencia importa muchísimo:
| Caso | Cómo lo detecta | Qué hace la app | ¿Reintenta? |
|---|---|---|---|
| No registrado | errorBody contiene "no registrado" |
Snackbar "contactá al admin"; marca retry y espera que el admin dé de alta el device | Sí (transitorio) |
| Revocado (robo) | errorBody contiene "DEVICE_REVOCADO" |
Señaliza el gate, bloquea la app y cancela todo el sync (periódico + oneshot) | No (terminal) |
// SyncRepository.kt
if (httpResponse.code() == 401 && errorBody?.contains("DEVICE_REVOCADO") == true) {
_deviceRevocado.tryEmit(Unit) // → bloquea la app, sin reintentos
return SyncOutcome.DeviceRevocado
}
if (errorBody?.contains("no registrado") == true) {
_deviceNoRegistrado.tryEmit(Unit) // → snackbar, reintenta
markRetry(pendientes, nfcPendientes, "device no registrado")
return SyncOutcome.DeviceNoRegistrado
}
DeviceRevocado es el escenario de robo: el admin revoca el device desde el BackOffice y la app se ladrillea sola en el próximo sync. DeviceNoRegistrado es benigno: en cuanto el admin lo da de alta, el siguiente sync arranca.
Fuente: SyncRepository.kt:134-146, SyncWorker.kt:79-80.
Reintentos del sync y rechazos terminales¶
El sync corre por WorkManager periódico cada 15 min (mínimo permitido), con backoff exponencial al fallar y constraint de conectividad. El SyncWorker mapea el resultado a un Result de WorkManager:
| Resultado del server | WorkManager Result |
|---|---|
Ok / NoSession |
success() |
NetworkError, HttpError 5xx, DeviceNoRegistrado |
retry() (backoff) |
HttpError 4xx, DeviceRevocado |
failure() |
Pero hay un nivel más fino: ciertos estados que el server devuelve por trabajo son rechazos terminales y la app los purga de la cola sin reintentar (reintentar daría siempre lo mismo):
SANITARIO_INACTIVO— el sanitario fue dado de bajaROL_NO_PERMITIDO— ej. un ADMIN intentando limpiarMANUAL_INVALIDO— registro sin placa con datos inválidosRECHAZADO_REBOTE— el server descartó un INGRESO rebote (anti doble-tap del lado server)
Fuente: SyncWorker.kt:76-84, SyncRepository.kt:309-333.
Migraciones Room (schema 1→5)¶
Las migraciones de Room son SQL explícito y nunca usan fallbackToDestructiveMigration — porque eso borraría la base, y con ella las colas locales sin sincronizar. La regla de oro que sigue cada migración:
| Tipo de tabla | Estrategia | Por qué |
|---|---|---|
Espejo (catálogos, trabajo_sincronizado) |
puede recrearse (DROP+CREATE) |
el server la repuebla por sync |
Cola local (trabajo_pendiente, nfc_evento_pendiente) |
debe sobrevivir (ALTER ADD) |
un operario puede actualizar la app con taps encolados sin sincronizar |
Ejemplo concreto: en MIGRATION_4_5 (registro sin placa) las dos columnas nuevas se suman con ALTER TABLE trabajo_pendiente ADD COLUMN ... justamente para no perder los [ts_local_device] y client_uuid de trabajos que el operario tapeó offline antes de actualizar.
Fuente: Migrations.kt (MIGRATION_1_2 a MIGRATION_4_5).
Riesgo conocido: la cola offline no tiene techo¶
LIMITACIÓN a evaluar: la cola trabajo_pendiente crece sin límite
La cola de trabajos pendientes (trabajo_pendiente) no tiene ni límite de tamaño ni envejecimiento (no hay TTL, ni cap de filas, ni purga por antigüedad). Mientras el sync drena bien, no se nota. Pero si un device nunca recupera la red (se pierde, se rompe la conectividad de forma permanente, queda en un sótano sin señal), cada tap sigue insertando filas en Room indefinidamente y la base crece sin techo.
Hoy lo único que saca filas de la cola es un sync exitoso (purga por client_uuid) o un rechazo terminal del server — ambos requieren llegar al server. Sin red, nada la achica. Es un riesgo real a evaluar (ej. cap + alerta, o purga por antigüedad con preservación de los más recientes), no un bug actual.
Fuente: la cola se llena en EvaluarTapUseCase / MainViewModel.onNfcTagRead; solo se purga en SyncRepository.procesar* tras tocar el server.