Saltar a contenido

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_uuid se 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_device es la verdad temporal del evento.
  • El device no define su identidad legible: numero_terminal y alias los 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 (PK client_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_uuid y 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:

  1. Toque en una carita → se genera client_uuid al instante, antes de cualquier I/O.
  2. 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.
  3. Si fue NEGATIVA y la terminal tiene mostrar_motivos activo → 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.
  4. "¡Gracias!" durante ~2 s (texto configurable por terminal).
  5. 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, sea TERMINAL_SMILEY y que el hash BCrypt coincida; falla cerrado si el filtro no corre.
  • El sanitario_id NO 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ó.

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:
    1. Info/diagnóstico: Terminal Nº, alias, sanitario, uuid corto, versión, última sync, opiniones pendientes en cola, últimas líneas de log.
    2. Conexión: URL actual + probar + editar (editar ⇒ S15: borra credenciales y vuelve a no-vinculada).
    3. Acciones: sincronizar ahora, reiniciar app.
    4. 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 por client_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 ConnectivityManagerWIFI / 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 false sin 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).