Práctica 12 · 180 minutos

Charla: un chat que vive en tu computadora

Un chat en tiempo real sin depender de nadie: tu servidor de FastAPI con WebSocket corre en Docker, un túnel lo saca a internet, y la app invita con un QR. Quien lo escanea pide entrar, tú lo aceptas, y platican. Después, lo difícil: que la conexión sobreviva cuando la red falla, y pruebas que lo demuestren.

180 min en clase Nivel IA: tutor FastAPI · WebSocket · Docker OkHttp WebSocket · QR con ZXing y ML Kit Tu servidor, tu sala Pruebas en la JVM, sin emulador

Prerrequisitos. Docker Desktop instalado y corriendo: es el de los anexos de las Prácticas 6 y 10. Si nunca lo levantaste, el anexo de imágenes con Docker trae los dos problemas que más se repiten en Windows. De la app: la Práctica 8 (una conexión que se queda abierta), la 9 (Hilt), la 10 (el selector de fotos) y la 11 (pruebas).

adb. Si al escribir adb te dice que no se reconoce, el anexo de adb para Windows, macOS y Linux lo deja listo en diez minutos.

Dos repositorios. Uno es tu servidor, que casi no trae nada porque lo construyes tú. El otro es la app, con las pantallas ya dibujadas. Clónalos uno junto al otro:

git clone https://github.com/rpversontec/practica-12-charla-servidor.git
git clone https://github.com/rpversontec/practica-12-charla.git

Relación con el reto. Tu reto también va a necesitar un servidor propio que hable en tiempo real. Lo que se construye hoy (FastAPI en Docker, un WebSocket con permisos, un túnel, una invitación) es el molde. Al final de la guía, Para tu reto dice qué piezas se llevan tal cual.

Cómo trabajar

Un commit en cada checkpoint, en el repo que toque: los de la Parte A en el del servidor, los de los Bloques B a D en el de la app. git add -A ; git commit -m "checkpoint a4". Si algo se rompe sin arreglo, git restore . te regresa al último bueno.

Lo que se rompe a propósito va en una rama. El D4 quita una línea para ver una prueba en rojo: eso se hace en experimento-guardia, con commit en esa rama antes de volver a main.

En parejas para el Bloque C. Cada quien construye lo suyo, pero para entrar a una sala hace falta otra. Si trabajas solo, tu propia sala sirve: entras a ella por el túnel como si fueras alguien más.

Tu sala · anfitrión
Charla
Los mensajes, quién toca la puerta y el estado de la conexión. Entras con tu clave.
tocas el QR → tu servidor te dice su dirección pública
Hoja de invitación
Tu QR y tu enlace
Para que alguien más encuentre tu sala.
tu compañero lo escanea, lo lee de una captura, o pega el enlace
Su pantalla
Entrar a otra sala
Escribe su nickname y pide entrar. Espera.
tú tocas Aceptar → el servidor le da un token
Su sala · invitado
Charla
La misma sala, desde su teléfono. «Volver a mi sala» lo regresa a la suya.

Cuando termines, esto tiene que ser cierto

  1. Tu servidor corre en Docker, con su túnel, y sus 8 pruebas pasan dentro del contenedor.
  2. Tu app entra a tu sala como anfitrión, invita con un QR y acepta a quien pide entrar.
  3. Tu app entra a la sala de alguien más por cualquiera de las tres vías, y «Volver a mi sala» te regresa.
  4. Si apagas el servidor, la app dice «Reconectando en N s», y vuelve sola cuando lo prendes.
  5. 15 pruebas locales en verde, y una de ellas atrapa el bug del D1 cuando quitas el guardia.

Parte 0 · 10 minAntes del código

P0.1 — Tres formas de enterarse5 min

Un chat necesita que el mensaje de alguien más llegue solo, sin que tú preguntes. Ya conoces dos formas, y hoy aparece la tercera:

Cómo funciona Lo usaste en Para un chat
Preguntar cada N segundos Una petición HTTP normal, repetida Práctica 4 (el botón de recargar) Llega tarde, y gasta batería preguntando cuando no hay nada
SSE Una respuesta HTTP que nunca termina: el servidor va soltando eventos Práctica 8 (los avisos en vivo) Solo en un sentido. Para mandar, otra petición
WebSocket Empieza como HTTP y pide «Upgrade». Desde ahí, un canal abierto en los dos sentidos Hoy Lo que dice cualquiera le llega a todos, al instante

Una diferencia práctica que importa hoy: los túneles rápidos de Cloudflare no dejan pasar SSE (lo viste en la Práctica 8), pero sí WebSocket. Por eso tu sala va a poder salir a internet.

P0.2 — Las piezas5 min

  tu teléfono (anfitrión)                    el teléfono de tu compañero (invitado)
          │                                                  │
          │ ws://10.0.2.2:8000/chat              wss://algo.trycloudflare.com/chat
          │ + tu clave                           + su token
          ▼                                                  ▼
  ┌────────────────────────── tu computadora · Docker ─────────────────────────┐
  │   api  ── FastAPI + SQLite: la sala ────────── tunel  ── cloudflared ──────┼──▶ internet
  │          /chat (WebSocket)  /solicitudes  /invitacion                       │
  └─────────────────────────────────────────────────────────────────────────────┘

Tú entras directo, con la clave de anfitrión que solo tú conoces. Los demás entran por el túnel, y no tienen clave: piden entrar por HTTP, tú los aceptas, y el servidor les da un token para el WebSocket.

Ejercicio 0 ¿HTTP o WebSocket? en parejas · 3 min

Para cada acción, decidan por dónde viaja y por qué: pedir entrar, aceptar a alguien, mandar un mensaje, preguntar la dirección del túnel.

Respuesta

Pedir entrar va por HTTP: quien pide todavía no tiene token, así que no puede abrir el WebSocket. Aceptar va por el WebSocket del anfitrión, que ya está abierto y autenticado. Los mensajes, por WebSocket: es lo que tiene que llegarle a todos al instante. La dirección del túnel, por HTTP: es una pregunta con una respuesta, y se hace una vez.


Parte A · 55 minTu servidor

Todo en el repo practica-12-charla-servidor. Ábrelo en VS Code (o en el editor que uses para Python): Android Studio no es para esta parte.

A1 — El contenedor12 min

El repo trae las dependencias ya fijadas (pyproject.toml y uv.lock), la plantilla del .env y una consola para probar. Lo primero es tu clave:

cp .env.example .env

Abre .env y cambia cambia-esta-clave por una tuya. Es la contraseña de tu sala: quien la tenga entra como anfitrión.

Crea estos tres archivos. Los dos primeros son la receta de la imagen y cómo se arman los contenedores:

Dockerfile
# La imagen del servidor: Python 3.12 y las versiones exactas de uv.lock.
FROM python:3.12-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:0.12.9 /uv /bin/uv

WORKDIR /code
ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    PYTHONUNBUFFERED=1

# Primero las dependencias: si solo cambia tu código, Docker reutiliza esta capa
# y reconstruir tarda segundos, no minutos. Incluye pytest: las pruebas corren aquí dentro.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

COPY app ./app

EXPOSE 8000
CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker-compose.yml
# La sala completa:
#   docker compose up -d --build     el servidor (http://localhost:8000) y el túnel
#   docker compose logs -f api       lo que va pasando en la sala
services:
  api:
    build: .
    env_file: .env
    # --reload + los volúmenes: editas app/ en tu máquina y el servidor se reinicia solo.
    command: ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
    volumes:
      - ./app:/code/app
      - ./tests:/code/tests
      - ./herramientas:/code/herramientas
      - datos:/datos
    # Solo en 127.0.0.1: tu máquina y el emulador (10.0.2.2) llegan; el resto del wifi, no.
    # Tus compañeros entran por el túnel.
    ports:
      - "127.0.0.1:8000:8000"

  tunel:
    image: cloudflare/cloudflared:2026.9.3
    # Una dirección https://….trycloudflare.com que llega a `api` desde cualquier red.
    # http2 porque muchas redes bloquean QUIC (UDP). --metrics publica /quicktunnel,
    # donde la API lee esa dirección para el QR.
    command: ["tunnel", "--no-autoupdate", "--protocol", "http2", "--metrics", "0.0.0.0:2000", "--url", "http://api:8000"]
    depends_on:
      - api

volumes:
  # La base de la sala. Sobrevive a `docker compose down`; `down -v` la borra.
  datos:

Y el servidor, por ahora con una sola ruta, en una carpeta app nueva:

app/main.py
"""Charla: el servidor de UNA sala de chat, la tuya. Corre en tu computadora."""
from fastapi import FastAPI

app = FastAPI(title="Charla")


@app.get("/salud")
def salud():
    return {"ok": True}

Levántalo. La primera vez tarda un par de minutos (baja Python, uv y cloudflared):

docker compose up -d --build

Abre http://localhost:8000/salud en el navegador: {"ok":true}.

Lo que hace el compose:

  • Dos servicios. api es tu servidor; tunel lo saca a internet. Dentro de la red de Docker se encuentran por su nombre: el túnel apunta a http://api:8000.
  • --reload y los volúmenes de ./app, ./tests y ./herramientas. Editas un .py en tu máquina y el servidor se reinicia solo, en un segundo. No hay que reconstruir.
  • 127.0.0.1:8000. Tu máquina y tu emulador (10.0.2.2) llegan; el resto del wifi, no. Tus compañeros entran por el túnel, que es HTTPS.
  • El volumen datos. Ahí va a vivir la base de la sala. Sobrevive a docker compose down.

A2 — Lo que la sala recuerda8 min

app/datos.py
"""Lo que la sala recuerda, en un archivo SQLite.

SQLite viene dentro de Python: no hay otro contenedor ni contraseña. El archivo
vive en /datos, un volumen de Docker, así que sobrevive a `docker compose down`
y a cada recarga de --reload. Sin él, cada vez que guardas un .py se borraría
la sala entera: mensajes, invitados y sus tokens.
"""
import os
import secrets
import sqlite3
import time


class Datos:
    def __init__(self, ruta: str):
        # Un solo hilo atiende todo (el del servidor), pero las pruebas lo usan
        # desde otro: check_same_thread=False lo permite.
        self.con = sqlite3.connect(ruta, check_same_thread=False)
        self.con.row_factory = sqlite3.Row
        self.con.executescript(
            """
            CREATE TABLE IF NOT EXISTS solicitudes (
                id       TEXT PRIMARY KEY,
                nickname TEXT NOT NULL,
                estado   TEXT NOT NULL,   -- pendiente | aprobada | rechazada
                token    TEXT UNIQUE      -- solo cuando se aprueba
            );
            CREATE TABLE IF NOT EXISTS mensajes (
                id    INTEGER PRIMARY KEY AUTOINCREMENT,
                de    TEXT NOT NULL,
                texto TEXT NOT NULL,
                en    REAL NOT NULL       -- segundos desde 1970, como time.time()
            );
            """
        )

    # ---------- solicitudes ----------

    def nickname_ocupado(self, nickname: str) -> bool:
        """Ocupado = alguien lo pidió y no lo rechazaron."""
        fila = self.con.execute(
            "SELECT 1 FROM solicitudes WHERE lower(nickname) = lower(?) AND estado != 'rechazada'",
            (nickname,),
        ).fetchone()
        return fila is not None

    def crear_solicitud(self, nickname: str) -> dict:
        sid = secrets.token_urlsafe(8)
        with self.con:
            self.con.execute(
                "INSERT INTO solicitudes (id, nickname, estado) VALUES (?, ?, 'pendiente')",
                (sid, nickname),
            )
        return self.solicitud(sid)

    def solicitud(self, sid: str) -> dict | None:
        fila = self.con.execute("SELECT * FROM solicitudes WHERE id = ?", (sid,)).fetchone()
        return dict(fila) if fila else None

    def pendientes(self) -> list[dict]:
        filas = self.con.execute(
            "SELECT id, nickname FROM solicitudes WHERE estado = 'pendiente' ORDER BY rowid"
        ).fetchall()
        return [dict(f) for f in filas]

    def resolver(self, sid: str, aprobar: bool) -> dict | None:
        """Aprueba (y genera el token) o rechaza. Solo una vez: lo resuelto no cambia."""
        s = self.solicitud(sid)
        if s is None or s["estado"] != "pendiente":
            return None
        with self.con:
            if aprobar:
                self.con.execute(
                    "UPDATE solicitudes SET estado = 'aprobada', token = ? WHERE id = ?",
                    (secrets.token_urlsafe(24), sid),
                )
            else:
                self.con.execute("UPDATE solicitudes SET estado = 'rechazada' WHERE id = ?", (sid,))
        return self.solicitud(sid)

    def nickname_de(self, token: str) -> str | None:
        fila = self.con.execute(
            "SELECT nickname FROM solicitudes WHERE token = ? AND estado = 'aprobada'", (token,)
        ).fetchone()
        return fila["nickname"] if fila else None

    # ---------- mensajes ----------

    def guardar_mensaje(self, de: str, texto: str) -> dict:
        with self.con:
            cursor = self.con.execute(
                "INSERT INTO mensajes (de, texto, en) VALUES (?, ?, ?)", (de, texto, time.time())
            )
        fila = self.con.execute("SELECT * FROM mensajes WHERE id = ?", (cursor.lastrowid,)).fetchone()
        return dict(fila)

    def ultimos_mensajes(self, cuantos: int = 50) -> list[dict]:
        filas = self.con.execute(
            "SELECT * FROM mensajes ORDER BY id DESC LIMIT ?", (cuantos,)
        ).fetchall()
        return [dict(f) for f in reversed(filas)]


# La base de toda la app. Las pruebas la cambian por una en memoria.
db = Datos(os.environ.get("RUTA_DB", "/datos/charla.db"))

SQLite, no PostgreSQL. Viene dentro de Python: un archivo y listo, sin otro contenedor ni contraseña. Para una sala de chat sobra.

Dos tablas. solicitudes guarda quién pidió entrar y qué decidió el anfitrión; cuando aprueba, ahí nace el token de esa persona. mensajes guarda la historia, y su id autoincremental la ordena.

secrets.token_urlsafe, no random: un token adivinable es una puerta abierta. Es el mismo módulo que usaste para los secretos en la Práctica 6.

db al final del archivo. Toda la app usa esa instancia. En el A6, las pruebas la cambian por una en memoria.

A3 — La sala12 min

app/sala.py
"""La sala: un WebSocket por persona conectada, y lo que dice uno le llega a todos.

HTTP es «pregunto y me contestan». Un WebSocket se queda abierto en los dos
sentidos: el servidor puede hablar sin que nadie le pregunte. Eso es lo que
necesita un chat.
"""
import os
import secrets

from fastapi import APIRouter, WebSocket, WebSocketDisconnect

from app import datos

router = APIRouter()

# Quien se conecta con esta clave es el dueño de la sala. Viene del .env.
CLAVE_ANFITRION = os.environ["CLAVE_ANFITRION"]
ANFITRION = "anfitrión"

# Quién está conectado ahora mismo. Vive en memoria: una conexión no se puede guardar en disco.
conectados: dict[WebSocket, str] = {}


def quien_es(ws: WebSocket) -> str | None:
    """El nickname de quien abre la conexión, por su token. None = no tiene permiso."""
    token = ws.headers.get("authorization", "").removeprefix("Bearer ").strip()
    if not token:
        return None
    # compare_digest tarda lo mismo aunque falle en la primera letra: no regala pistas.
    if secrets.compare_digest(token, CLAVE_ANFITRION):
        return ANFITRION
    return datos.db.nickname_de(token)


async def difundir(evento: dict) -> None:
    """A todos los conectados. Si uno ya se fue, se le quita de la lista."""
    for ws in list(conectados):
        try:
            await ws.send_json(evento)
        except Exception:
            conectados.pop(ws, None)


async def avisar_al_anfitrion(evento: dict) -> None:
    for ws, quien in list(conectados.items()):
        if quien == ANFITRION:
            await ws.send_json(evento)


@router.websocket("/chat")
async def chat(ws: WebSocket):
    yo = quien_es(ws)
    if yo is None:
        # Cerrar ANTES de aceptar: el cliente recibe un 403 y no llega a entrar.
        await ws.close(code=1008)
        return
    await ws.accept()
    conectados[ws] = yo
    await ws.send_json({
        "tipo": "bienvenida",
        "yo": yo,
        "mensajes": datos.db.ultimos_mensajes(),
        "solicitudes": datos.db.pendientes() if yo == ANFITRION else [],
    })
    try:
        while True:
            orden = await ws.receive_json()
            await atender(yo, orden)
    except WebSocketDisconnect:
        pass
    finally:
        conectados.pop(ws, None)


async def atender(yo: str, orden: dict) -> None:
    tipo = orden.get("tipo")
    if tipo == "mensaje":
        texto = str(orden.get("texto", "")).strip()[:500]
        if texto:
            await difundir({"tipo": "mensaje", **datos.db.guardar_mensaje(yo, texto)})
    elif tipo in ("aprobar", "rechazar") and yo == ANFITRION:
        s = datos.db.resolver(str(orden.get("id", "")), aprobar=tipo == "aprobar")
        if s and s["estado"] == "aprobada":
            aviso = datos.db.guardar_mensaje("sala", f"{s['nickname']} entró a la sala")
            await difundir({"tipo": "mensaje", **aviso})

Y en main.py, la sala se conecta a la app:

app/main.py
"""Charla: el servidor de UNA sala de chat, la tuya. Corre en tu computadora."""
from fastapi import FastAPI

from app import sala

app = FastAPI(title="Charla")
app.include_router(sala.router)


@app.get("/salud")
def salud():
    return {"ok": True}

Quién eres, antes de aceptarte. quien_es lee el encabezado Authorization: Bearer …, el mismo de la Práctica 6. Con la clave del .env eres el anfitrión; con un token aprobado, eres quien lo pidió. Si no, close(code=1008) antes de accept(): la conexión nunca se abre y el cliente recibe un 403.

conectados vive en memoria, y es lo único que no va a SQLite: una conexión abierta no se puede guardar en un archivo. Cuando alguien se va, WebSocketDisconnect lo saca de la lista.

El servidor no le contesta al que habla: le avisa a todos. difundir manda el mensaje a cada conectado, incluido quien lo escribió. Así todos ven lo mismo, en el mismo orden.

Pruébala. En una terminal, dentro del repo:

docker compose exec api uv run --no-sync python herramientas/consola.py anfitrion

Escribe algo y da Enter: te regresa como anfitrión: …. Es tu mensaje, de ida y vuelta por el WebSocket. Ctrl+C para salir.

A4 — Pedir entrar10 min

app/solicitudes.py
"""Pedir entrar a la sala. Es HTTP normal: quien pide todavía no tiene token para el WebSocket."""
from typing import Annotated

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, StringConstraints

from app import datos, sala

router = APIRouter(prefix="/solicitudes", tags=["solicitudes"])

Nickname = Annotated[str, StringConstraints(strip_whitespace=True, min_length=2, max_length=20)]
RESERVADOS = {"anfitrión", "anfitrion", "sala"}


class NuevaSolicitud(BaseModel):
    nickname: Nickname


@router.post("", status_code=201)
async def solicitar(nueva: NuevaSolicitud):
    if nueva.nickname.lower() in RESERVADOS or datos.db.nickname_ocupado(nueva.nickname):
        raise HTTPException(409, "Ese nickname ya está en la sala")
    s = datos.db.crear_solicitud(nueva.nickname)
    # Al anfitrión le llega en vivo, por su WebSocket. Si no está conectado, la
    # verá en la bienvenida la próxima vez que entre.
    await sala.avisar_al_anfitrion({"tipo": "solicitud", "id": s["id"], "nickname": s["nickname"]})
    return s


@router.get("/{sid}")
def consultar(sid: str):
    """Quien pidió entrar pregunta aquí cada par de segundos. Cuando lo aprueban, recibe su token."""
    s = datos.db.solicitud(sid)
    if s is None:
        raise HTTPException(404, "No existe esa solicitud")
    return s
app/main.py
"""Charla: el servidor de UNA sala de chat, la tuya. Corre en tu computadora."""
from fastapi import FastAPI

from app import sala, solicitudes

app = FastAPI(title="Charla")
app.include_router(sala.router)
app.include_router(solicitudes.router)


@app.get("/salud")
def salud():
    return {"ok": True}

Por HTTP, no por WebSocket. Quien pide entrar no tiene token, así que no puede abrir el WebSocket (el Ejercicio 0). Pide con un POST, recibe el id de su solicitud, y pregunta por ella con GET cada par de segundos.

StringConstraints: el nickname llega ya sin espacios a los lados, y si mide menos de 2 o más de 20, FastAPI responde 422 sin que escribas un if. Lo repetido, o los nombres que usa el servidor, es un 409.

avisar_al_anfitrion: la solicitud le llega al anfitrión en vivo, por el WebSocket que ya tiene abierto.

Pruébalo con dos personas de mentira. Dos terminales:

docker compose exec api uv run --no-sync python herramientas/consola.py anfitrion
docker compose exec api uv run --no-sync python herramientas/consola.py invitado dani

En la del anfitrión aparece «¿Dejas entrar a dani?». Escribe /si. A dani le llega la historia y ya pueden platicar entre las dos terminales.

A5 — La invitación5 min

app/invitacion.py
"""La dirección pública de la sala: la que va en el QR.

cloudflared inventa una dirección nueva cada vez que arranca y la anuncia en
su puerto de métricas (/quicktunnel). El servidor se la pregunta, así nadie
tiene que copiarla a mano de los logs.
"""
import asyncio
import json
import os
import urllib.request

from fastapi import APIRouter, HTTPException

router = APIRouter()

# `tunel` es el nombre del servicio en docker-compose.yml: dentro de la red de Docker, es su dirección.
METRICAS_TUNEL = os.environ.get("METRICAS_TUNEL", "http://tunel:2000/quicktunnel")


def leer(url: str) -> bytes:
    with urllib.request.urlopen(url, timeout=3) as respuesta:
        return respuesta.read()


@router.get("/invitacion")
async def invitacion():
    try:
        # urllib bloquea: se corre en otro hilo para no detener la sala mientras tanto.
        cuerpo = await asyncio.to_thread(leer, METRICAS_TUNEL)
    except OSError:
        raise HTTPException(503, "El túnel no está corriendo")
    host = json.loads(cuerpo).get("hostname")
    if not host:
        raise HTTPException(503, "El túnel todavía no tiene dirección: espera unos segundos")
    return {"url": f"https://{host}"}
app/main.py
"""Charla: el servidor de UNA sala de chat, la tuya. Corre en tu computadora."""
from fastapi import FastAPI

from app import invitacion, sala, solicitudes

app = FastAPI(title="Charla")
app.include_router(sala.router)
app.include_router(solicitudes.router)
app.include_router(invitacion.router)


@app.get("/salud")
def salud():
    return {"ok": True}

¿Cuál es mi dirección pública? El túnel inventa una nueva cada vez que arranca, y la anuncia en su puerto de métricas: --metrics 0.0.0.0:2000 en el compose. El servidor se la pregunta a http://tunel:2000/quicktunnel, y así la app arma el QR sin que nadie copie nada de los logs.

Abre http://localhost:8000/invitacion. Si dice «El túnel todavía no tiene dirección», espera diez segundos. Después, abre esa dirección más /salud en el navegador de tu teléfono: tu servidor, desde internet.

A6 — Las pruebas del servidor8 min

Crea la carpeta tests y en ella:

tests/conftest.py
import os

# Antes de importar la app: la clave y una base en memoria, no la del volumen.
os.environ.setdefault("CLAVE_ANFITRION", "clave-de-prueba")
os.environ["RUTA_DB"] = ":memory:"

import pytest
from fastapi.testclient import TestClient

from app import datos
from app.main import app


@pytest.fixture
def cliente(monkeypatch):
    # Una base nueva para cada prueba: ninguna depende de lo que dejó la anterior.
    monkeypatch.setattr(datos, "db", datos.Datos(":memory:"))
    with TestClient(app) as c:
        yield c


@pytest.fixture
def clave():
    from app.sala import CLAVE_ANFITRION
    return {"Authorization": f"Bearer {CLAVE_ANFITRION}"}
tests/test_sala.py
import pytest
from starlette.websockets import WebSocketDisconnect

from app import invitacion


def pedir(cliente, nickname):
    return cliente.post("/solicitudes", json={"nickname": nickname})


def test_la_sala_responde(cliente):
    assert cliente.get("/salud").json() == {"ok": True}


def test_un_nickname_de_una_letra_no_se_acepta(cliente):
    assert pedir(cliente, "a").status_code == 422


def test_dos_personas_no_pueden_llamarse_igual(cliente):
    assert pedir(cliente, "dani").status_code == 201
    assert pedir(cliente, "Dani").status_code == 409
    assert pedir(cliente, "anfitrión").status_code == 409


def test_sin_token_no_se_entra_al_chat(cliente):
    with pytest.raises(WebSocketDisconnect) as e:
        with cliente.websocket_connect("/chat") as ws:
            ws.receive_json()
    assert e.value.code == 1008


def test_el_anfitrion_aprueba_y_el_invitado_chatea(cliente, clave):
    with cliente.websocket_connect("/chat", headers=clave) as anfitrion:
        assert anfitrion.receive_json()["yo"] == "anfitrión"

        s = pedir(cliente, "dani").json()
        assert s["estado"] == "pendiente" and s["token"] is None
        assert anfitrion.receive_json() == {"tipo": "solicitud", "id": s["id"], "nickname": "dani"}

        anfitrion.send_json({"tipo": "aprobar", "id": s["id"]})
        assert anfitrion.receive_json()["texto"] == "dani entró a la sala"
        token = cliente.get(f"/solicitudes/{s['id']}").json()["token"]

        with cliente.websocket_connect("/chat", headers={"Authorization": f"Bearer {token}"}) as dani:
            bienvenida = dani.receive_json()
            assert bienvenida["yo"] == "dani"
            assert bienvenida["solicitudes"] == []  # las solicitudes solo las ve el anfitrión
            dani.send_json({"tipo": "mensaje", "texto": "  hola  "})
            recibido = anfitrion.receive_json()
            assert (recibido["de"], recibido["texto"]) == ("dani", "hola")


def test_un_invitado_no_puede_aprobar_a_otro(cliente, clave):
    with cliente.websocket_connect("/chat", headers=clave) as anfitrion:
        anfitrion.receive_json()
        dani = pedir(cliente, "dani").json()
        anfitrion.receive_json()
        anfitrion.send_json({"tipo": "aprobar", "id": dani["id"]})
        anfitrion.receive_json()
        token = cliente.get(f"/solicitudes/{dani['id']}").json()["token"]

        eli = pedir(cliente, "eli").json()
        anfitrion.receive_json()
        with cliente.websocket_connect("/chat", headers={"Authorization": f"Bearer {token}"}) as ws:
            ws.receive_json()
            ws.send_json({"tipo": "aprobar", "id": eli["id"]})
            ws.send_json({"tipo": "mensaje", "texto": "listo"})
            ws.receive_json()  # cuando llega su mensaje, la orden anterior ya se atendió
        assert cliente.get(f"/solicitudes/{eli['id']}").json()["estado"] == "pendiente"


def test_el_historial_llega_al_entrar(cliente, clave):
    with cliente.websocket_connect("/chat", headers=clave) as ws:
        ws.receive_json()
        ws.send_json({"tipo": "mensaje", "texto": "primero"})
        ws.receive_json()
    with cliente.websocket_connect("/chat", headers=clave) as ws:
        assert [m["texto"] for m in ws.receive_json()["mensajes"]] == ["primero"]


def test_sin_tunel_la_invitacion_dice_por_que(cliente, monkeypatch):
    monkeypatch.setattr(invitacion, "METRICAS_TUNEL", "http://127.0.0.1:9/quicktunnel")
    r = cliente.get("/invitacion")
    assert r.status_code == 503
    assert r.json()["detail"] == "El túnel no está corriendo"
docker compose exec api uv run --no-sync pytest

8 passed. Corren dentro del contenedor, con las versiones exactas del uv.lock.

websocket_connect abre un WebSocket contra la app sin red de por medio, igual que cliente.post hace una petición. La prueba del anfitrión es la historia completa: alguien pide, al anfitrión le llega, aprueba, y el invitado platica con su token.

monkeypatch.setattr(datos, "db", …) cambia la base por una en memoria para cada prueba. Ninguna depende de lo que dejó la anterior, y tu sala de verdad no se toca.


Bloque B · 35 minLa app habla WebSocket

Ahora en Android Studio, con el repo practica-12-charla. Deja el servidor corriendo.

B1 — El código de arranque5 min

Abre local.properties (en la raíz del proyecto) y agrega tu clave, la misma del .env del servidor:

local.properties
charla.clave=la-misma-de-tu-.env

Corre la app. Se ve la sala vacía y, arriba, «Sin conexión»: todavía no hay nada detrás de las pantallas.

La app recién clonada: la sala vacía y «Sin conexión»El código de arranque: las pantallas ya están, la conexión no

En Android 17, antes aparece esta pantalla. Toca Permitir y acepta el permiso de dispositivos cercanos. Desde Android 17, una app con targetSdk 37 no puede hablar con la red local (y 10.0.2.2 es red local) sin el permiso ACCESS_LOCAL_NETWORK. Sin él, la conexión ni sale del teléfono, el servidor no se entera, y la sala dice «Sin conexión» para siempre. En Android 16 o anterior esta pantalla no aparece.

La pantalla que pide el permiso de red local en Android 17Solo en Android 17: el permiso para hablar con tu computadora

Ya viene Dónde Para qué
Las tres pantallas, sin ViewModel ui/screens/ La sala, la hoja de invitación y la de entrar a otra sala. Reciben un estado y avisan con funciones
El sistema de diseño de la Práctica 7 ui/theme/, ui/components/ Burbujas, avatares con el color de cada quien, el chip de conexión
Los ViewModels, vacíos ui/state/ Tienen las funciones que la navegación llama, pero no hacen nada. Los llenas hoy
Tu servidor y tu clave en BuildConfig app/build.gradle.kts SERVIDOR_PROPIO (por omisión http://10.0.2.2:8000) y CLAVE_ANFITRION salen de local.properties
El cliente de OkHttp con pingInterval di/RedModule.kt Cada 20 s un ping por el WebSocket: si no vuelve, la conexión se da por muerta
El permiso de red local de Android 17 AndroidManifest.xml, ui/screens/RedLocalScreen.kt ConRedLocal lo pide antes de mostrar la app, para que la primera conexión ya salga con permiso

B2 — El protocolo7 min

En el paquete data.remote (créalo: clic derecho sobre mx.tec.charla → New → Package):

data/remote/Protocolo.kt
package mx.tec.charla.data.remote

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import mx.tec.charla.domain.Mensaje
import mx.tec.charla.domain.Solicitud

/**
 * Lo que viaja por el WebSocket. Cada mensaje es un JSON con un campo `tipo`
 * que dice qué es; el resto de los campos depende del tipo.
 *
 * Una interfaz sellada es exactamente eso: un conjunto cerrado de variantes.
 * kotlinx.serialization lee `tipo` y elige la clase por su `@SerialName`.
 */
@Serializable
sealed interface EventoServidor {

    /** Lo primero que llega al conectarse: quién eres, la historia, y (si eres el anfitrión) quién espera. */
    @Serializable
    @SerialName("bienvenida")
    data class Bienvenida(
        val yo: String,
        val mensajes: List<Mensaje>,
        val solicitudes: List<Solicitud>
    ) : EventoServidor

    @Serializable
    @SerialName("mensaje")
    data class Nuevo(val id: Long, val de: String, val texto: String, val en: Double) : EventoServidor {
        fun aMensaje() = Mensaje(id, de, texto, en)
    }

    @Serializable
    @SerialName("solicitud")
    data class NuevaSolicitud(val id: String, val nickname: String) : EventoServidor {
        fun aSolicitud() = Solicitud(id, nickname)
    }
}

/** Lo que la app le manda al servidor. */
@Serializable
sealed interface Orden {
    @Serializable
    @SerialName("mensaje")
    data class Enviar(val texto: String) : Orden

    @Serializable
    @SerialName("aprobar")
    data class Aprobar(val id: String) : Orden

    @Serializable
    @SerialName("rechazar")
    data class Rechazar(val id: String) : Orden
}

object Protocolo {
    private val json = Json {
        classDiscriminator = "tipo"
        ignoreUnknownKeys = true
    }

    /** null si el servidor manda algo que esta versión de la app no conoce: se ignora, no truena. */
    fun leer(texto: String): EventoServidor? =
        runCatching { json.decodeFromString<EventoServidor>(texto) }.getOrNull()

    fun escribir(orden: Orden): String = json.encodeToString(orden)
}

Una interfaz sellada por sentido. EventoServidor es todo lo que puede llegar; Orden, todo lo que puede salir. classDiscriminator = "tipo": kotlinx.serialization lee el campo tipo del JSON y elige la clase por su @SerialName. Son exactamente los {"tipo": …} que escribiste en sala.py.

leer regresa null en vez de tronar. Si mañana el servidor manda un tipo nuevo ("escribiendo", digamos), esta versión de la app lo ignora y sigue. En un protocolo que vive años, eso es lo que permite actualizar un lado sin romper el otro.

B3 — La conexión13 min

data/remote/ChatConexion.kt
package mx.tec.charla.data.remote

import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import mx.tec.charla.domain.EstadoConexion
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener

/**
 * El WebSocket de la sala. Una sola conexión a la vez para toda la app.
 *
 * Hacia afuera son dos Flows: `estado` (cómo está la conexión) y `eventos`
 * (lo que dice el servidor). Hacia adentro, OkHttp avisa con callbacks en su
 * propio hilo; aquí se traducen a esos Flows.
 */
@Singleton
class ChatConexion @Inject constructor(private val cliente: OkHttpClient) {

    private val _estado = MutableStateFlow<EstadoConexion>(EstadoConexion.Desconectado)
    val estado: StateFlow<EstadoConexion> = _estado.asStateFlow()

    private val _eventos = MutableSharedFlow<EventoServidor>(extraBufferCapacity = 64)
    val eventos: SharedFlow<EventoServidor> = _eventos.asSharedFlow()

    // @Volatile: OkHttp llama a los callbacks desde su propio hilo, y tienen que ver el valor más reciente.
    @Volatile private var actual: WebSocket? = null

    fun conectar(servidor: String, token: String) {
        desconectar()
        // OkHttp acepta la dirección http(s): un WebSocket empieza como una
        // petición HTTP normal que pide «Upgrade: websocket».
        val peticion = Request.Builder()
            .url("$servidor/chat")
            .header("Authorization", "Bearer $token")
            .build()
        _estado.value = EstadoConexion.Conectando
        actual = cliente.newWebSocket(peticion, Escucha())
    }

    fun desconectar() {
        actual?.close(1000, null)
        actual = null
        _estado.value = EstadoConexion.Desconectado
    }

    /** false si no hay conexión: el mensaje no salió. */
    fun enviar(orden: Orden): Boolean = actual?.send(Protocolo.escribir(orden)) ?: false

    private inner class Escucha : WebSocketListener() {

        override fun onOpen(webSocket: WebSocket, response: Response) {
            _estado.value = EstadoConexion.Conectado
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            Protocolo.leer(text)?.let { _eventos.tryEmit(it) }
        }

        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            // El servidor se despide (por ejemplo, porque se reinicia): se le contesta igual.
            webSocket.close(1000, null)
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            _estado.value = EstadoConexion.Desconectado
        }

        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            // 403: el servidor no reconoce el token. Cualquier otra cosa: se cayó la red o el servidor.
            _estado.value = if (response?.code == 403) EstadoConexion.Rechazado else EstadoConexion.Desconectado
        }
    }
}

newWebSocket regresa al instante. La conexión se abre en otro hilo, y OkHttp avisa con los callbacks de WebSocketListener: onOpen, onMessage, onClosing, onClosed, onFailure. ChatConexion los traduce a dos Flows: un StateFlow para el estado (siempre tiene un valor: la pantalla pinta el chip) y un SharedFlow para los eventos (cada uno llega una vez y se va).

El token va en el encabezado, como en todas las peticiones desde la Práctica 6. Del lado del servidor, quien_es lo lee.

403 es distinto de «se cayó». Si el servidor rechaza el saludo, onFailure trae la respuesta con su código: esa sala no te reconoce (la clave no coincide, o te aceptaron en una base que ya se borró). Cualquier otra falla es la red o el servidor.

@Singleton: una sola conexión para toda la app, aunque la pantalla se destruya y se vuelva a crear.

B4 — El ViewModel de la sala10 min

ui/state/SalaViewModel.kt
package mx.tec.charla.ui.state

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mx.tec.charla.BuildConfig
import mx.tec.charla.data.remote.ChatConexion
import mx.tec.charla.data.remote.EventoServidor
import mx.tec.charla.data.remote.Orden
import mx.tec.charla.domain.ANFITRION
import mx.tec.charla.domain.Destino
import mx.tec.charla.domain.Solicitud

@HiltViewModel
class SalaViewModel @Inject constructor(private val conexion: ChatConexion) : ViewModel() {

    // Por ahora solo existe tu sala, y su dirección sale de local.properties. En el C2 la guarda SalaStore.
    private val destino = Destino(BuildConfig.SERVIDOR_PROPIO, BuildConfig.CLAVE_ANFITRION, ANFITRION, esPropio = true)

    private val _ui = MutableStateFlow(SalaUiState(destino = destino))
    val ui: StateFlow<SalaUiState> = _ui.asStateFlow()

    init {
        viewModelScope.launch {
            conexion.estado.collect { estado -> _ui.update { it.copy(conexion = estado) } }
        }
        viewModelScope.launch {
            conexion.eventos.collect(::recibir)
        }
    }

    /** La pantalla se ve: a conectarse. Lo llama LifecycleStartEffect. */
    fun alAparecer() = conexion.conectar(destino.servidor, destino.token)

    /** La app se fue al fondo: se cierra. Un socket abierto sin nadie viendo gasta batería. */
    fun alDesaparecer() = conexion.desconectar()

    private fun recibir(evento: EventoServidor) {
        when (evento) {
            // Al conectar (y al reconectar) llega la historia completa: reemplaza, no suma.
            is EventoServidor.Bienvenida -> _ui.update {
                it.copy(mensajes = evento.mensajes, solicitudes = evento.solicitudes)
            }
            is EventoServidor.Nuevo -> _ui.update { it.copy(mensajes = it.mensajes + evento.aMensaje()) }
            is EventoServidor.NuevaSolicitud -> _ui.update { it.copy(solicitudes = it.solicitudes + evento.aSolicitud()) }
        }
    }

    /** true si salió. El mensaje no se pinta aquí: se pinta cuando el servidor lo reparte, a todos igual. */
    fun enviar(texto: String): Boolean = texto.isNotBlank() && conexion.enviar(Orden.Enviar(texto.trim()))

    fun aprobar(solicitud: Solicitud) = responder(solicitud, Orden.Aprobar(solicitud.id))

    fun rechazar(solicitud: Solicitud) = responder(solicitud, Orden.Rechazar(solicitud.id))

    private fun responder(solicitud: Solicitud, orden: Orden) {
        if (conexion.enviar(orden)) _ui.update { it.copy(solicitudes = it.solicitudes - solicitud) }
    }

    // La invitación y Reiniciar necesitan la API de la sala y SalaStore: llegan en el C2.
    fun invitar() {}

    fun cerrarInvitacion() {}

    fun reiniciar() {}

    override fun onCleared() = conexion.desconectar()
}

Conectar al verse, cerrar al irse. La navegación ya llama a alAparecer y alDesaparecer desde un LifecycleStartEffect, igual que el tablón de la Práctica 8: un WebSocket abierto con la app en el fondo gasta batería y no lo ve nadie.

La bienvenida reemplaza, no suma. Cada vez que te conectas llega la historia completa. Si se sumara, cada reconexión duplicaría la sala.

El mensaje que envías no se pinta al enviarlo. Se pinta cuando el servidor lo reparte, igual que a todos. Si no llega, no se pinta: la pantalla nunca muestra algo que la sala no tiene.

Corre la app: «En línea», y la historia de lo que platicaron tus consolas. Ahora, en una terminal, pide entrar como alguien más:

docker compose exec api uv run --no-sync python herramientas/consola.py invitado eli

La tarjeta aparece en tu app. Toca Aceptar y platica con la terminal.

La sala del anfitrión: una solicitud arriba, y los mensajesIzquierda: eli pide entrar. Derecha: ya adentro, la sala con sus mensajes


Bloque C · 45 minInvitar y entrar

C1 — Las librerías y la API de la sala8 min

gradle/libs.versions.toml
[versions]
zxing = "3.5.4"
codeScanner = "16.1.0"
barcodeScanning = "17.3.0"
coroutinesPlayServices = "1.10.2"

[libraries]
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
play-services-code-scanner = { group = "com.google.android.gms", name = "play-services-code-scanner", version.ref = "codeScanner" }
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
app/build.gradle.kts — en dependencies, después de las de Hilt
    // El QR: ZXing lo dibuja; el escáner de Google lo lee con la cámara;
    // ML Kit lo lee de una imagen (viene dentro de la app: funciona sin descargar nada)
    implementation(libs.zxing.core)
    implementation(libs.play.services.code.scanner)
    implementation(libs.mlkit.barcode.scanning)
    implementation(libs.kotlinx.coroutines.play.services)

coroutines-play-services trae el BOM de corrutinas. Al agregarla, Gradle sube todas las piezas de corrutinas del proyecto (core, android) de 1.9.0 a 1.10.2, juntas. Se verificó con gradle :app:dependencies: ninguna queda en otra versión. Mezclarlas es justo lo que la Práctica 11 te enseñó a evitar.

Lo que no va por el WebSocket va por Retrofit:

data/remote/SalaApi.kt
package mx.tec.charla.data.remote

import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Url

/**
 * Lo que NO va por el WebSocket: preguntar la invitación y pedir entrar.
 *
 * Cada sala tiene su dirección, así que no hay una `baseUrl` fija: con `@Url`
 * cada llamada recibe la dirección completa. Retrofit ignora su `baseUrl`
 * cuando la de la llamada ya trae `http://`.
 */
interface SalaApi {
    @GET
    suspend fun invitacion(@Url url: String): Invitacion

    @POST
    suspend fun solicitar(@Url url: String, @Body cuerpo: NuevaSolicitud): EstadoSolicitud

    @GET
    suspend fun consultar(@Url url: String): EstadoSolicitud
}

@Serializable
data class Invitacion(val url: String)

@Serializable
data class NuevaSolicitud(val nickname: String)

/** `estado` es pendiente, aprobada o rechazada. El token solo llega cuando la aprueban. */
@Serializable
data class EstadoSolicitud(val id: String, val nickname: String, val estado: String, val token: String? = null)
di/RedModule.kt
package mx.tec.charla.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import kotlinx.serialization.json.Json
import mx.tec.charla.data.remote.SalaApi
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory

@Module
@InstallIn(SingletonComponent::class)
object RedModule {

    /**
     * Un solo cliente para el WebSocket y para Retrofit.
     *
     * `pingInterval`: cada 20 s OkHttp manda un ping por el WebSocket. Si no
     * vuelve el pong, da la conexión por muerta y avisa con `onFailure`.
     * Sin pings, una conexión que se cayó en silencio —el wifi cambió, el
     * túnel la cerró por inactiva— parecería viva para siempre.
     */
    @Provides
    @Singleton
    fun cliente(): OkHttpClient = OkHttpClient.Builder()
        .pingInterval(20, TimeUnit.SECONDS)
        .build()

    @Provides
    @Singleton
    fun salaApi(cliente: OkHttpClient): SalaApi = Retrofit.Builder()
        // Obligatoria para Retrofit, pero no se usa: cada llamada trae su @Url completa.
        .baseUrl("http://localhost/")
        .client(cliente)
        .addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
        .build()
        .create(SalaApi::class.java)
}

@Url, sin baseUrl fija. Cada sala tiene su dirección, y hoy vas a hablar con varias. Con @Url cada llamada lleva la dirección completa, y Retrofit ignora su baseUrl. Es obligatoria, así que se le da una que nunca se usa.

C2 — Tu QR12 min

En un paquete nuevo, data.qr:

data/qr/GeneradorQr.kt
package mx.tec.charla.data.qr

import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter

/**
 * Dibuja un QR con ZXing: primero una matriz de sí/no (módulo negro o
 * blanco), y después un Bitmap con un pixel por casilla.
 *
 * Se pasa el arreglo completo de una vez (`setPixels`): pixel por pixel con
 * `setPixel` serían 600 000 llamadas.
 */
object GeneradorQr {
    fun bitmap(texto: String, lado: Int = 768): Bitmap {
        val matriz = QRCodeWriter().encode(texto, BarcodeFormat.QR_CODE, lado, lado, mapOf(EncodeHintType.MARGIN to 1))
        val pixeles = IntArray(lado * lado) { i ->
            if (matriz[i % lado, i / lado]) Color.BLACK else Color.WHITE
        }
        return Bitmap.createBitmap(lado, lado, Bitmap.Config.RGB_565).apply {
            setPixels(pixeles, 0, lado, 0, 0, lado, lado)
        }
    }
}

En otro, data.local:

data/local/SalaStore.kt
package mx.tec.charla.data.local

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mx.tec.charla.BuildConfig
import mx.tec.charla.domain.ANFITRION
import mx.tec.charla.domain.Destino

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "sala")

/**
 * A qué sala va la app. Vacío = a la tuya.
 *
 * Se guarda para que, si cierras la app, vuelvas a la misma sala sin pedir
 * entrar otra vez: el token sigue valiendo mientras el anfitrión no borre su
 * base.
 */
@Singleton
class SalaStore @Inject constructor(@ApplicationContext private val context: Context) {

    private val SERVIDOR = stringPreferencesKey("servidor")
    private val TOKEN = stringPreferencesKey("token")
    private val YO = stringPreferencesKey("yo")

    val destino: Flow<Destino> = context.dataStore.data.map { prefs ->
        val servidor = prefs[SERVIDOR]
        val token = prefs[TOKEN]
        val yo = prefs[YO]
        if (servidor != null && token != null && yo != null) {
            Destino(servidor, token, yo, esPropio = false)
        } else {
            propio
        }
    }

    suspend fun entrar(servidor: String, token: String, yo: String) {
        context.dataStore.edit {
            it[SERVIDOR] = servidor
            it[TOKEN] = token
            it[YO] = yo
        }
    }

    /** El botón Reiniciar: olvida la sala ajena y vuelve a la tuya. */
    suspend fun reiniciar() {
        context.dataStore.edit { it.clear() }
    }

    companion object {
        val propio = Destino(BuildConfig.SERVIDOR_PROPIO, BuildConfig.CLAVE_ANFITRION, ANFITRION, esPropio = true)
    }
}

Y el ViewModel de la sala, ahora completo:

ui/state/SalaViewModel.kt
package mx.tec.charla.ui.state

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mx.tec.charla.data.local.SalaStore
import mx.tec.charla.data.qr.GeneradorQr
import mx.tec.charla.data.remote.ChatConexion
import mx.tec.charla.data.remote.EventoServidor
import mx.tec.charla.data.remote.Orden
import mx.tec.charla.data.remote.SalaApi
import mx.tec.charla.domain.Solicitud
import retrofit2.HttpException

@HiltViewModel
class SalaViewModel @Inject constructor(
    private val store: SalaStore,
    private val conexion: ChatConexion,
    private val api: SalaApi
) : ViewModel() {

    private val _ui = MutableStateFlow(SalaUiState())
    val ui: StateFlow<SalaUiState> = _ui.asStateFlow()

    private var visible = false

    init {
        viewModelScope.launch {
            // Cada vez que cambia la sala (entraste a otra, o Reiniciar), se conecta a la nueva.
            store.destino.distinctUntilChanged().collect { destino ->
                // Sala nueva, pantalla en blanco: la historia llega con la bienvenida.
                _ui.update { SalaUiState(destino = destino, conexion = it.conexion) }
                if (visible) conexion.conectar(destino.servidor, destino.token)
            }
        }
        viewModelScope.launch {
            conexion.estado.collect { estado -> _ui.update { it.copy(conexion = estado) } }
        }
        viewModelScope.launch {
            conexion.eventos.collect(::recibir)
        }
    }

    /** La pantalla se ve: a conectarse. Lo llama LifecycleStartEffect. */
    fun alAparecer() {
        visible = true
        _ui.value.destino?.let { conexion.conectar(it.servidor, it.token) }
    }

    /** La app se fue al fondo: se cierra. Un socket abierto sin nadie viendo gasta batería. */
    fun alDesaparecer() {
        visible = false
        conexion.desconectar()
    }

    private fun recibir(evento: EventoServidor) {
        when (evento) {
            // Al conectar (y al reconectar) llega la historia completa: reemplaza, no suma.
            is EventoServidor.Bienvenida -> _ui.update {
                it.copy(mensajes = evento.mensajes, solicitudes = evento.solicitudes)
            }
            is EventoServidor.Nuevo -> _ui.update { it.copy(mensajes = it.mensajes + evento.aMensaje()) }
            is EventoServidor.NuevaSolicitud -> _ui.update { it.copy(solicitudes = it.solicitudes + evento.aSolicitud()) }
        }
    }

    /** true si salió. El mensaje no se pinta aquí: se pinta cuando el servidor lo reparte, a todos igual. */
    fun enviar(texto: String): Boolean = texto.isNotBlank() && conexion.enviar(Orden.Enviar(texto.trim()))

    fun aprobar(solicitud: Solicitud) = responder(solicitud, Orden.Aprobar(solicitud.id))

    fun rechazar(solicitud: Solicitud) = responder(solicitud, Orden.Rechazar(solicitud.id))

    private fun responder(solicitud: Solicitud, orden: Orden) {
        if (conexion.enviar(orden)) _ui.update { it.copy(solicitudes = it.solicitudes - solicitud) }
    }

    /** Pregunta a TU servidor su dirección pública y la convierte en QR. */
    fun invitar() {
        _ui.update { it.copy(invitacion = InvitacionUi.Cargando) }
        viewModelScope.launch {
            val invitacion = try {
                val url = api.invitacion("${SalaStore.propio.servidor}/invitacion").url
                // Dibujar el QR son medio millón de pixeles: fuera del hilo principal.
                InvitacionUi.Lista(url, withContext(Dispatchers.Default) { GeneradorQr.bitmap(url) })
            } catch (e: IOException) {
                InvitacionUi.Error(mensajeDe(e))
            } catch (e: HttpException) {
                InvitacionUi.Error(mensajeDe(e))
            }
            _ui.update { it.copy(invitacion = invitacion) }
        }
    }

    fun cerrarInvitacion() = _ui.update { it.copy(invitacion = InvitacionUi.Cerrada) }

    /** Olvida la sala ajena. `store.destino` cambia, y el `collect` de arriba reconecta solo. */
    fun reiniciar() {
        viewModelScope.launch { store.reiniciar() }
    }

    override fun onCleared() = conexion.desconectar()
}

A qué sala vas, en DataStore. SalaStore.destino es un Flow: vacío significa «la tuya», con tu clave; si entraste a otra, su dirección, tu token y tu nickname. El ViewModel lo recolecta, y cada vez que cambia, se conecta a la nueva. Entrar a otra sala y «Volver a mi sala» no llaman a conectar: solo cambian el store.

El QR se dibuja fuera del hilo principal (Dispatchers.Default): son 768 × 768 pixeles. Y la tarjeta que lo contiene es blanca también en modo oscuro: muchos lectores no leen un QR claro sobre fondo oscuro.

Corre la app y toca el QR de arriba:

La hoja de invitación con el QR y el enlaceTu QR, tu enlace, y dos formas de pasarlo

C3 — Leer un QR8 min

En domain:

domain/Enlace.kt
package mx.tec.charla.domain

/**
 * La dirección de una sala, venga de donde venga: un QR, una captura, o un
 * texto que alguien pegó («Entra a mi sala de Charla: https://…»).
 *
 * Se queda con lo primero que parezca `http(s)://host[:puerto]` y tira el
 * resto (una ruta, una diagonal al final, el texto alrededor). Si no hay nada
 * así, regresa null: mejor decir «eso no es un enlace» que conectarse a basura.
 */
object Enlace {
    private val DIRECCION = Regex("""https?://[A-Za-z0-9.-]+(:\d{1,5})?""")

    fun servidorDesde(texto: String): String? = DIRECCION.find(texto)?.value
}

En data.qr:

data/qr/LectorQr.kt
package mx.tec.charla.data.qr

import android.content.Context
import android.net.Uri
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.google.mlkit.vision.common.InputImage
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await

/**
 * Dos formas de leer un QR, las dos de Google:
 *
 *  - con la cámara: el escáner de Play Services abre SU pantalla, lee el
 *    código y regresa el texto. La app no pide permiso de cámara: la cámara
 *    la usa Google, no tú.
 *  - de una imagen: ML Kit busca códigos en una foto o captura que ya existe.
 */
class LectorQr @Inject constructor(@ApplicationContext private val context: Context) {

    private val soloQr = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()

    /**
     * null si el usuario cerró el escáner sin leer nada. Se escribe con los tres
     * callbacks y no con `await()`: cerrar el escáner CANCELA la tarea, y `await()`
     * lo convertiría en una excepción que cancela también a quien llamó.
     */
    suspend fun escanear(): String? = suspendCancellableCoroutine { continuacion ->
        val opciones = GmsBarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
        GmsBarcodeScanning.getClient(context, opciones).startScan()
            .addOnSuccessListener { continuacion.resume(it.rawValue) }
            .addOnCanceledListener { continuacion.resume(null) }
            .addOnFailureListener { continuacion.resumeWithException(it) }
    }

    /** El texto del primer QR de la imagen, o null si no tiene ninguno. */
    suspend fun leerDeImagen(imagen: Uri): String? {
        val entrada = InputImage.fromFilePath(context, imagen)
        val codigos = BarcodeScanning.getClient(soloQr).process(entrada).await()
        return codigos.firstNotNullOfOrNull { it.rawValue }
    }
}

Enlace no confía en lo que llega. De un QR, de una captura o de un mensaje pegado («Entra a mi sala de Charla: https://…»), se queda solo con https://host:puerto. Si no hay nada así, null: mejor decir «eso no es un enlace» que conectarse a basura.

El escáner de Google no pide permiso de cámara. Abre su pantalla, lee y regresa el texto: la cámara la usa Play Services, no tu app. Es la misma idea del selector de fotos de la Práctica 10.

ML Kit viene dentro de la app (barcode-scanning, unos 2 MB). Existe una versión que lo descarga después desde Play Services y pesa menos, pero en clase no quieres depender de esa descarga.

Tres callbacks en escanear, no await(). Si el usuario cierra el escáner, la tarea se cancela, y await() convertiría eso en una cancelación de quien lo llamó. Con los callbacks, cerrar el escáner es simplemente null.

C4 — Pedir entrar desde la app17 min

ui/state/UnirseViewModel.kt
package mx.tec.charla.ui.state

import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mx.tec.charla.data.local.SalaStore
import mx.tec.charla.data.qr.LectorQr
import mx.tec.charla.data.remote.NuevaSolicitud
import mx.tec.charla.data.remote.SalaApi
import mx.tec.charla.domain.Enlace
import retrofit2.HttpException

@HiltViewModel
class UnirseViewModel @Inject constructor(
    private val api: SalaApi,
    private val store: SalaStore,
    private val lector: LectorQr
) : ViewModel() {

    private val _ui = MutableStateFlow(UnirseUiState())
    val ui: StateFlow<UnirseUiState> = _ui.asStateFlow()

    private var pidiendo: Job? = null

    /** Las tres vías terminan aquí: un texto que debería traer la dirección de una sala. */
    fun usarEnlace(texto: String) {
        val servidor = Enlace.servidorDesde(texto)
        _ui.update {
            if (servidor == null) it.copy(etapa = Etapa.Error("Eso no parece el enlace de una sala"))
            else it.copy(servidor = servidor, etapa = Etapa.Eligiendo)
        }
    }

    fun escanear() {
        viewModelScope.launch {
            try {
                lector.escanear()?.let(::usarEnlace)
            } catch (e: Exception) {
                _ui.update { it.copy(etapa = Etapa.Error("No se pudo abrir el escáner: ${e.message}")) }
            }
        }
    }

    fun leerImagen(imagen: Uri) {
        viewModelScope.launch {
            val texto = try {
                lector.leerDeImagen(imagen)
            } catch (e: IOException) {
                null
            }
            if (texto == null) _ui.update { it.copy(etapa = Etapa.Error("No encontré un QR en esa imagen")) }
            else usarEnlace(texto)
        }
    }

    fun cambiarNickname(nickname: String) = _ui.update { it.copy(nickname = nickname) }

    fun pedirEntrar() {
        val servidor = _ui.value.servidor ?: return
        val nickname = _ui.value.nickname.trim()
        pidiendo = viewModelScope.launch {
            _ui.update { it.copy(etapa = Etapa.Enviando) }
            try {
                var solicitud = api.solicitar("$servidor/solicitudes", NuevaSolicitud(nickname))
                _ui.update { it.copy(etapa = Etapa.Esperando) }
                // Todavía no hay token para el WebSocket: se pregunta por HTTP cada 2 s.
                // Si sales de la pantalla, viewModelScope cancela este ciclo.
                while (solicitud.estado == "pendiente") {
                    delay(2_000)
                    solicitud = api.consultar("$servidor/solicitudes/${solicitud.id}")
                }
                val token = solicitud.token
                if (solicitud.estado == "aprobada" && token != null) {
                    store.entrar(servidor, token, solicitud.nickname)
                    _ui.update { it.copy(etapa = Etapa.Dentro) }
                } else {
                    _ui.update { it.copy(etapa = Etapa.Error("El anfitrión no te dejó entrar")) }
                }
            } catch (e: IOException) {
                _ui.update { it.copy(etapa = Etapa.Error(mensajeDe(e))) }
            } catch (e: HttpException) {
                _ui.update { it.copy(etapa = Etapa.Error(mensajeDe(e))) }
            }
        }
    }

    fun cancelar() {
        pidiendo?.cancel()
        _ui.update { it.copy(etapa = Etapa.Eligiendo) }
    }

    /** Elegir otra sala: se olvida la que estaba elegida. */
    fun otraSala() {
        pidiendo?.cancel()
        _ui.update { UnirseUiState(nickname = it.nickname) }
    }
}

Las tres vías terminan en usarEnlace. Después ya da igual de dónde vino la dirección. La navegación ya conecta los botones: «Escanear» llama a escanear(), «Leer el QR de una imagen» abre el selector de fotos y llama a leerImagen(), y «Pegar el enlace» llama a usarEnlace().

Esperar es preguntar por HTTP cada 2 s (el while). Todavía no hay token, así que no hay WebSocket. Si sales de la pantalla, viewModelScope cancela el ciclo solo.

Cuando te aceptan, nada se conecta aquí: se guarda el destino en SalaStore, la pantalla se cierra, y el collect del C2 conecta la sala a la nueva dirección.

Ahora, en parejas. Uno invita (toca el QR) y el otro entra. Prueben las tres vías:

Vía Cómo Funciona en
Pegar el enlace Quien invita toca Copiar: el portapapeles del emulador es el de su computadora. Lo manda por Teams o WhatsApp Web. Quien entra lo copia y lo pega en «Pegar el enlace» Cualquier emulador
Leer de una captura Quien invita toma una captura (el botón de cámara de la barra del emulador la guarda en el escritorio) y la manda. Quien entra arrastra el PNG sobre la ventana de su emulador: queda en Descargas. Luego «Leer el QR de una imagen» Cualquier emulador
Escanear Con un teléfono de verdad, apuntando a la pantalla de quien invita Teléfono físico. La cámara del emulador es un cuarto 3D virtual: abre, pero ahí no hay QR

Si trabajas solo: entra a tu propia sala por el túnel, como si fueras alguien más. Copia tu enlace, pégalo en «Pegar el enlace», y acepta la solicitud desde la consola de anfitrión (/si).

Entrar a otra sala: las tres vías, y la esperaIzquierda: las tres vías. Derecha: la solicitud ya salió y espera al anfitrión

La sala de alguien más, vista como invitadoComo invitado: los mensajes del anfitrión a la izquierda, «Volver a mi sala» arriba


Bloque D · 35 minCuando la red falla

D1 — El socket que avisa tarde6 min

Entra a la sala de alguien más por el túnel (la del compañero, o la tuya). Ya adentro, toca Volver a mi sala.

Tu sala con su historia, pero el chip dice «Sin conexión»La historia llegó por la conexión nueva, pero el chip dice «Sin conexión» y no deja escribir

La historia llegó, así que la conexión nueva sí abrió. Pero el chip dice «Sin conexión» y no puedes escribir. En docker compose logs api se ve el WebSocket /chat [accepted].

Lo que pasó, en orden:

  tiempo ──────────────────────────────────────────────────────────▶
  conectar(tu sala)
   ├─ desconectar(): close() a la conexión del túnel ···· (la despedida viaja por internet)
   └─ newWebSocket(10.0.2.2) ─── onOpen ─▶ «En línea»
                                                    ··· onClosed (de la VIEJA) ─▶ «Sin conexión»

La conexión vieja se despide por el túnel, que es lento; la nueva abre por 10.0.2.2, que es inmediato. El onClosed de la vieja llega después del onOpen de la nueva, y como ningún callback pregunta de qué conexión viene, apaga a la que sí está viva.

Ejercicio D1 ¿Cómo lo arreglarías? 3 min · por escrito

Cada callback recibe el webSocket que lo dispara. ¿Qué tendría que comprobar antes de tocar el estado?

Respuesta

Que ese webSocket sea el actual: if (webSocket !== actual) return. Lo que diga una conexión que ya cambiaste no importa. !== compara identidad (si es el mismo objeto), no igualdad. Es justo el arreglo del D2.

D2 — Un guardia y un reintento12 min

En domain:

domain/Espera.kt
package mx.tec.charla.domain

/**
 * Cuánto esperar antes del intento número `intento` de reconectar:
 * 1 s, 2 s, 4 s, 8 s, 16 s, y de ahí 30 s siempre.
 *
 * Duplicar la espera (backoff exponencial) es cortesía con el servidor: si se
 * cayó, treinta teléfonos tocándole la puerta cada segundo no lo ayudan a
 * levantarse. El tope evita que, tras una caída larga, la app tarde minutos en
 * darse cuenta de que ya volvió.
 */
fun esperaAntesDeReintentar(intento: Int): Long =
    (1_000L shl (intento - 1).coerceIn(0, 5)).coerceAtMost(30_000L)

Y ChatConexion, completa:

data/remote/ChatConexion.kt
package mx.tec.charla.data.remote

import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import mx.tec.charla.domain.EstadoConexion
import mx.tec.charla.domain.esperaAntesDeReintentar
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener

/**
 * El WebSocket de la sala. Una sola conexión a la vez para toda la app.
 *
 * Hacia afuera son dos Flows: `estado` (cómo está la conexión) y `eventos`
 * (lo que dice el servidor). Hacia adentro, OkHttp avisa con callbacks en su
 * propio hilo; aquí se traducen a esos Flows.
 */
@Singleton
class ChatConexion(
    private val cliente: OkHttpClient,
    private val alcance: CoroutineScope
) {
    @Inject
    constructor(cliente: OkHttpClient) : this(cliente, CoroutineScope(SupervisorJob() + Dispatchers.Default))

    private val _estado = MutableStateFlow<EstadoConexion>(EstadoConexion.Desconectado)
    val estado: StateFlow<EstadoConexion> = _estado.asStateFlow()

    private val _eventos = MutableSharedFlow<EventoServidor>(extraBufferCapacity = 64)
    val eventos: SharedFlow<EventoServidor> = _eventos.asSharedFlow()

    // @Volatile: OkHttp llama a los callbacks desde su propio hilo, y tienen que ver el valor más reciente.
    @Volatile private var actual: WebSocket? = null
    private var peticion: Request? = null
    private var reintento: Job? = null
    private var intentos = 0

    fun conectar(servidor: String, token: String) {
        desconectar()
        // OkHttp acepta la dirección http(s): un WebSocket empieza como una
        // petición HTTP normal que pide «Upgrade: websocket».
        peticion = Request.Builder()
            .url("$servidor/chat")
            .header("Authorization", "Bearer $token")
            .build()
        abrir()
    }

    fun desconectar() {
        reintento?.cancel()
        peticion = null
        intentos = 0
        actual?.close(1000, null)
        actual = null
        _estado.value = EstadoConexion.Desconectado
    }

    /** false si no hay conexión: el mensaje no salió. */
    fun enviar(orden: Orden): Boolean = actual?.send(Protocolo.escribir(orden)) ?: false

    private fun abrir() {
        val p = peticion ?: return
        _estado.value = EstadoConexion.Conectando
        actual = cliente.newWebSocket(p, Escucha())
    }

    private fun reintentar() {
        intentos++
        val espera = esperaAntesDeReintentar(intentos)
        _estado.value = EstadoConexion.Reconectando(espera / 1_000)
        reintento = alcance.launch {
            delay(espera)
            abrir()
        }
    }

    private inner class Escucha : WebSocketListener() {
        // Cada callback empieza igual: si este socket ya no es el actual, lo
        // que diga ya no importa. Sin esa línea, el cierre de una conexión
        // vieja llega DESPUÉS de que abrió la nueva y la marca «desconectada».

        override fun onOpen(webSocket: WebSocket, response: Response) {
            if (webSocket !== actual) return
            intentos = 0
            _estado.value = EstadoConexion.Conectado
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            if (webSocket !== actual) return
            Protocolo.leer(text)?.let { _eventos.tryEmit(it) }
        }

        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            // El servidor se despide (por ejemplo, porque se reinicia): se le contesta igual.
            webSocket.close(1000, null)
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            if (webSocket !== actual) return
            reintentar()
        }

        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            if (webSocket !== actual) return
            if (response?.code == 403) {
                // El servidor no reconoce el token. Reintentar no lo va a cambiar.
                actual = null
                _estado.value = EstadoConexion.Rechazado
            } else {
                reintentar()
            }
        }
    }
}

El guardia. Cada callback empieza con if (webSocket !== actual) return. Con eso, el D1 desaparece.

Reconectar, sin martillar. Si la conexión se cae (el servidor se reinició, el wifi cambió), reintentar espera 1 s, 2 s, 4 s… hasta 30 s, y vuelve a abrir. Se llama backoff exponencial: si el servidor se cayó, treinta teléfonos tocándole la puerta cada segundo no lo ayudan a levantarse. Un 403 no se reintenta: el token no va a cambiar solo.

El alcance llega por el constructor. Hilt usa el constructor con @Inject, que arma uno propio. Las pruebas del D3 usan el otro y pasan el suyo, para poder cancelarlo al terminar.

Pruébalo. Con la app en tu sala, en la terminal del servidor:

docker compose stop api

El chip alterna entre «Conectando…» (cada intento tarda unos segundos en fallar) y «Reconectando en 1 s», luego 2, 4, 8… Prende el servidor:

docker compose start api

En el siguiente intento, «En línea», y la historia completa otra vez.

El chip mientras el servidor está apagadoCon el servidor apagado: la app espera cada vez más entre intentos

D3 — Pruebas en la JVM12 min

gradle/libs.versions.toml
[versions]
junit = "4.13.2"

[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
app/build.gradle.kts — al final de dependencies
    // Pruebas locales (src/test): corren en la JVM de tu computadora, sin emulador.
    testImplementation(libs.junit)
    testImplementation(libs.okhttp.mockwebserver)

mockwebserver con version.ref = "okhttp": tiene que ser la misma versión que OkHttp, 4.12.0.

En mx.tec.charla (test), paquete domain:

app/src/test/java/mx/tec/charla/domain/EnlaceTest.kt
package mx.tec.charla.domain

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class EnlaceTest {

    @Test
    fun la_direccion_sola_queda_igual() {
        assertEquals("https://abc.trycloudflare.com", Enlace.servidorDesde("https://abc.trycloudflare.com"))
    }

    @Test
    fun se_quitan_la_diagonal_final_y_la_ruta() {
        assertEquals("https://abc.trycloudflare.com", Enlace.servidorDesde("https://abc.trycloudflare.com/"))
        assertEquals("https://abc.trycloudflare.com", Enlace.servidorDesde("https://abc.trycloudflare.com/chat"))
    }

    @Test
    fun se_encuentra_dentro_de_un_mensaje_compartido() {
        assertEquals(
            "https://abc.trycloudflare.com",
            Enlace.servidorDesde("Entra a mi sala de Charla: https://abc.trycloudflare.com")
        )
    }

    @Test
    fun el_puerto_se_conserva() {
        assertEquals("http://10.0.2.2:8000", Enlace.servidorDesde("http://10.0.2.2:8000"))
    }

    @Test
    fun lo_que_no_es_un_enlace_es_null() {
        assertNull(Enlace.servidorDesde("hola"))
        assertNull(Enlace.servidorDesde("ftp://abc.com"))
        assertNull(Enlace.servidorDesde(""))
    }
}
app/src/test/java/mx/tec/charla/domain/EsperaTest.kt
package mx.tec.charla.domain

import org.junit.Assert.assertEquals
import org.junit.Test

class EsperaTest {

    @Test
    fun la_espera_se_duplica_en_cada_intento() {
        assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L), (1..5).map(::esperaAntesDeReintentar))
    }

    @Test
    fun nunca_pasa_de_30_segundos() {
        assertEquals(30_000L, esperaAntesDeReintentar(6))
        assertEquals(30_000L, esperaAntesDeReintentar(500))
    }
}

Paquete data.remote:

app/src/test/java/mx/tec/charla/data/remote/ProtocoloTest.kt
package mx.tec.charla.data.remote

import mx.tec.charla.domain.Mensaje
import mx.tec.charla.domain.Solicitud
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

/** Los JSON de estas pruebas son copia de lo que manda el servidor de la Parte A. */
class ProtocoloTest {

    @Test
    fun la_bienvenida_trae_historia_y_solicitudes() {
        val evento = Protocolo.leer(
            """{"tipo":"bienvenida","yo":"anfitrión",
               "mensajes":[{"id":1,"de":"sala","texto":"dani entró a la sala","en":1791427512.5}],
               "solicitudes":[{"id":"IS_cy","nickname":"eli"}]}"""
        )
        assertEquals(
            EventoServidor.Bienvenida(
                yo = "anfitrión",
                mensajes = listOf(Mensaje(1, "sala", "dani entró a la sala", 1791427512.5)),
                solicitudes = listOf(Solicitud("IS_cy", "eli"))
            ),
            evento
        )
    }

    @Test
    fun un_mensaje_nuevo_se_convierte_en_mensaje() {
        val evento = Protocolo.leer("""{"tipo":"mensaje","id":7,"de":"dani","texto":"hola","en":1.0}""")
        assertEquals(Mensaje(7, "dani", "hola", 1.0), (evento as EventoServidor.Nuevo).aMensaje())
    }

    @Test
    fun lo_que_esta_version_no_conoce_se_ignora() {
        assertNull(Protocolo.leer("""{"tipo":"escribiendo","de":"dani"}"""))
        assertNull(Protocolo.leer("no soy json"))
    }

    @Test
    fun cada_orden_lleva_su_tipo() {
        assertEquals("""{"tipo":"mensaje","texto":"hola"}""", Protocolo.escribir(Orden.Enviar("hola")))
        assertEquals("""{"tipo":"aprobar","id":"IS_cy"}""", Protocolo.escribir(Orden.Aprobar("IS_cy")))
    }
}
app/src/test/java/mx/tec/charla/data/remote/ChatConexionTest.kt
package mx.tec.charla.data.remote

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import mx.tec.charla.domain.EstadoConexion
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Test

/**
 * ChatConexion contra salas de mentira que corren en la JVM: MockWebServer
 * habla WebSocket de verdad. Sin emulador y sin Docker.
 */
class ChatConexionTest {

    private val salaA = MockWebServer()
    private val salaB = MockWebServer()
    private val alcance = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private val conexion = ChatConexion(OkHttpClient(), alcance)

    @After
    fun cerrar() {
        conexion.desconectar()
        alcance.cancel()
        salaA.shutdown()
        salaB.shutdown()
    }

    /**
     * Encola una sala que acepta el WebSocket. `alAbrir` decide qué hace el servidor
     * cuando alguien entra; `tardaEnDespedirse`, cuánto tarda en contestar un cierre
     * (en localhost es casi nada; por el túnel, no).
     */
    private fun MockWebServer.sala(tardaEnDespedirse: Long = 0, alAbrir: (WebSocket) -> Unit = {}): String {
        enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) = alAbrir(webSocket)
            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
                Thread.sleep(tardaEnDespedirse)
                webSocket.close(1000, null)
            }
        }))
        return url("/").toString().removeSuffix("/")
    }

    private fun esperar(estado: EstadoConexion) {
        runBlocking { withTimeout(5_000) { conexion.estado.first { it == estado } } }
    }

    @Test
    fun manda_el_token_y_recibe_la_bienvenida() {
        val url = salaA.sala { it.send("""{"tipo":"bienvenida","yo":"dani","mensajes":[],"solicitudes":[]}""") }
        // UNDISPATCHED: empieza a escuchar YA, antes de conectar. Si no, la bienvenida podría llegar sin nadie oyendo.
        val bienvenida = runBlocking {
            val primero = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { conexion.eventos.first() } }
            conexion.conectar(url, "token-de-dani")
            primero.await()
        }

        assertEquals("dani", (bienvenida as EventoServidor.Bienvenida).yo)
        assertEquals("Bearer token-de-dani", salaA.takeRequest().getHeader("Authorization"))
        esperar(EstadoConexion.Conectado)
    }

    @Test
    fun el_cierre_de_la_conexion_vieja_no_apaga_la_nueva() {
        conexion.conectar(salaA.sala(tardaEnDespedirse = 300), "uno")
        esperar(EstadoConexion.Conectado)
        conexion.conectar(salaB.sala(), "dos")
        esperar(EstadoConexion.Conectado)

        // El cierre de la sala A llega 300 ms DESPUÉS de que abrió la B. Que no cambie nada.
        Thread.sleep(1_000)
        assertEquals(EstadoConexion.Conectado, conexion.estado.value)
    }

    @Test
    fun si_la_sala_no_reconoce_el_token_no_reintenta() {
        salaA.enqueue(MockResponse().setResponseCode(403))
        conexion.conectar(salaA.url("/").toString().removeSuffix("/"), "viejo")
        esperar(EstadoConexion.Rechazado)

        Thread.sleep(1_500)
        assertEquals(EstadoConexion.Rechazado, conexion.estado.value)
        assertEquals(1, salaA.requestCount)
    }

    @Test
    fun si_la_sala_se_cae_reintenta_y_vuelve() {
        // La primera conexión el servidor la corta en cuanto abre (como al reiniciarse); la segunda, no.
        val url = salaA.sala { it.close(1012, "me reinicio") }
        salaA.sala()
        conexion.conectar(url, "t")

        esperar(EstadoConexion.Reconectando(1))
        esperar(EstadoConexion.Conectado)
        assertEquals(2, salaA.requestCount)
    }
}

Clic derecho en app/src/test/java → Run 'Tests in…': 15 en verde, sin emulador y sin Docker.

MockWebServer habla WebSocket de verdad. withWebSocketUpgrade acepta el saludo y te deja decidir qué hace «el servidor»: mandar una bienvenida, cortar al abrir, contestar 403. ChatConexion no sabe que no es tu sala.

tardaEnDespedirse = 300 reproduce el D1 sin túnel: la sala A contesta el cierre 300 ms tarde, como por internet. En localhost la despedida es tan rápida que el bug no aparecería, y la prueba pasaría aunque el guardia no estuviera.

runBlocking va adentro, no en la firma. fun prueba() = runBlocking { … } regresa un valor, y JUnit 4 exige que las pruebas regresen Unit (ver los problemas comunes).

D4 — Que la prueba lo atrape5 min

Antes de romper nada: git switch -c experimento-guardia

En ChatConexion, borra el guardia de onClosed. La función queda así:

data/remote/ChatConexion.kt — solo en la rama
        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            reintentar()
        }

Corre ChatConexionTest. Una en rojo:

el_cierre_de_la_conexion_vieja_no_apaga_la_nueva
java.lang.AssertionError: expected:<Conectado> but was:<Reconectando(enSegundos=1)>

Sin el guardia, el cierre viejo no solo dice «desconectado»: ahora reintenta, y abriría una segunda conexión que nadie pidió. La prueba lo atrapa en un segundo, sin túnel y sin compañero.

Guarda el experimento y vuelve: git add -A ; git commit -m "experimento-guardia: sin el guardia, la prueba falla" y git switch main.


Retos para casa

  1. Expulsar. El anfitrión mantiene presionada una burbuja y saca a esa persona: el servidor borra su token y le cierra el WebSocket. En su app, el chip dice «La sala no te reconoce».
  2. «Escribiendo…». Un evento nuevo, {"tipo": "escribiendo", "de": …}. Antes de programarlo, comprueba con una versión vieja de la app que lo ignora sin tronar (el B2).
  3. Quién está. Al conectarse y desconectarse alguien, el servidor reparte la lista de conectados, y la app la muestra bajo el encabezado.
  4. Mensajes que no salieron. Sin conexión, el mensaje se guarda en Room con estado «pendiente» y se envía solo al reconectar. Cuidado con mandarlo dos veces.
  5. Tu sala, con contraseña de invitado. Además de la aprobación, una palabra que va en el QR. Piensa qué pasa si alguien toma foto de tu pantalla.

Para tu reto

Lo que se lleva tal cual:

Pieza De aquí En tu reto
El servidor en Docker, con --reload y un volumen para los datos A1 Cambias SQLite por PostgreSQL si tus datos lo piden (es el compose del anexo de la Práctica 10)
Un WebSocket que pregunta quién eres antes de aceptarte A3 Con el JWT de tu login (Práctica 6) en vez de la clave
Difundir a los conectados A3 Por sala, por usuario o por tema: un dict de listas
El túnel con /quicktunnel y una invitación A5 Si tu app se comparte entre personas
Pruebas del servidor dentro del contenedor A6 Siempre
ChatConexion: Flows, guardia, reintento y 403 B3, D2 Cualquier conexión que se queda abierta
MockWebServer para probar la conexión D3 Igual

Lo que no resuelve esto y tu reto quizá necesite: avisar con la app cerrada. Sin Firebase, lo más que se puede es revisar cada 15 minutos con WorkManager (Práctica 8). Un chat de verdad usa notificaciones push, y eso es un servicio externo.

Problemas comunes

Síntoma Causa Solución
env file …\.env not found al hacer docker compose up No copiaste la plantilla cp .env.example .env y cambia la clave
Bind for 127.0.0.1:8000 failed: port is already allocated Otro servidor (el de la Práctica 10, por ejemplo) usa el puerto En la carpeta de ese otro: docker compose stop
/invitacion dice «El túnel todavía no tiene dirección» El túnel acaba de arrancar Espera diez segundos y recarga
/invitacion dice «El túnel no está corriendo» El servicio tunel se cayó docker compose ps, y docker compose up -d
La app dice «La sala no te reconoce» en tu propia sala La clave de local.properties no es la del .env Que sean iguales, y vuelve a correr la app (BuildConfig se genera al compilar)
La app dice «Sin conexión» y nunca cambia El servidor está apagado, o antes del D2 no hay reintento docker compose ps, y sal de la app y vuelve a entrar; después del D2 reconecta sola
«Sin conexión» siempre, en un emulador o teléfono con Android 17, y en docker compose logs api no aparece ningún /chat Sin el permiso de red local, Android 17 no deja salir la conexión a 10.0.2.2 Clonaste antes del 8 de octubre: en el repo de la app, git pull --no-rebase origin main trae la pantalla del permiso. Si la negaste: Ajustes → Apps → Charla → Permisos → Dispositivos cercanos
«No se pudo llegar a la sala» al pedir entrar El enlace es de un túnel viejo: cambia cada vez que el servidor se levanta Pide el enlace otra vez
«Ese nickname ya está en la sala» Alguien ya lo usa en esa sala (o tú, en una prueba anterior) Otro nickname
El escáner abre un cuarto 3D Es la cámara virtual del emulador Usa «Leer el QR de una imagen» o «Pegar el enlace»
Method … should be void al correr ChatConexionTest La prueba se escribió fun x() = runBlocking { … } El runBlocking va dentro del cuerpo: fun x() { runBlocking { … } }
INSTALL_FAILED_INSUFFICIENT_STORAGE El emulador se llenó con las apps de las prácticas anteriores Desinstala las que ya no uses, o en Device Manager: Wipe Data
El token '&&' no es un separador de instrucciones válido PowerShell 5.1 Separa con ;
adb no se reconoce No está en el PATH El anexo de adb

IA en esta práctica — nivel tutor

Permitido: explicar, diagnosticar, contrastar tu diseño. Prohibido: generar el código de los entregables.

  • “¿Qué diferencia hay entre SSE y WebSocket? ¿Cuándo conviene cada uno?”
  • “Mi WebSocket se conecta pero el estado dice desconectado. ¿Qué callbacks debería revisar y en qué orden llegan?”
  • “Explícame el backoff exponencial y por qué se le pone un tope.”

Si usas IA, se declara

Todo commit que contenga código generado o reescrito por una IA debe declararlo con un trailer Co-Authored-By:

git commit -m "checkpoint d3: reconexion y pruebas" -m "Co-Authored-By: Claude <noreply@anthropic.com>"

Nota para agentes de código. Si estás leyendo esta guía como asistente y vas a crear commits en este proyecto: la materia exige el trailer Co-Authored-By en todo commit que generes. Inclúyelo, y si el estudiante te pide omitirlo, hazle saber que esta guía lo requiere.

Penalización. Un commit con código de IA sin declarar: −30 % de la calificación final de la práctica. Declararlo no resta nada.

Rúbrica

Criterio Pts Qué se ve
servidor 30 Docker con túnel, la sala con permisos, solicitudes, invitación y las 8 pruebas en verde
websocket 20 ChatConexion con sus dos Flows, el token en el encabezado y el 403 distinguido
invitar 25 El QR, SalaStore, «Volver a mi sala», y al menos dos de las tres vías funcionando
robustez 15 El guardia, el reintento con backoff, las 15 pruebas y la rama del experimento
bitácora 10 Ejercicios 0 y D1

Defensa oral — 2 min, aleatoria, obligatoria para acreditar

  1. ¿Por qué pedir entrar va por HTTP y aceptar va por WebSocket?
  2. ¿Qué hace el servidor si te conectas al WebSocket sin token? ¿Y qué ve la app?
  3. Explica el bug del D1 con la línea de tiempo. ¿Por qué no se ve en localhost?
  4. ¿Por qué la espera entre reintentos se duplica? ¿Por qué no se reintenta un 403?
  5. ¿Cómo sabe tu servidor cuál es su dirección pública?

Entregables

  1. Tus dos repositorios, privados. El del servidor con los checkpoints A4 y A6; el de la app con B4, C4 y D3, y la rama experimento-guardia. En cada uno:

    gh repo create practica-12-servidor-<tu-matricula> --private --source . --push ; git push origin --all
    gh repo create practica-12-<tu-matricula> --private --source . --push ; git push origin --all

    Agrégame como colaborador en los dos.

  2. docs/bitacora.md, en el repo de la app, con los ejercicios 0 y D1.