Kiosk Smiley¶
El kiosk Smiley es una tablet Android en modo kiosko montada a la salida de cada sanitario. Es la terminal de opinión del público: el visitante opina con un toque (3 caritas) y, si la opinión fue negativa, opcionalmente indica un motivo. Cierra el ciclo de calidad del ecosistema WorkDone: el equipo registra lo que hace (app móvil), la supervisión lo que controla, y Smiley lo que el público percibe.
Vive en el repo workdone-smiley-kiosk, que no tiene backend propio: es otro cliente del backend WorkDone, con los mismos patrones que la app móvil. Se autentica como Dispositivo (api_key), sin login de usuario. Para el protocolo de sync ver Sincronización; para las entidades, Modelo de datos.
Para qué sirve y la experiencia del visitante¶
El loop completo del módulo:
el visitante opina con un toque → el motor de tendencias detecta negativas acumuladas → acción correctiva (alerta + semáforo rojo) → el operario limpia (flujo NFC de siempre) → una limpieza válida resetea la tendencia → el sistema mide si las opiniones mejoraron.
La experiencia para el visitante es deliberadamente mínima: sale del sanitario, ve tres caritas grandes, toca una y listo. Sin texto que leer, sin login, sin pasos obligatorios.
| Decisión | Valor |
|---|---|
| Escala | 3 caritas: POSITIVA / NEUTRA / NEGATIVA. Un toque. |
| Motivos | Solo en negativas, segunda pantalla, opcional (catálogo: SUCIO, SIN_INSUMOS, MAL_OLOR, ALGO_ROTO, OTRO) |
| Hardware | Tablet Android en modo kiosko + soporte antivandálico |
| Idle | "Último servicio: hace X min" — sin nombre del operario |
| Targets táctiles | ≥ 20 mm (público general, de apuro, a veces con manos mojadas) |
Reglas de oro heredadas del ecosistema
client_uuidse genera al toque, antes de cualquier I/O. Es la identidad del evento.- Offline-first real: la terminal opera 100% sin red (solo el idle pierde frescura). Nada se pierde, nada se duplica (idempotencia por
client_uuid). ts_local_devicees la verdad temporal del evento.- El device no define su identidad legible:
numero_terminalyaliaslos asigna el backend al vincular; la app los muestra, no los edita.
Stack¶
- Kotlin + Jetpack Compose (mismas versiones/convenciones que la app móvil).
- Room mínimo:
opinion_pendiente(PKclient_uuid,OnConflictStrategy.IGNORE) +config_local. - WorkManager: sync periódico (15 min) + sync inmediato tras cada opinión + al recuperar red.
- Retrofit/Moshi contra
/api/v1/smiley/*. - DataStore para
api_key,device_uuidy datos de vinculación (api_key cifrada en reposo). - i18n desde el día 1: todo texto visible en
strings.xml(es-AR default). La terminal va a aeropuertos: EN/PT se agregan después sin tocar código.
El flujo de opinión paso a paso¶
La pantalla principal son las 3 caritas grandes. El visitante toca una y la terminal lo guía según el valor elegido.
flowchart TD
CARITAS[Pantalla principal:<br/>3 caritas grandes] -->|toque| UUID["⚡ Generar client_uuid<br/>(antes de cualquier I/O)"]
UUID --> FB["Feedback S9:<br/>otras caritas se ocultan,<br/>la elegida queda con glow<br/>+ sonido opcional"]
FB --> NEG{¿NEGATIVA y<br/>mostrar_motivos?}
NEG -->|No| GRACIAS
NEG -->|Sí| MOTIVOS["Pantalla de motivos<br/>(botones grandes, 'omitir',<br/>timeout 10s = sin motivo)"]
MOTIVOS --> GRACIAS["'¡Gracias!' ~2s<br/>(texto config S12)"]
GRACIAS --> CARITAS
Paso a paso:
- Toque en una carita → se genera
client_uuidal instante, antes de cualquier I/O. - Feedback inmediato: las otras dos caritas se ocultan, la elegida queda resaltada con un glow de su color, y suena una confirmación opcional (flag por terminal). Esto es herencia validada del Smiley 2019.
- Si fue NEGATIVA y la terminal tiene
mostrar_motivosactivo → segunda pantalla con los motivos (botones grandes). Hay un botón "omitir" y un timeout de 10 s que registra la opinión sin motivo. Opinar sin motivo también vale. - "¡Gracias!" durante ~2 s (texto configurable por terminal).
- Vuelve solo a la pantalla principal.
Lockout anti-rebote
Tras cada opinión hay un lockout de 3 s (debounce client-side; el server igual re-depura por debounce y colapso de ráfaga). Evita el doble-toque y el spam.
Pantalla de reposo (idle)¶
Entre opiniones, la terminal muestra la pantalla de reposo, pensada como invitación a opinar y como señal de vida del sistema:
- Invitación a opinar (las caritas).
- "Último servicio: hace X min" — viene del estado liviano del backend (
GET /api/v1/smiley/estado). Si no hay dato o es viejo, la línea se oculta: nunca se le muestra info dudosa al público. Sin nombre del operario (decisión S6). - Logo del cliente final (configurable por empresa/terminal, asset distribuido por config remota) + marca Lubeca/WorkDone discreta abajo.
- Línea de diagnóstico al pie (S11), en tipografía mínima y permanente:
Terminal Nº · alias · última sync · versión. Permite al técnico diagnosticar de un vistazo sin tocar nada. Configurable on/off.
El idle se refresca con un poll de GET /api/v1/smiley/estado cada 5 min en foreground; el resto del estado y la config bajan en el sync.
stateDiagram-v2
[*] --> Idle
Idle --> Caritas: toque / interacción
Caritas --> Feedback: opina
Feedback --> Motivos: NEGATIVA + mostrar_motivos
Feedback --> Gracias: positiva/neutra o sin motivos
Motivos --> Gracias
Gracias --> Idle: ~2s
note right of Idle
"Último servicio: hace X min"
Logo cliente + línea diagnóstico
end note
Modo kiosko (Lock Task) y por qué¶
La tablet va montada en pared, a la salida de un sanitario, en un espacio público. Tiene que comportarse como un electrodoméstico: encenderse sola, no dejar salir de la app, y recuperarse de cualquier caída sin que nadie la toque.
- Lock Task / dedicated launcher: la app queda confinada, sin barras de sistema. El visitante no puede salir a la home ni abrir otras apps.
- Pantalla siempre encendida y orientación bloqueada en horizontal (
sensorLandscape): va en pared, nunca rota a vertical. - Autoarranque al boot + watchdog de reinicio con backoff anti-boot-loop: si la app crashea, se reinicia sola; el watchdog despierta el device (
RTC_WAKEUP). - Brillo programable por franja y logging local rotativo.
Banner 'Terminal no asegurada'
Si la tablet no tiene device-owner (no está realmente confinada con Lock Task), aparece un banner "Terminal no asegurada". En una tablet con device-owner en estado LOCKED el banner no aparece. En builds debug/DEMO_MODE nunca aparece (no es un kiosko real).
Provisioning y autenticación como dispositivo¶
La terminal no tiene usuario: se autentica como Dispositivo de tipo TERMINAL_SMILEY, vinculado a un único sanitario. La identidad la asigna el backend.
flowchart TD
ADMIN[Admin crea la terminal en BackOffice<br/>elige sanitario] --> COD[Backend genera código de<br/>vinculación corto, un solo uso, con vencimiento]
COD --> SETUP[En la tablet: pantalla de setup directa]
SETUP --> URL[URL del server<br/>+ 'Probar conexión' GET /smiley/ping]
URL --> ING[Ingresa el código de vinculación]
ING --> VINC["POST /api/v1/smiley/vincular {codigo}"]
VINC --> RESP[Backend responde device_uuid + api_key<br/>+ datos del sanitario + PIN-hash<br/>y consume el código]
RESP --> OP[Terminal operando]
Detalles del modelo de autenticación:
- El sync usa headers
X-Device-Uuid+X-Api-Key. Un filtro server-side (SmileyApiKeyAuthFilter) valida que el dispositivo exista, esté activo, seaTERMINAL_SMILEYy que el hash BCrypt coincida; falla cerrado si el filtro no corre. - El
sanitario_idNO viaja en el body: lo determina el server por el vínculo del device. Una terminal no puede opinar por otro sanitario. - Cambio de URL ⇒ re-vincular (S15): editar la URL del server borra las credenciales y vuelve la terminal al estado no-vinculado (exige código nuevo). La api_key jamás viaja a un server distinto del que la emitió.
Menú técnico oculto¶
La pantalla oculta de info/diagnóstico del dispositivo (absorbe G5-kiosk) vive dentro del menú técnico:
- Acceso: 10 toques sobre el logo Lubeca (máx. 2,5 s entre toques) → PIN técnico (hash local, verifica offline; 3 PINs errados = bloqueo 5 min). El PIN se gestiona en el BackOffice, baja a la terminal al vincular y se refresca en cada sync. Nunca hay un PIN fijo compilado en el APK.
- Alcance: SOLO bootstrap + diagnóstico. Jamás configuración funcional (textos, sonido, logo, reglas: todo eso es remoto vía BackOffice).
- Secciones:
- Info/diagnóstico: Terminal Nº, alias, sanitario, uuid corto, versión, última sync, opiniones pendientes en cola, últimas líneas de log.
- Conexión: URL actual + probar + editar (editar ⇒ S15: borra credenciales y vuelve a no-vinculada).
- Acciones: sincronizar ahora, reiniciar app.
- Re-vinculación con código nuevo.
Cola offline y sync¶
La terminal opera 100% sin conexión: solo el idle pierde frescura.
- Cada opinión se encola en
opinion_pendiente(idempotente porclient_uuid). - El sync (
POST /api/v1/smiley/sync) se dispara tras cada opinión, por WorkManager cada 15 min y al recuperar red. - El backend depura anti-abuso (debounce <3 s, colapso de ráfaga) y responde el estado por opinión + el catálogo de motivos + el bloque de config (textos, sonido, logo,
ultima_limpieza_hace_min).
Detalle del contrato
El protocolo de sync del kiosk (/api/v1/smiley/*) está documentado en Sincronización. Las entidades (Opinion, MotivoOpinion, Dispositivo) en Modelo de datos.
Alcance del repo¶
Según V21_SMILEY.md, a este repo (workdone-smiley-kiosk) le tocan únicamente:
| Tarea | Qué cubre | Estado |
|---|---|---|
| D1 Scaffold | Compose, Room mínimo, Retrofit, WorkManager, DataStore, modo kiosko | ✅ |
| D2 Provisioning + menú técnico (S13-S15) | Setup directo, vinculación por código, menú técnico con PIN offline | ✅ |
| D3 Flujo de opinión | 3 caritas, feedback, motivos, "¡Gracias!", lockout 3 s | ✅ |
| D4 Idle + offline | Idle "último servicio", logo cliente, línea diagnóstico, cola + sync | ✅ |
| D5 Robustez kiosk | Autoarranque, watchdog, brillo por franja, logging rotativo | ✅ |
| G5-kiosk | Pantalla de info del dispositivo (absorbida en el menú técnico de D2) | ✅ |
El resto del documento (fases A-C backend, E BackOffice, F portal, G telemetría, H cierre) es contexto: se ejecuta en workdone-backend o el BackOffice, no en este repo. Las fases backend A-B deben estar verdes antes de implementar D2 en adelante (el provisioning y el sync necesitan los endpoints reales).
Fuente de verdad del contrato
Los contratos REST viven en workdone-backend/docs/. El kiosk tiene una copia gobernada de solo lectura; si algo no coincide con el backend real, gana el backend — se reporta, no se "arregla" en el kiosk.
Robustez y operación¶
El kiosk corre 24/7 en una tablet montada en pared, sin supervisión: nadie la mira, nadie la reinicia, nadie nota si dejó de andar. Estos mecanismos son los que la mantienen viva (que se recupere sola) y observable (que sepamos cómo está sin estar al lado). Para el contexto de sync ver Sincronización; para cómo se cruza con jobs y alertas del ecosistema, Jobs y alertas.
Watchdog anti-boot-loop¶
Ante un crash no capturado, la app deja rastro en el log y programa su propio reinicio con un backoff escalado, para que un fallo aislado se recupere en segundos pero un boot-loop no parpadee sin fin:
| Crashes consecutivos | Delay de reintento |
|---|---|
| 1–2 | 1,5 s |
| 3–5 | 15 s |
| 6–10 | 60 s |
| 11+ | 5 min |
El contador se resetea a 0 tras un arranque estable de 60 s (MainActivity.kt:86-88 lo dispara cuando la terminal lleva ese tiempo en STARTED). La alarma usa AlarmManager.RTC_WAKEUP con setAndAllowWhileIdle: tras un crash no hay activity que mantenga la pantalla encendida, así que el device podría dormir y un RTC normal no lo despertaría. El contador persiste en SharedPreferences device-protected (accesible pre-unlock y persistente entre reinicios).
Lógica en WatchdogBackoff.kt y SmileyKioskApp.kt:60-91.
Best-effort por diseño
El contador es best-effort: si el storage device-protected falla, igual se reinicia con un delay seguro. Lo crítico es que la alarma se programe SIEMPRE — por eso cada paso va envuelto en runCatching.
Telemetría en cada sync¶
Cada ciclo de sync adjunta telemetría del device que el backend persiste para el tablero de equipos. Todos los campos son opcionales: un null no rompe el contrato.
| Campo | Origen | Ejemplo |
|---|---|---|
appVersion |
BuildConfig.VERSION_NAME |
1.4.0 |
osVersion |
Build.VERSION.RELEASE |
Android 13 |
bateriaPct |
BatteryManager (0–100, si no null) |
87 |
redTipo |
ConnectivityManager → WIFI / CELULAR / ETHERNET / OTRA / SIN_RED |
WIFI |
lockTaskActivo |
estado Lock Task (LOCKED = true) |
true |
redTipo nunca es null: ante un ConnectivityManager ausente cae a SIN_RED, para que el panel sea consistente entre operarios y kiosk. Ver Telemetria.kt, AndroidTelemetriaProvider.kt:22-54 y el armado del request en SyncManager.kt:38-49.
Brillo por franja horaria¶
La terminal está siempre encendida; de noche baja el brillo para no molestar y cuidar el panel.
| Franja | Horario | Brillo |
|---|---|---|
| Día | 07:00–22:00 | 100% (1.0f) |
| Noche | 22:00–07:00 | 35% (0.35f) |
Se re-evalúa cada 15 min mientras la activity está STARTED (MainActivity.kt:116-121), para cruzar la frontera día/noche sin reiniciar. Definido en FranjaBrillo.kt.
Hardcodeado
Los valores de brillo y las horas de corte están fijos en el código, no bajan por config remota. Cambiarlos hoy requiere un build nuevo. Está marcado en el código como "configurable a futuro", pero a la fecha no lo es.
Refresco liviano del idle¶
El "Último servicio: hace X min" del idle se refresca con un GET /api/v1/smiley/estado cada 5 min, solo en foreground (Lifecycle.State.STARTED, no corre en background). Está deliberadamente separado del sync de opiniones (15 min): es un poll barato que no encola nada y solo actualiza ultima_limpieza_hace_min.
Tres casos (SyncManager.kt:116-133):
- server con valor → lo cachea y la línea se muestra;
- server manda
null("no hay limpieza") → cachea vacío y la línea se oculta — nunca se le muestra info dudosa al público; - fallo de red/excepción → devuelve
falsesin tocar el último valor conocido.
Logging local rotativo¶
Para diagnosticar la terminal en el lugar, sin red y sin libs: escribe a kiosk.log en filesDir/logs y rota cuando supera el tope, conservando 3 archivos de 512 KB (kiosk.log, kiosk.log.1, kiosk.log.2, descartando el más viejo). Las últimas líneas se muestran en la sección Info del menú técnico. Implementado en RotatingLogger.kt:10-37.
Reintentos del sync¶
El SyncWorker corre sobre WorkManager: Ok y NoVinculado cierran el trabajo; SinRed y Error devuelven Result.retry(). El backoff de reintento es lineal de 30 s (SyncWorker.kt:27).
Cambio de URL del server (menú técnico)¶
Editar la URL del server desde el menú técnico borra las credenciales y vuelve la terminal al estado no-vinculado (exige código nuevo). El orden importa: cambiarServerUrl borra el cache de credenciales del interceptor de forma síncrona (limpiarCredencialesRuntime()) antes de fijar la nueva URL, porque el colector de DataStore es async y podría llegar tarde. Si no, un sync de fondo podría estampar la api_key vieja contra el server nuevo (SmileyRepository.kt:99-106).
Regla de oro #5
La api_key jamás viaja a un server distinto del que la emitió. Por eso cambiar la URL fuerza re-vinculación.
Códigos de error de vinculación¶
El POST /smiley/vincular puede rechazar el código por varias razones; la UI los colapsa a un único estado VinculacionResultado.CodigoInvalido para mostrar un mensaje claro ("código inválido/expirado/usado"). Mapeo en SmileyRepository.kt:120 (CODIGOS_RECHAZO):
| HTTP | Significado típico |
|---|---|
400 |
Código mal formado |
404 |
Código inexistente |
409 |
Código ya usado |
410 |
Código expirado |
Cualquier otro código HTTP cae a Error (reintentar), y un fallo de red a SinRed.
Limitación real: la cola offline no tiene tope ni envejecimiento
La cola de opiniones offline (opinion_pendiente en Room) se drena solo cuando el sync confirma cada opinión. Hoy no tiene límite de tamaño ni política de envejecimiento: si la red nunca vuelve, las opiniones se acumulan indefinidamente en la base local. En una terminal sana esto no se nota, pero ante una caída de red prolongada es un riesgo de crecimiento sin tope a evaluar (cap por tamaño, TTL, o descarte de las más viejas).