Índice del curso

HTML en móviles · de cero a experto

Aprende a dominar las APIs de hardware de la web móvil: cámara, geolocalización, teclados virtuales, almacenamiento asíncrono IndexedDB y Service Workers offline. El puente completo entre la web y el desarrollo nativo.

35 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil Web APIs 2026
35
Capítulos
120+
Ejemplos de código
3
Niveles: básico a experto
JS básico
Prerrequisito
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables y puntos clave al final. Practica cada ejemplo: es la única vía para dominar cualquier tema.

1 · Web vs. Híbrido vs. Nativo: La gran decisión

Básico ~15 min

Antes de escribir una sola línea de código, debemos responder la pregunta fundamental de arquitectura: ¿Dónde debe ejecutarse nuestra aplicación? Muchos equipos saltan directamente a Kotlin o Flutter creyendo que la web es incapaz de acceder a sensores o interactuar con el hardware. Hoy desmitificamos la web móvil y analizamos sus límites reales frente al mundo nativo.

  • Diferenciar las arquitecturas Web (PWA), Híbrida (Capacitor), Compilada (Flutter) y Nativa (Kotlin/Swift).
  • Desmitificar el rendimiento y analizar el coste de distribución de cada enfoque.
  • Conocer la tabla comparativa de capacidades de hardware por plataforma.
  • Aprender a justificar la elección de tecnología basándose en las necesidades del negocio.

Los cuatro caminos hacia el móvil

Cuando nos encargan construir una aplicación que funcionará en teléfonos móviles, tenemos cuatro alternativas principales de implementación:

Arquitectura Motor de renderizado Acceso a Hardware Distribución
Web Móvil / PWA Navegador del sistema (Blink/WebKit) Web APIs estándares URL directa (instantánea)
Híbrido (Capacitor) WebView nativo (Chrome/WKWebView) Plugins nativos a JS Tiendas (Play Store / App Store)
Compilado (Flutter) Lienzo propio (Impeller/Skia) Puente nativo a Dart Tiendas (Play Store / App Store)
Nativo Puro (Kotlin) Componentes nativos del OS (Compose) Acceso directo a APIs del SDK Tiendas (Play Store / App Store)

El costo de la tienda de aplicaciones

La distribución nativa no es gratuita. Publicar en tiendas de aplicaciones introduce fricción para el usuario (descargar 50MB, actualizar constantemente) y procesos de revisión burocráticos que pueden tardar días en aprobar un parche crítico. En contraste, la web móvil permite actualizaciones inmediatas con un simple refresco del navegador.

# Ciclo de actualización Web
Cambio de código -> Deploy Servidor -> Usuario refresca la página (Listo)

# Ciclo de actualización Tiendas (Play Store / App Store)
Cambio de código -> Compilación -> Subir a consola -> Revisión técnica (12-72 hrs) -> Aprobación -> Despliegue progresivo -> Usuario actualiza manualmente

Mitos de rendimiento

Existe el mito de que "la web es lenta". En el hardware móvil de hoy (incluso en gama media-baja), el motor JavaScript (V8 en Android, JavaScriptCore en iOS) ejecuta código a velocidades cercanas a la nativa gracias a la compilación JIT (Just-In-Time). Los problemas de velocidad en la web móvil casi siempre se deben a un mal diseño del frontend: exceso de DOM, frameworks sobrecargados y falta de optimización del hilo principal.

Puntos clave

  • La Web Móvil elimina la fricción de descarga de las tiendas y permite actualizaciones instantáneas.
  • Capacitor actúa como un puente que envuelve código web en una app nativa con acceso a hardware extendido.
  • El cuello de botella de rendimiento web móvil no es JS, sino la manipulación ineficiente del DOM y del hilo de renderizado.
  • iOS impone el uso de WebKit para todos los navegadores, limitando ciertas capacidades experimentales de la Web.

2 · El Viewport y CSS Mobile-first

Básico ~12 min

Un diseño web adaptado para celulares no consiste únicamente en hacer que los elementos se encojan. Comienza con la forma en que el navegador interpreta las dimensiones de la pantalla física. Hoy estudiaremos la configuración del viewport, las nuevas unidades de altura dinámica en CSS y las técnicas críticas de CSS para pantallas con muescas (safe areas).

  • Configurar correctamente la etiqueta viewport para soporte multi-pantalla y safe-areas.
  • Utilizar las unidades dinámicas de altura (`svh`, `lvh`, `dvh`) para evitar desbordamientos de layout.
  • Dominar las variables de entorno `env()` para áreas seguras (pantallas con notch).
  • Escribir estilos CSS estructurados bajo el estándar Mobile-first.

La etiqueta Viewport indispensable

Por defecto, los navegadores móviles asumen que están renderizando un sitio diseñado para monitores de escritorio (típicamente de 980px de ancho) y escalan el contenido haciendo que se vea diminuto. Para desactivar este comportamiento e indicarle al navegador que renderice a escala real, usamos la siguiente etiqueta:

<!-- Configuración estándar para responsive design y pantallas con notch -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

El parámetro viewport-fit=cover expande el lienzo del sitio para ocupar todo el espacio físico de la pantalla, incluyendo las áreas detrás de muescas superiores (notches) o barras del sistema en iOS y Android.

Safe Areas y variables de entorno

Al activar viewport-fit=cover, corremos el riesgo de que el texto quede oculto debajo de la muesca de la cámara o de la barra de gestos de navegación. Para evitarlo, CSS proporciona las variables de entorno predefinidas safe-area-inset-*:

.header-fijo {
  position: fixed;
  top: 0;
  width: 100%;
  /* Agrega padding superior seguro si hay notch, con un fallback de 15px */
  padding-top: env(safe-area-inset-top, 15px);
  background-color: #ffffff;
  z-index: 100;
}

.footer-acciones {
  position: fixed;
  bottom: 0;
  width: 100%;
  /* Respeta el espacio inferior destinado a la barra de gestos del OS */
  padding-bottom: env(safe-area-inset-bottom, 10px);
}

Unidades de altura dinámica: dvh, svh y lvh

En el pasado, usar height: 100vh en móviles causaba problemas porque las barras de dirección del navegador aparecían y desaparecían al hacer scroll, provocando saltos bruscos en el contenido y ocultando botones en la parte inferior. CSS3 introduce nuevas unidades que solucionan esto:

Unidad Nombre Comportamiento
100svh Short Viewport Height Altura máxima de pantalla cuando las barras del navegador están visibles.
100lvh Long Viewport Height Altura máxima de pantalla cuando las barras del navegador están ocultas.
100dvh Dynamic Viewport Height Altura dinámica que se ajusta automáticamente en tiempo real al expandirse/contraerse las barras.
/* Contenedor principal que ocupa toda la pantalla útil del dispositivo sin desbordamientos */
.pantalla-completa {
  height: 100dvh;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

Puntos clave

  • viewport-fit=cover le permite a la web utilizar todo el lienzo físico de la pantalla móvil.
  • Las variables de entorno env(safe-area-inset-*) protegen la UI de solapamientos con elementos de hardware.
  • Usa dvh para contenedores de pantalla completa para evitar saltos y ocultamiento de botones bajo las barras dinámicas del navegador.
  • La estructura CSS Mobile-first optimiza el procesamiento al cargar estilos ligeros base antes de evaluar reglas responsive más complejas.

3 · Eventos Táctiles y Gestos

Intermedio ~14 min

En celulares, el puntero del mouse no existe. Los usuarios interactúan usando toques simples, toques múltiples y gestos como deslizamientos (swipes) o pellizcos (pinches). Hoy estudiaremos cómo capturar eventos táctiles a nivel de JavaScript nativo, la API unificada de Pointer Events y cómo optimizar la experiencia eliminando la latencia de respuesta física.

  • Eliminar el retraso (delay) táctil de 300ms nativamente con CSS.
  • Comprender la diferencia y estructura de `touches`, `targetTouches` y `changedTouches`.
  • Implementar listeners híbridos de mouse y touch utilizando la API unificada de `Pointer Events`.
  • Construir un script en JS nativo para detectar gestos de deslizamiento lateral (swipe).

El fin de la latencia de 300ms

Históricamente, los navegadores móviles introducían un retraso de 300ms en el disparo de los eventos click. Este delay servía para esperar si el usuario hacía un segundo toque para hacer zoom (doble tap). En aplicaciones modernas, esto arruina la sensación de fluidez ("responsiveness"). Para eliminarlo por completo de forma declarativa, usamos CSS:

/* Desactiva gestos de zoom del navegador pero elimina la latencia de click inmediatamente */
.boton-tactil {
  touch-action: manipulation;
  cursor: pointer;
}

Estructura de un evento táctil

Un evento táctil contiene colecciones de toques activos. No accedemos directamente a la coordenada como en e.clientX, sino que debemos seleccionar el toque específico dentro de una de las tres listas disponibles:

  • touches: Lista de TODOS los dedos actualmente apoyados en la pantalla.
  • targetTouches: Lista de los dedos apoyados en el elemento que disparó el evento.
  • changedTouches: Lista de los dedos que provocaron el evento actual (útil para touchend).
elemento.addEventListener('touchstart', function(e) {
  // Obtiene las coordenadas del primer dedo apoyado en este elemento
  const primerToque = e.targetTouches[0];
  console.log(`Toque en: X=${primerToque.clientX}, Y=${primerToque.clientY}`);
}, { passive: true });

Swipe Detection en JS nativo

A continuación, implementamos un detector básico de deslizamiento lateral (Swipe) hacia la izquierda o derecha en JS puro, útil para cerrar notificaciones o cambiar de tarjetas de forma táctil:

let xInicial = null;

const contenedor = document.getElementById('caja-deslizable');

contenedor.addEventListener('touchstart', function(e) {
  xInicial = e.touches[0].clientX;
}, { passive: true });

contenedor.addEventListener('touchend', function(e) {
  if (!xInicial) return;

  const xFinal = e.changedTouches[0].clientX;
  const diferenciaX = xInicial - xFinal;
  const umbralMinimo = 50; // Distancia mínima en px para considerar el gesto

  if (Math.abs(diferenciaX) > umbralMinimo) {
    if (diferenciaX > 0) {
      console.log('Swipe detectado hacia la IZQUIERDA');
      contenedor.dispatchEvent(new CustomEvent('swipe-left'));
    } else {
      console.log('Swipe detectado hacia la DERECHA');
      contenedor.dispatchEvent(new CustomEvent('swipe-right'));
    }
  }
  xInicial = null;
}, { passive: true });

Pointer Events: El estándar unificado

En lugar de escribir código separado para mouse y eventos táctiles, la API de Pointer Events unifica ratón, toques de dedos y lápices ópticos (stylus) en un solo conjunto de eventos:

// Funciona para mouse click, finger touch o stylus pen
elemento.addEventListener('pointerdown', function(e) {
  console.log(`Entrada de tipo: ${e.pointerType}`); // "mouse", "touch" o "pen"
  console.log(`Presión aplicada: ${e.pressure}`);   // Entre 0 y 1
});

Puntos clave

  • touch-action: manipulation; en CSS deshace el delay de 300ms de click de manera ágil.
  • En touchend, la lista e.touches estará vacía; las coordenadas finales se leen de e.changedTouches.
  • La API de Pointer Events unifica dispositivos en eventos como `pointerdown`, `pointermove` y `pointerup`.
  • Configurar listeners de toques pesados con { passive: true } mantiene la inercia de scroll a 60fps.

4 · Depuración remota y emuladores

Básico ~12 min

Probar el diseño móvil encogiendo el navegador de la computadora es útil para maquetar, pero oculta la realidad: no evalúa el rendimiento térmico de un procesador móvil, las latencias de las redes móviles ni las particularidades de los navegadores móviles reales. Hoy aprenderemos a inspeccionar y depurar nuestras aplicaciones directamente en dispositivos físicos reales usando herramientas profesionales.

  • Conectar un dispositivo Android real a Chrome DevTools mediante depuración USB.
  • Depurar una web en Safari de iOS usando el Inspector de Desarrollo en macOS.
  • Configurar Port Forwarding para cargar un servidor local de desarrollo (`localhost`) en el teléfono físico.
  • Simular coberturas móviles deficientes (3G/2G) e inyectar coordenadas de GPS ficticias desde las DevTools.

Android y Chrome DevTools USB

Para depurar en Android, el primer paso es preparar el teléfono activando el modo desarrollador (presionando 7 veces consecutivas sobre "Número de compilación" en Ajustes del teléfono) y activando la opción Depuración por USB. Posteriormente:

# 1. Conecta el teléfono a la computadora mediante cable USB
# 2. Abre Google Chrome en tu computadora e ingresa a:
chrome://inspect

# 3. En el teléfono aparecerá un aviso de confirmación; acéptalo.
# 4. Verás las pestañas abiertas en el Chrome de tu móvil. Haz click en "inspect".

Se abrirá una ventana de DevTools en tu PC que muestra exactamente la pantalla de tu móvil en tiempo real. Puedes inspeccionar elementos HTML del celular, ver errores en consola de JavaScript y medir rendimiento como si depuraras en escritorio.

iOS y Safari Web Inspector

Para inspeccionar un iPhone o iPad, es necesario contar con una computadora Mac. Primero, activa la depuración en el móvil en: Ajustes → Safari → Avanzado → Inspector web (activar). Luego:

1. Conecta el iPhone a la Mac por cable.
2. Abre Safari en la Mac y ve al menú "Desarrollo" en la barra superior.
3. Coloca el cursor sobre el nombre de tu iPhone y selecciona la pestaña web activa que deseas depurar.

Acceder a localhost desde el celular

Cuando estamos programando en la PC, nuestro sitio vive en http://localhost:3000. Si queremos cargarlo en el teléfono, tenemos dos opciones:

  • Dirección IP Local: Conectar ambos dispositivos a la misma red Wi-Fi y entrar desde el móvil usando la IP de la PC (ej. http://192.168.1.15:3000).
  • Port Forwarding (Recomendado): A través del panel `chrome://inspect`, añade una regla de reenvío de puerto. Esto mapea el puerto del PC directamente al cable USB, permitiendo ingresar en el móvil a http://localhost:3000 de forma directa.

Mocking de sensores y redes

En Chrome DevTools de la PC, a través del panel de inspección de tu teléfono real, puedes abrir el menú de tres puntos, seleccionar More tools → Sensors. Desde allí podrás:

  • Simular coordenadas GPS predefinidas (ej. Londres, Tokio) o coordenadas exactas personalizadas para probar el comportamiento de tu geolocalización.
  • Controlar la orientación tridimensional del giroscopio del móvil arrastrando un modelo 3D físico en pantalla.

Puntos clave

  • La depuración remota USB permite depurar webs en vivo en el motor del celular usando el teclado y monitor de la PC.
  • En Android se usa chrome://inspect; en iOS se requiere macOS y el inspector de Safari.
  • El Port Forwarding USB soluciona las restricciones de contexto seguro al permitir que el móvil acceda a localhost por HTTP.
  • La pestaña Sensors de DevTools permite falsear de forma controlada la ubicación y la orientación del giroscopio.

5 · Rendimiento en dispositivos móviles

Intermedio ~12 min

Un celular de gama media no es una computadora de escritorio pequeña. Sus núcleos de CPU son más lentos, la memoria RAM es limitada y el estrangulamiento térmico (thermal throttling) reduce la velocidad del procesador a los pocos minutos de uso intenso para ahorrar batería y evitar el sobrecalentamiento. Hoy estudiaremos cómo diseñar webs de alto rendimiento móvil, optimizando el ciclo de renderizado y aprovechando la GPU.

  • Comprender el impacto del hardware móvil (CPU, GPU y estrangulamiento térmico) en el navegador.
  • Optimizar el ciclo de renderizado evitando Reflows y Repaints en el hilo principal.
  • Activar la aceleración por hardware mediante CSS eficiente (`will-change`).
  • Implementar optimizaciones modernas de renderizado virtual con `content-visibility`.

El ciclo de renderizado: Layout, Paint y Composite

Cuando modificamos la UI con CSS o JavaScript, el navegador móvil ejecuta un proceso de tres etapas para pintar los cambios en pantalla:

  • Layout (Reflow): El navegador calcula el tamaño y posición de cada elemento. Modificar propiedades como width, height, margin o top fuerza al navegador a recalcular todo el layout de la página, consumiendo mucha CPU.
  • Paint (Repaint): El navegador rellena los píxeles de los elementos (colores, fondos, sombras). Modificar background-color o box-shadow fuerza un repintado sin alterar la estructura física.
  • Composite: El navegador une las diferentes capas de la página para dibujarlas en pantalla. Las únicas propiedades que se procesan directamente aquí (saltándose Layout y Paint) son transform y opacity.

Aceleración por Hardware (GPU)

Para lograr animaciones fluidas a 60 cuadros por segundo (FPS) en móviles, debemos delegar el trabajo de renderizado de la CPU a la GPU. Esto se logra creando capas independientes en el compositor de CSS:

/* Enfoque moderno: avisa al navegador que prepare la GPU para animar esta propiedad */
.menu-deslizable {
  will-change: transform;
  transition: transform 0.3s ease-out;
}

/* Enfoque antiguo (hack de compatibilidad de capa 3D) */
.capa-gpu-legacy {
  transform: translate3d(0, 0, 0);
}

Renderizado diferido con content-visibility

Una página móvil con mucho contenido (como un catálogo de productos) puede volverse pesada debido a la cantidad de nodos en el DOM. Con la propiedad moderna content-visibility: auto, le indicamos al navegador móvil que no renderice los elementos que están fuera de la pantalla (viewport) hasta que el usuario haga scroll cerca de ellos:

/* Aplica a secciones largas que están fuera de la vista inicial */
.tarjeta-catálogo {
  content-visibility: auto;
  /* Define una altura estimada para evitar saltos en la barra de scroll */
  contain-intrinsic-size: auto 320px;
}

Tabla de impacto de propiedades CSS

Propiedad CSS Layout (CPU) Paint (CPU) Composite (GPU) Recomendación Móvil
transform No No Excelente para mover, rotar o escalar elementos.
opacity No No Excelente para desvanecimientos (fade).
top / left Evitar. Reemplazar con transform: translate().
width / height Evitar en transiciones. Provoca lag severo en CPU.

Puntos clave

  • El exceso de Layout/Reflow satura el hilo principal de la CPU móvil, causando tirones visuales (jank).
  • Usa transform y opacity para transiciones; se procesan directo en la GPU mediante composite.
  • content-visibility: auto; ahorra CPU y RAM al omitir el pintado de elementos fuera de pantalla.
  • Usa will-change solo en elementos persistentes o interactivos que realmente se beneficien de la GPU.

6 · Captura rápida con HTML

Básico ~10 min

Cuando un proyecto móvil requiere que el usuario tome una foto de perfil, grabe un clip de audio o escanee un documento para subirlo al servidor, no siempre es necesario implementar flujos JS complejos. HTML posee capacidades nativas para invocar las cámaras y micrófonos del sistema operativo con una sola etiqueta y cero JavaScript. Hoy estudiaremos el atributo `capture` y sus virtudes.

  • Utilizar el atributo capture en elementos <input type="file"> para abrir las herramientas multimedia del OS.
  • Controlar la selección entre cámara trasera (`environment`) y cámara delantera (`user`).
  • Capturar videos y audios del sistema directamente.
  • Implementar una previsualización de imagen instantánea usando `URL.createObjectURL()`.

El atributo capture y la delegación al OS

Al agregar el atributo capture a un campo de selección de archivos, el navegador móvil no abre el explorador de archivos tradicional. En su lugar, lanza directamente la aplicación de cámara o grabadora de voz nativa del dispositivo:

<!-- Abre la cámara trasera para tomar una foto -->
<input type="file" accept="image/*" capture="environment" id="foto-trasera">

<!-- Abre la cámara delantera para tomar un selfie -->
<input type="file" accept="image/*" capture="user" id="foto-selfie">

<!-- Abre la videocámara del sistema para grabar un video -->
<input type="file" accept="video/*" capture="environment" id="video-registro">

<!-- Abre la grabadora de voz del sistema -->
<input type="file" accept="audio/*" capture="user" id="nota-voz">

El valor environment solicita la cámara trasera (orientada al entorno), mientras que user solicita la cámara frontal (orientada al usuario).

Previsualización inmediata del archivo capturado

Aunque la captura se delega al sistema operativo, una vez que el usuario toma la foto y confirma, el archivo se entrega al navegador. Podemos usar JavaScript para mostrar una vista previa instantánea antes de subirlo al servidor:

const inputFoto = document.getElementById('foto-trasera');
const imgPreview = document.getElementById('vista-previa');

inputFoto.addEventListener('change', function() {
  const archivo = inputFoto.files[0];
  if (archivo) {
    // Crea una URL temporal en memoria que apunta al archivo físico tomado
    const urlTemporal = URL.createObjectURL(archivo);
    imgPreview.src = urlTemporal;
    imgPreview.style.display = 'block';
    
    // Libera memoria una vez que la imagen se ha cargado en el DOM
    imgPreview.onload = () => URL.revokeObjectURL(urlTemporal);
  }
});

Ventajas y límites del enfoque declarativo

Característica Declarativo (capture) Imperativo (Web APIs)
Líneas de JS 0 (básico) Muchas (inicializar, canvas, permisos)
Solicitud de permisos No requiere permiso especial de navegador Requiere confirmación explícita del usuario
Interfaz de cámara La del OS (robusta, con zoom, flash nativo) Lienzo HTML (hay que construir los botones)
Previsualización en vivo No (sales de la web a la cámara) (dentro del diseño de tu web)
Escaneo en tiempo real Imposible (lectores de QR, OCR en vivo)

Puntos clave

  • capture="environment" abre la cámara trasera, y capture="user" abre la frontal.
  • No requiere pedir permisos de cámara explícitos mediante JS, ya que el sistema operativo se encarga de la seguridad al abrir su app nativa.
  • Usa URL.createObjectURL() para generar una previsualización de imagen instantánea de lo capturado.
  • Es la mejor opción para cargas simples como fotos de perfil, imágenes de comprobantes o firmas en fotos.

7 · MediaDevices API: El flujo continuo

Intermedio ~15 min

Cuando requerimos procesar la imagen de la cámara en tiempo real, como para escanear un código de barras, leer un código QR o aplicar un filtro facial interactivo, la delegación al sistema operativo no es suficiente. Necesitamos incrustar el video en vivo directamente dentro de nuestra maquetación HTML. Hoy aprenderemos a utilizar la API de MediaDevices para capturar flujos multimedia.

  • Solicitar acceso a la cámara y micrófono del móvil usando `navigator.mediaDevices.getUserMedia()`.
  • Inyectar y reproducir el flujo de video directamente en un elemento <video> de HTML5.
  • Aprender a apagar físicamente la cámara recorriendo los canales (`tracks`) del flujo activo.
  • Configurar correctamente los permisos obligatorios para evitar bloqueos en iOS y Android.

Petición del flujo continuo en JavaScript

Para iniciar la cámara, llamamos al método getUserMedia() pasando un objeto de restricciones (constraints). Este método devuelve una promesa que, de ser aceptada por el usuario, entrega un objeto de tipo MediaStream:

const videoElement = document.getElementById('mi-camara');

const restricciones = {
  video: {
    width: { ideal: 1280 },
    height: { ideal: 720 }
  },
  audio: false // Desactivamos el micrófono para evitar acoples de sonido
};

let streamActivo = null;

navigator.mediaDevices.getUserMedia(restricciones)
  .then(function(stream) {
    streamActivo = stream;
    // Asigna el flujo directamente al elemento de video HTML
    videoElement.srcObject = stream;
    videoElement.play();
  })
  .catch(function(error) {
    if (error.name === 'NotAllowedError') {
      alert('El usuario denegó los permisos de cámara.');
    } else {
      console.error('Error al iniciar cámara: ', error);
    }
  });

El atributo crítico: playsinline

En dispositivos móviles (especialmente en iPhones e iPads), los videos tienden a reproducirse por defecto en el reproductor nativo del sistema en pantalla completa. Para incrustar el video dentro de tu diseño web y poder colocar botones o texto encima, debes configurar los siguientes atributos HTML:

<!-- playsinline es indispensable en iOS. autoplay y muted aseguran el inicio inmediato -->
<video id="mi-camara" autoplay muted playsinline class="w-100 border rounded"></video>

Apagar la cámara física y liberar recursos

Mantener la cámara encendida consume una cantidad masiva de energía, calentando el celular y agotando la batería. Cuando el usuario sale del flujo o cambia de sección en tu aplicación, debes apagar físicamente el hardware deteniendo cada una de las pistas (tracks) del stream:

function apagarCamara() {
  if (streamActivo) {
    // Obtiene todas las pistas del stream (video e incluso audio si estuviera activo)
    const pistas = streamActivo.getTracks();
    
    pistas.forEach(function(pista) {
      pista.stop(); // Apaga físicamente el sensor de la cámara en el teléfono
    });
    
    videoElement.srcObject = null;
    streamActivo = null;
    console.log('Cámara apagada y recurso liberado.');
  }
}

Puntos clave

  • getUserMedia() requiere permisos explícitos del navegador y solo funciona bajo contextos seguros (HTTPS / Localhost).
  • El atributo HTML playsinline en la etiqueta <video> previene la pantalla completa forzada en dispositivos Apple.
  • Para iniciar la reproducción del flujo inmediatamente en móviles se requiere la dupla autoplay muted.
  • Siempre ejecuta track.stop() al desmontar o cerrar la vista para evitar drenar la batería del teléfono.

8 · Cámara avanzada: Frontal vs. Trasera

Avanzado ~15 min

En aplicaciones web reales no solo queremos mostrar la cámara en pantalla; necesitamos que el usuario pueda alternar entre la cámara selfie y la principal, capturar una foto instantánea en alta resolución y procesarla para subirla a un servidor como archivo binario. Hoy implementaremos un sistema completo de captura interactiva con JavaScript y la API de Canvas.

  • Configurar `facingMode` para forzar o alternar entre cámara frontal (`user`) y trasera (`environment`).
  • Implementar un botón de alternancia (toggle) que apague y reencienda la cámara con restricciones opuestas.
  • Capturar el fotograma actual del video usando la API de Canvas a resolución real de hardware.
  • Convertir el canvas a un archivo binario `Blob` (JPEG/PNG) para subidas eficientes con `Fetch` y `FormData`.

Restricciones de orientación y facingMode

Para indicarle al navegador móvil qué cámara física deseamos abrir, utilizamos la propiedad facingMode de la API de restricciones:

// Solicita la cámara frontal (cámara selfie)
const configFrontal = {
  video: { facingMode: "user" }
};

// Solicita estrictamente la cámara trasera (cámara principal)
const configTrasera = {
  video: { facingMode: { exact: "environment" } }
};

Nota importante de compatibilidad: Evita usar el modificador exact si el sitio también se abrirá en computadoras portátiles o de escritorio, ya que si el dispositivo no posee una cámara que coincida exactamente con la solicitud (como una iMac que solo tiene cámara frontal), la promesa fallará arrojando un error de tipo OverconstrainedError.

Implementación de un selector dinámico (Toggle)

Para alternar entre cámaras en móviles, debemos detener por completo el stream actual y volver a iniciar un flujo con la restricción contraria. Aquí tienes la lógica estructurada:

let modoCamara = "user"; // Iniciamos con cámara frontal

function cambiarCamara() {
  apagarCamara(); // Detiene el stream actual (función del cap anterior)
  
  // Alterna el modo
  modoCamara = (modoCamara === "user") ? "environment" : "user";
  
  const nuevasRestricciones = {
    video: { facingMode: modoCamara }
  };
  
  // Re-inicia la cámara con la nueva configuración
  iniciarCamara(nuevasRestricciones);
}

Captura de foto con Canvas y conversión a Blob

Para congelar y extraer la imagen de la cámara web, dibujamos el fotograma actual del elemento de video sobre un elemento <canvas> oculto en memoria. Posteriormente, exportamos ese lienzo a un archivo binario real de tipo Blob:

const botonCapturar = document.getElementById('boton-capturar');
const canvasOculto = document.createElement('canvas');

botonCapturar.addEventListener('click', function() {
  const context = canvasOculto.getContext('2d');
  
  // Ajusta las dimensiones del canvas al tamaño real de captura del hardware
  canvasOculto.width = videoElement.videoWidth;
  canvasOculto.height = videoElement.videoHeight;
  
  // Dibuja el fotograma actual del video en el lienzo
  context.drawImage(videoElement, 0, 0, canvasOculto.width, canvasOculto.height);
  
  // Convierte el lienzo a un archivo JPEG comprimido al 90%
  canvasOculto.toBlob(function(blob) {
    if (blob) {
      console.log(`Foto capturada con éxito. Tamaño: ${blob.size} bytes`);
      subirAlServidor(blob);
    }
  }, 'image/jpeg', 0.90);
});

function subirAlServidor(blobFoto) {
  const datosFormulario = new FormData();
  // Añadimos el blob de imagen como si fuera un campo de input tipo file tradicional
  datosFormulario.append('foto_evidencia', blobFoto, 'captura.jpg');
  
  fetch('/api/subir-foto', {
    method: 'POST',
    body: datosFormulario
  })
  .then(res => res.json())
  .then(datos => console.log('Subida completada: ', datos))
  .catch(err => console.error('Error de subida: ', err));
}

Puntos clave

  • facingMode: "user" selecciona la cámara de selfies; facingMode: "environment" selecciona la de paisajes.
  • No utilices exact en facingMode de forma descuidada para evitar fallos de inicialización en dispositivos mono-cámara.
  • Para alternar cámaras en móviles, detén siempre las pistas del stream activo antes de solicitar uno nuevo.
  • canvas.toBlob() es más eficiente que toDataURL() para enviar imágenes al servidor, ya que envía binario puro en vez de base64.

9 · Geolocalización API

Intermedio ~12 min

Una gran ventaja de los celulares es la movilidad, y con ella, la necesidad de conocer dónde se encuentra el dispositivo. Ya sea para ubicar la sucursal más cercana o registrar la coordenada exacta donde se prestó un servicio médico a domicilio, la Geolocalización API del navegador nos permite acceder al hardware del GPS físico. Hoy estudiaremos cómo usarla de forma profesional.

  • Solicitar coordenadas precisas (latitud, longitud, altitud) usando la Geolocalización API.
  • Configurar restricciones avanzadas de precisión (`enableHighAccuracy`), tiempos límite (`timeout`) y caché.
  • Implementar rastreo continuo en tiempo real usando `watchPosition` y cancelarlo con `clearWatch`.
  • Analizar los límites reales de rastreo en segundo plano de la Web frente a Kotlin y Flutter.

Obtener ubicación única (getCurrentPosition)

Para obtener la posición actual una sola vez, llamamos a getCurrentPosition(). Este método requiere dos funciones de callback (éxito y error) y un objeto opcional de configuración:

const opcionesGps = {
  enableHighAccuracy: true, // Forzar uso del GPS de hardware (más preciso pero gasta batería)
  timeout: 10000,          // Tiempo límite para obtener la lectura (10 segundos)
  maximumAge: 0            // No usar lecturas cacheadas en el historial
};

navigator.geolocation.getCurrentPosition(
  function(posicion) {
    const lat = posicion.coords.latitude;
    const lng = posicion.coords.longitude;
    const precision = posicion.coords.accuracy; // Precisión de la lectura en metros
    
    console.log(`Ubicación obtenida: Lat=${lat}, Lng=${lng} (Precisión: ${precision}m)`);
  },
  function(error) {
    switch(error.code) {
      case error.PERMISSION_DENIED:
        console.error("El usuario rechazó la solicitud de localización.");
        break;
      case error.POSITION_UNAVAILABLE:
        console.error("Información de ubicación no disponible (ej. sin señal satelital).");
        break;
      case error.TIMEOUT:
        console.error("Se agotó el tiempo de espera para obtener la ubicación.");
        break;
    }
  },
  opcionesGps
);

Seguimiento en tiempo real (watchPosition)

Si la aplicación requiere seguir la ruta del usuario mientras se desplaza, no debemos usar intervalos repetitivos de getCurrentPosition. Para ello existe watchPosition(), el cual se dispara automáticamente cada vez que el chip de hardware detecta un cambio de coordenadas:

// Inicia el seguimiento continuo
const idSeguimiento = navigator.geolocation.watchPosition(
  function(posicion) {
    console.log(`Nueva coordenada: Lat=${posicion.coords.latitude}, Lng=${posicion.coords.longitude}`);
  },
  function(err) {
    console.error("Error en seguimiento: ", err);
  },
  opcionesGps
);

// Detener el seguimiento cuando ya no sea necesario (ej. usuario cerró el viaje)
function detenerGps() {
  navigator.geolocation.clearWatch(idSeguimiento);
  console.log("Rastreador GPS apagado.");
}

Límites honestos: Rastreo en segundo plano

Aquí es donde chocamos con uno de los límites más estrictos de la Web frente a las aplicaciones nativas:

Capacidad Web / PWA Kotlin / Flutter (Nativo)
Rastreo en primer plano (pantalla encendida) (completo) (completo)
Rastreo con pantalla apagada / bloqueada No (el navegador suspende el hilo) (mediante servicios de foreground)
Rastreo con app minimizada (segundo plano) No (se bloquea por seguridad del OS) (con permisos de fondo y wake locks)
Integración con mapas (Leaflet / Google Maps) (fácil con librerías JS) (SDKs nativos pesados)

Puntos clave

  • getCurrentPosition es asíncrono y devuelve latitud, longitud y precisión (accuracy) en metros.
  • enableHighAccuracy: true fuerza al chip GPS a encenderse, mientras que `false` usa Wi-Fi/redes celulares (más rápido pero impreciso).
  • Usa watchPosition para actualizaciones reactivas automáticas basadas en cambios físicos de posición.
  • La Web **no permite** geolocalización en segundo plano persistente con la pantalla bloqueada debido a políticas de privacidad del sistema operativo móvil.

10 · Sensores de movimiento y orientación

Avanzado ~14 min

Los teléfonos inteligentes contienen sensores de precisión como giroscopios y acelerómetros para conocer su orientación tridimensional y detectar movimientos físicos del usuario. En la web móvil podemos acceder a estos componentes a través de eventos JS dedicados. Hoy estudiaremos la física detrás de `DeviceOrientation` y `DeviceMotion`, y cómo resolver las restricciones de permisos especiales de Apple.

  • Leer la orientación espacial en tres ejes (Alpha, Beta y Gamma) usando `DeviceOrientationEvent`.
  • Medir la aceleración física del dispositivo y fuerzas de gravedad con `DeviceMotionEvent`.
  • Implementar la solicitud de permisos obligatoria para sensores en sistemas iOS 13+.
  • Construir un script en JS nativo para detectar sacudidas del teléfono (Shake Gesture).

DeviceOrientation: Alfa, Beta y Gamma

El evento deviceorientation nos entrega la rotación del teléfono en base a tres coordenadas espaciales:

  • Alpha (α): Rotación alrededor del eje Z (0 a 360 grados). Equivale a la brújula (dirección magnética).
  • Beta (β): Rotación alrededor del eje X (-180 a 180 grados). Inclinación hacia adelante o hacia atrás.
  • Gamma (γ): Rotación alrededor del eje Y (-90 a 90 grados). Inclinación hacia la izquierda o derecha.
window.addEventListener('deviceorientation', function(e) {
  const inclinacionAdelante = Math.round(e.beta);
  const inclinacionLateral = Math.round(e.gamma);
  const brujula = Math.round(e.alpha);
  
  console.log(`Brújula: ${brujula}°, Beta: ${inclinacionAdelante}°, Gamma: ${inclinacionLateral}°`);
});

DeviceMotion y el detector de sacudidas (Shake)

El evento devicemotion mide la aceleración física aplicada sobre el dispositivo (en m/s²). Esto nos permite identificar movimientos bruscos como cuando el usuario agita o sacude el celular para limpiar un formulario o disparar un evento de deshacer:

let xAnterior = null, yAnterior = null, zAnterior = null;
let ultimaLectura = Date.now();
const umbralSacudida = 15; // Sensibilidad del movimiento (fuerza G)

window.addEventListener('devicemotion', function(e) {
  const aceleracion = e.accelerationIncludingGravity;
  if (!aceleracion.x) return;

  const ahora = Date.now();
  if ((ahora - ultimaLectura) > 100) { // Evaluar cada 100ms
    const dx = aceleracion.x - (xAnterior || aceleracion.x);
    const dy = aceleracion.y - (yAnterior || aceleracion.y);
    const dz = aceleracion.z - (zAnterior || aceleracion.z);
    
    // Cálculo simplificado del vector de fuerza de cambio
    const fuerzaCambio = Math.sqrt(dx*dx + dy*dy + dz*dz) / (ahora - ultimaLectura) * 10000;
    
    if (fuerzaCambio > umbralSacudida) {
      console.log("¡Sacudida detectada!");
      alert("Has sacudido el teléfono. Acción ejecutada.");
    }
    
    xAnterior = aceleracion.x;
    yAnterior = aceleracion.y;
    zAnterior = aceleracion.z;
    ultimaLectura = ahora;
  }
});

El permiso especial de iOS 13+

Por seguridad y para mitigar ataques de fingerprinting, Apple bloqueó el acceso a los sensores de forma predeterminada. En iOS, debes solicitar permiso explícito al usuario a través de un diálogo nativo disparado obligatoriamente por una interacción directa (por ejemplo, al pulsar un botón):

const botonPermiso = document.getElementById('activar-sensores');

botonPermiso.addEventListener('click', function() {
  // Comprobamos si el navegador móvil requiere solicitar permiso
  if (typeof DeviceOrientationEvent.requestPermission === 'function') {
    DeviceOrientationEvent.requestPermission()
      .then(respuesta => {
        if (respuesta === 'granted') {
          console.log("Permisos otorgados en iOS.");
          // Aquí inicias los event listeners de deviceorientation o devicemotion
        } else {
          alert("Permisos de sensores denegados.");
        }
      })
      .catch(console.error);
  } else {
    // En navegadores que no son iOS (Android/Escritorio), el acceso suele ser directo
    console.log("Acceso directo a sensores disponible.");
  }
});

Puntos clave

  • deviceorientation entrega los ejes Alpha (brújula), Beta (inclinación adelante/atrás) y Gamma (inclinación lateral).
  • devicemotion mide la aceleración con o sin gravedad en ejes X, Y y Z en metros por segundo cuadrado (m/s²).
  • En dispositivos Apple con iOS 13+, debes invocar DeviceOrientationEvent.requestPermission() tras un click del usuario.
  • El detector de sacudidas se calcula evaluando las diferencias de aceleración en intervalos regulares de tiempo.

11 · Teclados virtuales optimizados

Básico ~12 min

Escribir en una pantalla táctil pequeña es incómodo y propenso a errores. Como desarrolladores web, tenemos la obligación de ponérselo fácil al usuario mostrando automáticamente la variante de teclado virtual adecuada según el dato solicitado (números, correos, búsquedas o teléfonos). Hoy estudiaremos los atributos HTML que controlan los teclados de iOS y Android.

  • Utilizar el atributo `inputmode` para desplegar teclados optimizados de números, correos, URLs y más.
  • Configurar `enterkeyhint` para modificar la etiqueta visual de la tecla de acción (enviar, buscar, siguiente).
  • Optimizar la captura desactivando correcciones automáticas y capitalizaciones en campos técnicos.
  • Evitar los errores típicos del tipo `number` en móviles usando combinaciones de atributos seguras.

El atributo clave: inputmode

El atributo inputmode le indica al navegador del dispositivo móvil qué tipo de teclado virtual debe abrir cuando el usuario hace foco en un elemento <input> o <textarea>, sin importar el valor de type:

<!-- Muestra el teclado telefónico (pad numérico básico) -->
<input type="text" inputmode="tel" name="telefono">

<!-- Muestra un teclado numérico con punto decimal -->
<input type="text" inputmode="decimal" name="monto_pago">

<!-- Muestra teclado numérico puro (solo dígitos, ideal para PIN/documentos) -->
<input type="text" inputmode="numeric" name="dni">

<!-- Muestra teclado con letras, incluyendo la arroba (@) y el botón .com -->
<input type="text" inputmode="email" name="correo">

<!-- Muestra teclado optimizado para ingresar direcciones web (con barra / y .com) -->
<input type="text" inputmode="url" name="sitio_web">

Guiar el flujo con enterkeyhint

Por defecto, la tecla inferior derecha del teclado móvil es un simple retorno de carro ("Enter" o una flecha). Con enterkeyhint, podemos cambiar esa tecla para guiar al usuario en el flujo del formulario, modificando tanto su ícono como su comportamiento en pantalla:

<!-- El botón del teclado dirá "Enviar" (Send) o mostrará un ícono de avión de papel -->
<input type="text" enterkeyhint="send" id="mensaje-chat">

<!-- El botón del teclado dirá "Buscar" (Search) o mostrará una lupa -->
<input type="search" enterkeyhint="search" id="campo-busqueda">

<!-- El botón del teclado dirá "Siguiente" (Next), ideal para formularios multi-campo -->
<input type="text" enterkeyhint="next" id="primer-nombre">

Desactivar políticas automáticas molestas

Para campos técnicos como nombres de usuario, códigos de verificación o claves complejas, el auto-corrector y la mayúscula automática del celular generan errores constantes de digitación. Es buena práctica desactivar estas políticas manualmente:

<!-- Desactiva mayúsculas iniciales automáticas, auto-corrector y sugerencias de texto -->
<input type="text"
       autocapitalize="none"
       autocorrect="off"
       spellcheck="false"
       name="usuario_app">

El error de type="number" en móviles

Muchos desarrolladores novatos colocan type="number" para capturar números de tarjetas de crédito o documentos de identidad. Esto es una mala práctica:

Implementación Comportamiento en Móvil Problemas
type="number" Abre teclado numérico ordinario Añade flechas incrementales molestas, formatea con comas y puntos decimales indeseados, y puede recortar ceros a la izquierda.
type="text" inputmode="numeric" pattern="[0-9]*" Abre teclado numérico puro (seguro) Ninguno. Mantiene la cadena exacta, permite ceros a la izquierda y es soportado universalmente.

Puntos clave

  • inputmode define la variante de teclado a nivel visual (numeric, tel, email, decimal) sin alterar la validación semántica del formulario.
  • enterkeyhint cambia la tecla de acción (send, search, next, go, done) para adecuarla al contexto del flujo.
  • Desactiva autocorrect="off" y autocapitalize="none" en inputs de códigos de barras, IDs de usuario y tokens.
  • Usa type="text" inputmode="numeric" pattern="[0-9]*" para recolectar números de identificación sin formateos no deseados.

12 · Visual Viewport API

Avanzado ~15 min

Uno de los dolores de cabeza más comunes al diseñar interfaces móviles es el comportamiento de los elementos flotantes (como barras de chat fijadas abajo o botones de pago) cuando se abre el teclado virtual. En móviles, las propiedades CSS tradicionales como `position: fixed; bottom: 0;` actúan de forma inconsistente. Hoy estudiaremos la Visual Viewport API y cómo reaccionar con precisión milimétrica al teclado.

  • Diferenciar el Layout Viewport (tamaño de la ventana web) del Visual Viewport (lienzo visual útil).
  • Escuchar eventos dinámicos de redimensionamiento y desplazamiento del Visual Viewport en JS.
  • Calcular la altura exacta del teclado virtual del dispositivo.
  • Reposicionar elementos fijos sobre el teclado para evitar que queden ocultos o desfasados.

Layout Viewport vs. Visual Viewport

Los navegadores modernos manejan dos tipos de viewports:

  • Layout Viewport: La ventana completa del navegador. Sus dimensiones no cambian cuando el usuario hace zoom (pellizco) o cuando el teclado virtual se desliza hacia arriba.
  • Visual Viewport: La caja física que el usuario ve realmente. Su altura disminuye drásticamente cuando el teclado virtual aparece o cuando el usuario hace zoom.

Escuchar eventos del Viewport

La API nos provee del objeto global window.visualViewport. Podemos escuchar sus cambios de dimensiones de la siguiente manera:

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', alCambiarViewport);
  window.visualViewport.addEventListener('scroll', alCambiarViewport);
}

function alCambiarViewport() {
  const vv = window.visualViewport;
  console.log(`Viewport visible: Ancho=${vv.width}px, Alto=${vv.height}px`);
  console.log(`Desplazamiento: X=${vv.offsetLeft}px, Y=${vv.offsetTop}px`);
}

Reposicionar una barra de chat sobre el teclado

A continuación, implementamos una solución interactiva en JS. Si el teclado sube (lo que reduce la altura del Visual Viewport), calculamos la diferencia y empujamos hacia arriba una barra de herramientas fijada en el fondo, evitando que el teclado la cubra:

const barraChat = document.getElementById('barra-chat-flotante');

function alCambiarViewport() {
  if (!window.visualViewport) return;

  const vv = window.visualViewport;
  
  // El espacio ocupado por el teclado es la altura total interna menos el viewport visible
  const espacioTeclado = window.innerHeight - vv.height;
  
  // Ajustamos la posición inferior usando CSS transform para máxima fluidez de la GPU
  if (espacioTeclado > 0) {
    // Si el teclado está abierto, desplazamos la barra hacia arriba
    barraChat.style.transform = `translateY(-${espacioTeclado}px)`;
  } else {
    // Si el teclado se cerró, la barra vuelve a su posición original (bottom: 0)
    barraChat.style.transform = 'translateY(0)';
  }
}

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', alCambiarViewport);
}

Matriz de propiedades del Visual Viewport

Propiedad Tipo Representa
width Double El ancho actual del viewport visual (se encoge al hacer zoom).
height Double El alto actual del viewport visual (se encoge al hacer zoom o abrir el teclado).
scale Double El factor de zoom actual del usuario (ej. 1.0, 2.0).
offsetTop Double El desfase vertical de la vista respecto al documento (cambia al hacer scroll con zoom).

Puntos clave

  • El Layout Viewport se mantiene estático mientras que el Visual Viewport cambia de tamaño reactivamente al abrirse el teclado o hacer zoom.
  • En móviles, position: fixed; bottom: 0; puede quedar tapado silenciosamente por el teclado si no se ajusta dinámicamente.
  • window.innerHeight - window.visualViewport.height calcula con exactitud la altura física del teclado virtual en pantalla.
  • Usar translateY acelerado por GPU para reposicionar la UI flotante previene tirones visuales durante la transición del teclado.

13 · Feedback Háptico: Vibration API

Básico ~10 min

En el software móvil, las respuestas físicas (o feedback háptico) añaden una capa de realismo y confirmación fundamental. Sentir una vibración sutil al presionar un botón de confirmación o un pulso doble cuando ocurre un error de validación mejora drásticamente la experiencia de usuario. Hoy estudiaremos la Vibration API y cómo implementarla en tus formularios web.

  • Detectar la disponibilidad del hardware de vibración en el navegador.
  • Implementar pulsos de vibración simples y patrones intermitentes complejos con JS.
  • Detener vibraciones activas de manera programática.
  • Conocer los límites de compatibilidad (la limitación histórica de iOS Safari).

Detección de soporte y vibración simple

Antes de invocar al motor de vibración del celular, debemos comprobar si el navegador móvil del usuario soporta la API, evitando errores de ejecución. Un pulso simple se dispara pasando un número que representa la duración en milisegundos:

function vibrarDispositivo(duracion = 200) {
  if ('vibrate' in navigator) {
    // Hace vibrar el teléfono por el tiempo indicado en milisegundos
    navigator.vibrate(duracion);
    console.log(`Vibración de ${duracion}ms ejecutada.`);
  } else {
    console.log("La Vibration API no está soportada en este navegador.");
  }
}

Patrones de vibración avanzados

Podemos crear ritmos y patrones complejos pasando un arreglo de números. La API interpreta este patrón alternando de forma secuencial:

  • Los índices **pares** (0, 2, 4...) indican la **duración de la vibración** en milisegundos.
  • Los índices **impares** (1, 3, 5...) indican la **duración de la pausa (silencio)** en milisegundos.
// Patrón de doble pulso rápido (típico para notificar un error o advertencia)
// Vibra 100ms -> Pausa 50ms -> Vibra 100ms
const patronError = [100, 50, 100];

// Patrón de alerta persistente (ej. llamada o alarma)
const patronAlarma = [500, 250, 500, 250, 500];

function vibrarPatron(patron) {
  if ('vibrate' in navigator) {
    navigator.vibrate(patron);
  }
}

Detener una vibración en curso

Si has iniciado una vibración larga o un patrón cíclico y deseas cancelarlo inmediatamente (por ejemplo, porque el usuario silenció la alerta), puedes apagar el motor enviando un 0 o un arreglo vacío:

function detenerVibracion() {
  if ('vibrate' in navigator) {
    // Ambos métodos detienen inmediatamente cualquier vibración activa
    navigator.vibrate(0);
    // o: navigator.vibrate([]);
    console.log("Motor de vibración silenciado.");
  }
}

El caso especial de iOS

A pesar de que la Vibration API es un estándar de la W3C soportado por Chrome, Firefox y Opera en Android desde hace años, Apple ha decidido **no implementarlo** en iOS Safari por motivos de privacidad y abuso publicitario (páginas web fraudulentas que vibraban para simular falsos virus). En iPhone, esta llamada simplemente se ignorará sin arrojar errores gracias a nuestra validación 'vibrate' in navigator.

Puntos clave

  • navigator.vibrate(ms) inicia una vibración simple; deténla pasando 0.
  • Los arreglos en vibrate([vibra, pausa, vibra...]) permiten diseñar feedback táctil personalizado.
  • iOS Safari no soporta la Vibration API; es indispensable validar su existencia antes de llamarla.
  • Las vibraciones solo se activan tras un evento desencadenado físicamente por el usuario (gesto de seguridad).

14 · Screen Wake Lock API

Intermedio ~12 min

Los dispositivos móviles apagan su pantalla tras unos segundos de inactividad del usuario para preservar batería. Sin embargo, en ciertas tareas (como seguir una receta paso a paso, leer un reporte largo sin tocar la pantalla o esperar a que se procese una carga de datos masiva), que la pantalla se apague es molesto. Hoy estudiaremos la Screen Wake Lock API, que nos permite mantener la pantalla encendida de forma segura.

  • Solicitar un bloqueo de suspensión de pantalla (`Wake Lock`) al sistema operativo.
  • Escuchar la liberación del bloqueo cuando el sistema o el usuario cancelan el servicio.
  • Re-activar automáticamente el Wake Lock cuando la pestaña web recupera la visibilidad.
  • Implementar buenas prácticas para evitar el agotamiento accidental de la batería.

Solicitar un Wake Lock

El acceso a la API se realiza a través de navigator.wakeLock. Solicitar el bloqueo es una operación asíncrona que devuelve un objeto centinela (sentinel) con el cual controlaremos el estado:

let centinelaWakeLock = null;

async function activarPantallaEncendida() {
  try {
    if ('wakeLock' in navigator) {
      // Solicita mantener la pantalla encendida
      centinelaWakeLock = await navigator.wakeLock.request('screen');
      console.log("Wake Lock activo: La pantalla no se suspenderá.");
      
      // Escucha si el bloqueo es liberado automáticamente por el navegador
      centinelaWakeLock.addEventListener('release', () => {
        console.log("El Wake Lock ha sido liberado.");
      });
    } else {
      console.log("Screen Wake Lock API no soportada en este navegador.");
    }
  } catch (err) {
    console.error(`Error al solicitar Wake Lock: ${err.message}`);
  }
}

Liberar el bloqueo manualmente

Es fundamental desactivar el Wake Lock una vez concluida la actividad para que el teléfono pueda volver a su comportamiento de ahorro energético habitual:

async function desactivarPantallaEncendida() {
  if (centinelaWakeLock) {
    await centinelaWakeLock.release(); // Libera el bloqueo de pantalla
    centinelaWakeLock = null;
    console.log("Pantalla liberada para suspensión normal.");
  }
}

Ciclo de vida y Page Visibility API

Por seguridad, los navegadores liberan el Wake Lock automáticamente si el usuario minimiza el navegador, cambia de pestaña o apaga la pantalla del celular manualmente. Si el usuario regresa a nuestra web, el Wake Lock no se reactiva solo. Debemos usar la Page Visibility API para re-solicitarlo:

document.addEventListener('visibilitychange', async () => {
  // Si la pestaña vuelve a ser visible y teníamos un centinela activo antes
  if (centinelaWakeLock !== null && document.visibilityState === 'visible') {
    centinelaWakeLock = await navigator.wakeLock.request('screen');
    console.log("Wake Lock reactivado tras recuperar visibilidad.");
  }
});

Puntos clave

  • navigator.wakeLock.request('screen') solicita al sistema operativo no apagar la pantalla.
  • El navegador libera el bloqueo de forma automática e inmediata al cambiar de pestaña o minimizar la app.
  • Es necesario combinarlo con visibilitychange para volver a solicitar el bloqueo cuando el usuario regresa a la web.
  • Llama a centinela.release() en cuanto finalice el proceso crítico para proteger la batería del móvil.

15 · Web Share y Contact Picker API

Intermedio ~12 min

Integrar nuestra web con las funciones sociales del teléfono móvil es clave para mejorar el enganche del usuario. Hoy estudiaremos dos Web APIs que abren directamente el sistema operativo: la Web Share API (para invocar la ventana de compartir nativa de Android/iOS con enlaces, textos o archivos) y la Contact Picker API (para seleccionar números telefónicos directamente desde la agenda del celular).

  • Invocar la hoja de compartir nativa del sistema operativo con textos, URLs y archivos.
  • Utilizar `navigator.canShare` para validar la compatibilidad de archivos antes de compartirlos.
  • Acceder de forma segura a la lista de contactos nativos usando `navigator.contacts.select()`.
  • Controlar los flujos asíncronos y cancelaciones de la hoja de compartir (AbortError).

Compartir enlaces y archivos (Web Share API)

El método navigator.share() despliega el menú nativo del OS (el mismo que muestra las aplicaciones de mensajería, correo y redes). Este método es asíncrono y requiere un gesto del usuario para ejecutarse:

const botonCompartir = document.getElementById('btn-compartir');

botonCompartir.addEventListener('click', async () => {
  const datosCompartir = {
    title: 'webcode · manuales',
    text: 'Aprende APIs de hardware móvil en la web',
    url: 'https://webcode.net.pe/web/'
  };

  try {
    if (navigator.share) {
      await navigator.share(datosCompartir);
      console.log("Contenido compartido con éxito.");
    } else {
      console.log("Web Share API no soportada en este navegador.");
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log("El usuario canceló la acción de compartir.");
    } else {
      console.error("Error al compartir: ", error);
    }
  }
});

Compartir archivos binarios (imágenes/PDFs)

También podemos compartir archivos creados en tiempo real (por ejemplo, una foto capturada con canvas). Para ello, validamos primero con canShare:

// Creamos un archivo de prueba simulado
const archivoImagen = new File([blobFoto], 'evidencia.jpg', { type: 'image/jpeg' });
const datosCompartirConArchivo = {
  files: [archivoImagen]
};

// Comprobamos si el navegador móvil permite compartir este archivo específico
if (navigator.canShare && navigator.canShare(datosCompartirConArchivo)) {
  await navigator.share(datosCompartirConArchivo);
} else {
  console.log("Este tipo de archivo no puede compartirse vía Web Share.");
}

Contact Picker API

Esta API nos permite solicitarle al usuario que elija uno o varios contactos de su libreta de direcciones nativa del teléfono. Es ideal para formularios de transferencia, invitaciones o registro de números de emergencia:

const botonContactos = document.getElementById('btn-contactos');

botonContactos.addEventListener('click', async () => {
  // Las propiedades del contacto que deseamos consultar
  const propiedadesRequeridas = ['name', 'tel'];
  const opciones = { multiple: false }; // Solicitar solo un contacto

  try {
    if ('contacts' in navigator && 'ContactsManager' in window) {
      // Abre el selector de contactos nativo del sistema operativo
      const contactosSeleccionados = await navigator.contacts.select(propiedadesRequeridas, opciones);
      
      if (contactosSeleccionados.length > 0) {
        const contacto = contactosSeleccionados[0];
        console.log(`Nombre: ${contacto.name[0]}, Teléfono: ${contacto.tel[0]}`);
      }
    } else {
      console.log("Contact Picker API no está soportada en este navegador.");
    }
  } catch (error) {
    console.error("Error al seleccionar contacto: ", error);
  }
});

Puntos clave

  • navigator.share() despliega el menú nativo del OS para compartir textos, links y archivos de forma fluida.
  • Si el usuario cancela la hoja de compartir, la promesa es rechazada lanzando un error con nombre AbortError.
  • Usa navigator.contacts.select() para abrir la agenda telefónica nativa sin requerir bases de datos locales complejas.
  • Ambas APIs requieren HTTPS y activación exclusiva bajo eventos físicos del usuario (user gestures).

16 · Taller 1: Reportero de Evidencias

Intermedio ~20 min

Es momento de consolidar todo lo aprendido en la primera parte de este manual. En este taller práctico, construiremos desde cero una mini-aplicación web de alto rendimiento optimizada para celulares llamada "Reportero de Evidencias". Esta herramienta permite a supervisores o médicos de campo tomar una foto con la cámara del dispositivo, geolocalizar la captura en vivo, vibrar el dispositivo para confirmar la acción y compartir el reporte completo con el archivo de la foto adjunta de manera nativa.

  • Crear una interfaz de usuario mobile-first responsiva con Bootstrap 5.
  • Integrar la cámara web con `getUserMedia` y un botón para conmutar frontal/trasera.
  • Obtener las coordenadas GPS con alta precisión de forma simultánea.
  • Utilizar `canvas.toBlob` y la Web Share API para compartir el archivo físico de la foto capturada.

1. La interfaz HTML (Mobile-First)

Escribe la estructura del reportero en tu archivo HTML. Utilizaremos Bootstrap 5 y botones grandes táctiles diseñados para pulgares:

<div class="container py-3">
  <h1 class="h4 text-center fw-bold mb-3">Reportero de Evidencias</h1>
  
  <!-- Previsualizador de la Cámara -->
  <div class="position-relative bg-dark rounded overflow-hidden mb-3" style="aspect-ratio: 4/3;">
    <video id="video-visor" autoplay muted playsinline class="w-100 h-100" style="object-fit: cover;"></video>
    <div class="position-absolute bottom-0 start-0 w-100 p-2 bg-dark bg-opacity-50 text-white small text-center" id="coords-visor">
      Esperando GPS...
    </div>
  </div>

  <!-- Botones de Acción Rápida -->
  <div class="row g-2 mb-3">
    <div class="col-6">
      <button class="btn btn-secondary btn-sm w-100" id="btn-conmutar">
        <i class="bi bi-arrow-repeat me-1"></i> Girar Cámara
      </button>
    </div>
    <div class="col-6">
      <button class="btn btn-primary btn-sm w-100" id="btn-foto">
        <i class="bi bi-camera-fill me-1"></i> Capturar
      </button>
    </div>
  </div>

  <!-- Vista Previa y Botón Social -->
  <div id="contenedor-reporte" class="card shadow-sm d-none">
    <img id="img-resultado" class="card-img-top" alt="Evidencia capturada">
    <div class="card-body p-3">
      <p class="small text-secondary mb-3" id="txt-reporte"></p>
      <button class="btn btn-success btn-sm w-100" id="btn-compartir-evidencia">
        <i class="bi bi-share-fill me-1"></i> Compartir Reporte
      </button>
    </div>
  </div>
</div>

2. El script integrador en JavaScript

A continuación, implementamos el código JavaScript modular que gestiona la cámara, GPS, vibración y la compartición nativa de archivos:

(function() {
  'use strict';

  const video = document.getElementById('video-visor');
  const coordsVisor = document.getElementById('coords-visor');
  const btnConmutar = document.getElementById('btn-conmutar');
  const btnFoto = document.getElementById('btn-foto');
  const btnCompartir = document.getElementById('btn-compartir-evidencia');
  const contenedorReporte = document.getElementById('contenedor-reporte');
  const imgResultado = document.getElementById('img-resultado');
  const txtReporte = document.getElementById('txt-reporte');

  let streamActivo = null;
  let modoCamara = 'environment'; // Iniciamos con la cámara trasera
  let coordenadasActuales = null;
  let blobFotoActual = null;

  // --- PASO A: Inicializar Cámara ---
  async function arrancarCamara() {
    if (streamActivo) {
      streamActivo.getTracks().forEach(track => track.stop());
    }

    try {
      const constraints = { video: { facingMode: modoCamara } };
      streamActivo = await navigator.mediaDevices.getUserMedia(constraints);
      video.srcObject = streamActivo;
    } catch (err) {
      coordsVisor.innerText = "Error al iniciar cámara: " + err.message;
    }
  }

  // --- PASO B: Geolocalización en Tiempo Real ---
  function iniciarGps() {
    if ('geolocation' in navigator) {
      navigator.geolocation.watchPosition(
        (pos) => {
          coordenadasActuales = pos.coords;
          coordsVisor.innerHTML = `<i class="bi bi-geo-alt-fill text-danger"></i> Lat: ${pos.coords.latitude.toFixed(5)}, Lng: ${pos.coords.longitude.toFixed(5)} (±${Math.round(pos.coords.accuracy)}m)`;
        },
        (err) => {
          coordsVisor.innerText = "GPS inactivo: " + err.message;
        },
        { enableHighAccuracy: true }
      );
    }
  }

  // --- PASO C: Capturar Fotograma y Procesar Blob ---
  btnFoto.addEventListener('click', () => {
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const ctx = canvas.getContext('2d');
    
    // Dibujamos el video en el lienzo
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    
    // Generamos el Blob físico
    canvas.toBlob((blob) => {
      if (blob) {
        blobFotoActual = blob;
        imgResultado.src = URL.createObjectURL(blob);
        contenedorReporte.classList.remove('d-none');
        
        // Formateamos texto del reporte
        const lat = coordenadasActuales ? coordenadasActuales.latitude.toFixed(5) : "No disponible";
        const lng = coordenadasActuales ? coordenadasActuales.longitude.toFixed(5) : "No disponible";
        txtReporte.innerText = `Evidencia registrada en Coordenadas: Lat ${lat}, Lng ${lng}.`;

        // Vibrar el celular (doble pulso corto) para dar retroalimentación de éxito
        if ('vibrate' in navigator) {
          navigator.vibrate([100, 50, 100]);
        }
      }
    }, 'image/jpeg', 0.85);
  });

  // --- PASO D: Compartir Reporte con Archivo adjunto ---
  btnCompartir.addEventListener('click', async () => {
    if (!blobFotoActual) return;

    const lat = coordenadasActuales ? coordenadasActuales.latitude.toFixed(5) : "N/D";
    const lng = coordenadasActuales ? coordenadasActuales.longitude.toFixed(5) : "N/D";
    
    // Creamos el archivo físico a partir de nuestro blob de memoria
    const archivoFoto = new File([blobFotoActual], 'evidencia.jpg', { type: 'image/jpeg' });
    const datosCompartir = {
      title: 'Nueva Evidencia Web',
      text: `Evidencia enviada desde campo. Ubicación: Lat ${lat}, Lng ${lng}.`,
      files: [archivoFoto]
    };

    try {
      if (navigator.canShare && navigator.canShare(datosCompartir)) {
        await navigator.share(datosCompartir);
        console.log("Reporte compartido con éxito.");
      } else {
        alert("Tu navegador móvil no soporta compartir imágenes de forma nativa.");
      }
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error("Error al compartir reporte: ", err);
      }
    }
  });

  // Alternar cámaras (Selfie vs Principal)
  btnConmutar.addEventListener('click', () => {
    modoCamara = (modoCamara === 'user') ? 'environment' : 'user';
    arrancarCamara();
  });

  // Arrancar de inmediato
  arrancarCamara();
  iniciarGps();
})();

Puntos clave

  • La UI móvil debe diseñarse priorizando elementos fáciles de presionar en pantallas pequeñas y táctiles (mobile-first).
  • canvas.toBlob() captura el búfer de imagen en un formato binario y nos permite empaquetarlo dentro de un objeto File.
  • La verificación navigator.canShare() valida la viabilidad de compartir archivos físicos antes de lanzar el selector del OS.
  • El uso de la vibración con la Vibration API aporta feedback inmediato para confirmar operaciones críticas de hardware.

17 · Persistencia local: Storage y límites

Básico ~12 min

Una aplicación web móvil profesional debe ser capaz de guardar configuraciones de usuario, datos de inicio de sesión o incluso reportes temporales de forma local. No obstante, a diferencia de una laptop con disco duro espacioso, los celulares poseen cuotas muy estrictas de almacenamiento web impuestas por el sistema operativo. Hoy estudiaremos LocalStorage, SessionStorage y sus límites móviles.

  • Diferenciar el ciclo de vida y alcance de `localStorage` y `sessionStorage` en celulares.
  • Comprender los límites físicos de cuota (5MB estándar) y políticas de expiración agresivas en iOS.
  • Consultar la capacidad disponible del dispositivo usando la Storage Estimate API.
  • Capturar y procesar el error de cuota excedida (`QuotaExceededError`) en JavaScript.

LocalStorage vs. SessionStorage en móviles

Ambas son APIs síncronas del tipo clave-valor que solo almacenan texto plano (strings). Su diferencia radica en la persistencia:

  • SessionStorage: Los datos persisten solo mientras la pestaña del navegador móvil esté abierta. Si el usuario cierra la pestaña o el sistema suspende el navegador por inactividad de RAM, el almacenamiento se borra.
  • LocalStorage: Los datos sobreviven al cierre de pestañas, del navegador e incluso al reinicio del teléfono.
// Guardar datos
localStorage.setItem('preferencia_tema', 'dark');

// Leer datos
const tema = localStorage.getItem('preferencia_tema');

// Capturar el error clásico de espacio lleno
try {
  localStorage.setItem('datos_pesados', unStringGigante);
} catch (error) {
  if (error.name === 'QuotaExceededError') {
    alert("¡Almacenamiento Local lleno! Por favor, limpia datos antiguos.");
  }
}

La gran trampa de iOS (La purga de Safari)

En el ecosistema Apple iOS (iPhones), existe una regla muy estricta instalada por seguridad y ahorro de espacio: si el usuario no abre tu sitio web durante **7 días consecutivos**, Safari eliminará automáticamente todo el contenido guardado en localStorage, sessionStorage e IndexedDB de ese origen. La única excepción es que el usuario haya guardado tu sitio en su pantalla de inicio como una aplicación PWA (Web App agregada).

Storage Estimate API (Consultar cuota útil)

Para no escribir a ciegas y conocer cuántos bytes libres le quedan a nuestra web antes de colapsar, podemos utilizar la API del Administrador de Almacenamiento (asíncrona y soportada en Android/iOS modernos):

async function consultarEspacioDisponible() {
  if (navigator.storage && navigator.storage.estimate) {
    const estimacion = await navigator.storage.estimate();
    
    // Convertimos bytes a Megabytes
    const usadoMB = (estimacion.usage / (1024 * 1024)).toFixed(2);
    const limiteMB = (estimacion.quota / (1024 * 1024)).toFixed(2);
    
    console.log(`Espacio usado: ${usadoMB} MB de un límite de ${limiteMB} MB.`);
  } else {
    console.log("Storage Estimate API no soportada.");
  }
}

Resumen de almacenamiento web en celular

Característica LocalStorage / SessionStorage IndexedDB (BD local)
Límite de espacio Estricto ~5MB por origen Flexible (hasta el 60% del disco libre)
Tipo de datos Solo texto (Strings) Objetos complejos, archivos binarios (Blobs)
Operaciones Síncronas (bloquea el hilo del navegador) Asíncronas (no traba la interfaz táctil)
Uso recomendado Preferencias, tokens de sesión pequeños Imágenes de cámara, catálogos offline, cachés

Puntos clave

  • El límite absoluto de LocalStorage en navegadores móviles está estandarizado en unos escasos 5 Megabytes.
  • En iPhones (iOS), las webs inactivas sufren una purga de datos guardados tras 7 días de inactividad del usuario.
  • Usa navigator.storage.estimate() para conocer con precisión científica la cuota usada y el total asignado.
  • Captura siempre el error QuotaExceededError para guiar al usuario a liberar espacio de manera controlada.

18 · IndexedDB: La base de datos local

Avanzado ~15 min

Cuando construimos aplicaciones web offline-first (como formularios de inspección que deben funcionar en minas o zonas rurales sin internet), LocalStorage se queda corto inmediatamente debido a su límite de 5MB. La solución estándar de la industria es **IndexedDB**, una base de datos transaccional no relacional integrada en el navegador del teléfono capaz de almacenar gigabytes de información y archivos binarios puros. Hoy aprenderemos a dominarla.

  • Inicializar bases de datos IndexedDB y crear almacenes de objetos (`Object Stores`).
  • Gestionar transacciones asíncronas seguras para lecturas y escrituras.
  • Almacenar archivos binarios directos (Blobs de fotos de cámara) sin codificación Base64.
  • Implementar lecturas y consultas de datos guardados localmente.

Conexión e inicialización de IndexedDB

Al ser IndexedDB una base de datos, requiere una versión y un esquema de almacenamiento estructurado. El evento clave es onupgradeneeded, el cual se ejecuta una sola vez cuando la base de datos se abre por primera vez o cambia de versión:

let db = null;
const request = indexedDB.open('ClinicaOfflineDB', 1);

request.onupgradeneeded = function(e) {
  db = e.target.result;
  
  // Creamos un almacén de objetos para reportes con clave autoincremental
  if (!db.objectStoreNames.contains('evidencias')) {
    db.createObjectStore('evidencias', { keyPath: 'id', autoIncrement: true });
  }
  console.log("Estructura de IndexedDB inicializada.");
};

request.onsuccess = function(e) {
  db = e.target.result;
  console.log("Conexión a IndexedDB establecida con éxito.");
};

request.onerror = function(e) {
  console.error("Error al abrir IndexedDB: ", e.target.error);
};

Guardar fotos y coordenadas en una transacción

Escribir en IndexedDB requiere abrir una transacción. A diferencia de LocalStorage, aquí podemos meter directamente archivos Blob o File. El navegador móvil se encarga de optimizar el guardado binario en disco sin sobrecargar la RAM:

function guardarReporteOffline(blobFoto, lat, lng) {
  if (!db) return;

  // Abrimos una transacción de lectura/escritura en el almacén evidencias
  const transaccion = db.transaction(['evidencias'], 'readwrite');
  const almacen = transaccion.objectStore('evidencias');
  
  const nuevoRegistro = {
    foto: blobFoto, // Guardado directo del archivo binario de la cámara
    coordenadas: { lat, lng },
    fecha: new Date().toISOString()
  };

  const peticionEscritura = almacen.add(nuevoRegistro);

  peticionEscritura.onsuccess = function() {
    console.log("Evidencia binaria guardada offline en IndexedDB con éxito.");
  };

  transaccion.oncomplete = function() {
    console.log("Transacción finalizada.");
  };
}

Recuperar y mostrar la evidencia guardada

Para recuperar los datos de IndexedDB y renderizarlos en nuestro HTML, iniciamos una transacción de solo lectura y cargamos el registro deseado:

function renderizarUltimaFoto(idRegistro) {
  const transaccion = db.transaction(['evidencias'], 'readonly');
  const almacen = transaccion.objectStore('evidencias');
  
  const peticionLectura = almacen.get(idRegistro);

  peticionLectura.onsuccess = function(e) {
    const registro = e.target.result;
    if (registro) {
      // Generamos un enlace temporal directo del blob binario
      const urlFoto = URL.createObjectURL(registro.foto);
      document.getElementById('img-visor').src = urlFoto;
      
      console.log(`Foto leída de IndexedDB de fecha: ${registro.fecha}`);
    }
  };
}

Puntos clave

  • IndexedDB es no bloqueante (asíncrona), lo que previene que la interfaz táctil del celular se ralentice al realizar escrituras en disco.
  • Admite almacenamiento de archivos Blob binarios puros de forma nativa sin transformarlos a base64.
  • Todas las escrituras y lecturas de datos se realizan obligatoriamente dentro del marco de transacciones (`transactions`).
  • Usa URL.createObjectURL(blob) para mostrar imágenes extraídas de IndexedDB directamente en la etiqueta de imagen.

19 · Service Workers: El intermediario offline

Avanzado ~15 min

El talón de Aquiles de la web móvil frente a las aplicaciones nativas (Kotlin/Flutter) siempre fue la dependencia total del internet. Si un usuario se queda sin cobertura en el metro o en un sótano, la web muestra la clásica pantalla de error de "Sin conexión". Los **Service Workers** cambian las reglas del juego actuando como un servidor proxy local en el celular que intercepta peticiones de red. Hoy iniciaremos el camino offline.

  • Registrar un Service Worker desde la ventana principal del navegador.
  • Comprender el ciclo de vida del Service Worker (Instalación, Activación e Intercepción).
  • Interceptar llamadas de red con el evento `fetch` de forma transparente.
  • Responder de manera instantánea ante pérdidas de cobertura móvil.

1. Registro del Service Worker (Página Principal)

El registro del Service Worker se realiza desde el hilo principal de nuestra aplicación web tras la carga de la página, para no interferir en la velocidad de renderizado inicial:

// Validamos soporte en el navegador móvil
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js')
      .then(function(registro) {
        console.log(`Service Worker registrado con éxito en el ámbito: ${registro.scope}`);
      })
      .catch(function(error) {
        console.error("Fallo al registrar el Service Worker: ", error);
      });
  });
}

2. El ciclo de vida en sw.js

El archivo sw.js es un script independiente que se ejecuta en segundo plano. No tiene acceso directo al DOM de la página. El navegador del teléfono gatilla eventos específicos en este archivo:

// Evento de Instalación: Se ejecuta una sola vez al descargar el script
self.addEventListener('install', function(e) {
  console.log("Service Worker: Instalando...");
  // Fuerza al Service Worker recién instalado a tomar el control de inmediato
  self.skipWaiting();
});

// Evento de Activación: Ideal para limpiar cachés de versiones antiguas
self.addEventListener('activate', function(e) {
  console.log("Service Worker: Activado.");
  // Toma el control de todas las pestañas abiertas inmediatamente
  self.clients.claim();
});

3. Interceptación de peticiones (Evento fetch)

Una vez activo, cada imagen, script, CSS o llamada de AJAX que la página web intente hacer pasará obligatoriamente por el evento fetch de nuestro Service Worker, permitiéndonos reescribir la respuesta si no hay red:

self.addEventListener('fetch', function(e) {
  console.log(`Petición interceptada: ${e.request.url}`);
  
  e.respondWith(
    // Intentamos realizar la petición a internet de manera ordinaria
    fetch(e.request)
      .catch(function() {
        // Si falla internet, respondemos con un texto plano amigable de contingencia
        return new Response(
          "Lo sentimos, te encuentras offline y esta sección requiere internet.",
          {
            headers: { 'Content-Type': 'text/plain;charset=utf-8' }
          }
        );
      })
  );
});

Puntos clave

  • El Service Worker se ejecuta fuera del hilo principal del navegador móvil, evitando ralentizaciones en la interfaz visual.
  • No posee acceso al objeto window ni al DOM de la página por motivos de seguridad informática.
  • El evento fetch permite interceptar peticiones de red y proveer contingencias ante la pérdida de señal.
  • Exigen conexión segura HTTPS obligatoria en producción para poder registrarse en teléfonos Android e iOS.

20 · Cache Storage API: Estrategias de caché

Avanzado ~15 min

Tener un Service Worker que detecte el estado offline es inútil si no poseemos una copia local de los archivos físicos de la web (HTML, CSS, JS, fuentes e imágenes) lista para ser servida. La **Cache Storage API** es un sistema de almacenamiento específico que guarda pares de solicitudes y respuestas HTTP directamente en el celular del usuario. Hoy estudiaremos cómo utilizarla para lograr cargas instantáneas.

  • Implementar el pre-cacheo (`Precaching`) de archivos durante la instalación del Service Worker.
  • Dominar la estrategia **Cache First** (ideal para activos estáticos como Bootstrap y fuentes).
  • Dominar la estrategia **Network First** (ideal para datos dinámicos que requieren internet pero tienen fallback).
  • Dominar la estrategia **Stale-while-revalidate** para una experiencia móvil de velocidad inmediata.

1. Pre-cacheo de recursos (sw.js)

Al instalar el Service Worker, abrimos un caché con un nombre de versión y forzamos la descarga de los archivos esenciales para que la web funcione sin internet:

const CACHE_VERSION = 'wc-cache-v1';
const RECURSOS_ESTATICOS = [
  '/',
  '/index.html',
  '/html_02_movil.html',
  '/webcode_sq.png',
  'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css'
];

self.addEventListener('install', function(e) {
  e.waitUntil(
    caches.open(CACHE_VERSION)
      .then(function(cache) {
        console.log("Service Worker: Cacheando activos estáticos iniciales...");
        return cache.addAll(RECURSOS_ESTATICOS);
      })
  );
});

2. Estrategia: Cache First (Con caída a red)

Esta estrategia busca el archivo primero en el caché local de almacenamiento. Si lo encuentra, lo devuelve al instante (carga en 0ms). Si no está en caché, realiza la petición a internet:

// Utilizar dentro del evento 'fetch' del Service Worker
function cacheFirstStrategy(request) {
  return caches.match(request)
    .then(function(respuestaCacheada) {
      // Si el archivo está cacheado, lo servimos de inmediato
      if (respuestaCacheada) {
        return respuestaCacheada;
      }
      // Si no, lo buscamos en internet
      return fetch(request);
    });
}

3. Estrategia: Network First (Con caída a caché)

Ideal para paneles de control u hojas de ruta médicas donde lo prioritario es el dato más fresco de internet, pero si el usuario pierde señal, se le muestra la última copia guardada en su celular:

function networkFirstStrategy(request) {
  return fetch(request)
    .then(function(respuestaRed) {
      // Si la respuesta es exitosa, guardamos una copia fresca en caché
      return caches.open(CACHE_VERSION).then(function(cache) {
        cache.put(request, respuestaRed.clone());
        return respuestaRed;
      });
    })
    .catch(function() {
      // Si falla internet, recuperamos el respaldo local del caché
      return caches.match(request);
    });
}

4. Estrategia: Stale-while-revalidate

Esta estrategia combina lo mejor de ambos mundos: sirve inmediatamente el recurso desde el caché local para que la UI cargue de forma instantánea en el celular, mientras que en segundo plano lanza una petición a internet para actualizar el caché silenciosamente para la próxima visita:

function staleWhileRevalidateStrategy(request) {
  return caches.match(request)
    .then(function(respuestaCacheada) {
      const fetchPromesa = fetch(request).then(function(respuestaRed) {
        return caches.open(CACHE_VERSION).then(function(cache) {
          cache.put(request, respuestaRed.clone());
          return respuestaRed;
        });
      });
      // Retorna la respuesta vieja al instante, u opera la promesa de red si no había caché
      return respuestaCacheada || fetchPromesa;
    });
}

Puntos clave

  • El pre-cacheo descarga y almacena los archivos indispensables durante el ciclo de instalación del Service Worker.
  • Cache First acelera la carga de recursos de interfaz estáticos al saltarse la latencia de red.
  • Network First prioriza los datos actualizados de internet pero asegura el funcionamiento básico sin conexión.
  • La API de Cache Storage solo admite almacenamiento de peticiones del protocolo seguro de lectura GET.

21 · El archivo Web App Manifest

Básico ~12 min

Para que el navegador de un teléfono inteligente reconozca que tu sitio web es más que una simple página y le ofrezca al usuario la opción de instalarla en su pantalla de inicio (como si fuera una app descargada de Google Play o App Store), necesitas definir el archivo **Web App Manifest**. Este archivo JSON contiene los metadatos de configuración que configuran la apariencia nativa de la aplicación.

  • Crear el archivo `manifest.json` y enlazarlo correctamente en las cabeceras HTML de tu sitio.
  • Definir modos de visualización sin barra de navegación (`display: standalone`).
  • Configurar los colores de marca del sistema operativo (`theme_color` y `background_color`).
  • Implementar los iconos adaptables obligatorios y configurar la compatibilidad exclusiva para iOS.

1. Enlazar el Manifest en HTML

El manifiesto se conecta en la sección <head> de todas las páginas de tu web mediante un enlace simple:

<link rel="manifest" href="/manifest.json">

2. Estructura estándar de manifest.json

A continuación se detalla la configuración clásica para lograr una integración fluida con Android y Chrome:

{
  "name": "Reportero de Evidencias Clínicas",
  "short_name": "Evidencias",
  "description": "Herramienta offline de registro fotográfico y GPS para personal de campo.",
  "start_url": "/html_02_movil.html",
  "display": "standalone",
  "background_color": "#0d1117",
  "theme_color": "#e11d48",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/webcode_sq.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/webcode_sq.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Análisis de propiedades clave

  • display: standalone: Oculta la barra de direcciones del navegador, la barra de estado y los botones de atrás/adelante. La web corre en su propia ventana, simulando ser una app nativa.
  • theme_color: Controla el color de la barra superior de estado del teléfono (donde se observa la hora, batería y notificaciones).
  • purpose: maskable: Indica que el icono posee un margen seguro para que el sistema operativo lo recorte con diferentes formas (círculo, cuadrado redondeado, ardilla), evitando que los bordes del logo queden recortados en Android.

Compatibilidad exclusiva con iOS (Apple)

Históricamente, Apple no ha soportado por completo el archivo `manifest.json` en iOS Safari. Para lograr que la PWA tenga un ícono nativo y corra sin barra de navegación en iPhones, debes duplicar la configuración agregando etiquetas meta exclusivas en el <head> de tu HTML:

<!-- Indica a iOS que la web se puede instalar y correr en standalone -->
<meta name="apple-mobile-web-app-capable" content="yes">

<!-- Define el color de la barra de estado superior de iOS -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

<!-- Icono exclusivo que usará el iPhone en su pantalla de inicio -->
<link rel="apple-touch-icon" href="/webcode_sq.png">

Puntos clave

  • El Web App Manifest es un archivo JSON que define el nombre, iconos y comportamiento del sitio al instalarse.
  • display: standalone elimina los controles del navegador web, dándole aspecto de aplicación nativa.
  • Define iconos maskable para que sistemas como Android puedan recortar tu logo de forma uniforme.
  • Para dar soporte de íconos e instalación en iPhones, incluye obligatoriamente las metaetiquetas apple-mobile-web-app-*.

22 · Banner de instalación personalizado

Intermedio ~12 min

Cuando tu web cumple con los requisitos de PWA, el navegador móvil suele mostrar un discreto y poco estético banner automático en la parte inferior invitando a instalar la app. Para lograr una experiencia de marca pulida, es recomendable desactivar este aviso nativo del navegador e implementar nuestro propio botón integrado en el diseño web. Hoy aprenderemos a interceptar y controlar esta instalación.

  • Capturar e interceptar el evento de instalación nativa `beforeinstallprompt`.
  • Prevenir la aparición del banner por defecto del navegador en Android Chrome.
  • Desplegar un botón de instalación propio dentro del HTML.
  • Detectar si la aplicación ya está instalada o se está ejecutando en pantalla completa.

1. Interceptar el evento beforeinstallprompt

El navegador móvil dispara el evento beforeinstallprompt cuando confirma que el sitio web es una PWA elegible para instalación. Detenemos su flujo para guardar el evento en memoria y mostrar nuestro botón personalizado de Bootstrap:

let eventoInstalacion = null;
const btnInstalar = document.getElementById('btn-instalar-pwa');

window.addEventListener('beforeinstallprompt', function(e) {
  // Evita que aparezca el banner automático del navegador
  e.preventDefault();
  
  // Guardamos el evento para dispararlo cuando el usuario haga click en nuestro botón
  eventoInstalacion = e;
  
  // Hacemos visible nuestro botón personalizado en la UI
  btnInstalar.classList.remove('d-none');
  console.log("Evento de instalación capturado y en espera.");
});

2. Disparar la ventana de instalación nativa

Cuando el usuario decide pulsar nuestro botón de instalación, invocamos al método prompt() guardado en el evento centinela, abriendo el cuadro de diálogo oficial del sistema operativo:

btnInstalar.addEventListener('click', async function() {
  if (eventoInstalacion) {
    // Abre el diálogo nativo de instalación
    eventoInstalacion.prompt();
    
    // Esperamos la elección final del usuario (Si aceptó o denegó)
    const { outcome } = await eventoInstalacion.userChoice;
    console.log(`Elección del usuario: ${outcome}`);
    
    // Reseteamos la variable y ocultamos nuestro botón
    eventoInstalacion = null;
    btnInstalar.classList.add('d-none');
  }
});

3. Escuchar la instalación exitosa

Una vez que el usuario confirma la instalación y el icono se crea en la pantalla de su teléfono, el navegador móvil gatilla el evento appinstalled. Podemos usarlo para confirmaciones internas o analíticas:

window.addEventListener('appinstalled', function() {
  console.log("¡Aplicación PWA instalada con éxito en el teléfono!");
  alert("Gracias por instalar nuestra aplicación de Evidencias.");
});

Ocultar UI si ya está instalada

Si el usuario ya instaló la app y la está ejecutando desde su icono del teléfono, no tiene sentido mostrar botones de instalación. Evaluamos las "media queries" en JS para confirmar si corre en standalone:

if (window.matchMedia('(display-mode: standalone)').matches) {
  // Ocultamos permanentemente cualquier botón de instalación
  btnInstalar.classList.add('d-none');
  console.log("Corriendo en modo Standalone instalado.");
}

Puntos clave

  • El evento beforeinstallprompt permite pausar el banner de instalación automático del navegador.
  • Usa e.prompt() dentro del click de tu botón personalizado para abrir el diálogo oficial de instalación.
  • El evento appinstalled confirma la finalización del proceso de copia de la app en la pantalla del celular.
  • Usa (display-mode: standalone) en CSS o JS para ocultar botones inútiles si la app ya está corriendo instalada.

23 · Gestión de actualizaciones (Update Lifecycle)

Avanzado ~12 min

Uno de los mayores dolores de cabeza de los Service Workers en móviles es la entrega de actualizaciones. Cuando modificas tu código JS, CSS o HTML y subes una nueva versión de sw.js al servidor, el navegador del teléfono la detecta y la instala en segundo plano. Sin embargo, la nueva versión entra en estado "En espera" (waiting) y no se activará hasta que el usuario cierre todas las pestañas de tu web. Dado que en celulares casi nadie cierra pestañas, tus actualizaciones podrían no llegar nunca. Hoy implementaremos un sistema de actualización forzada.

  • Comprender los estados del ciclo de vida del Service Worker (Installing, Waiting, Active).
  • Escuchar el estado de espera (`waiting`) mediante el registro de Service Worker.
  • Desplegar una alerta Toast para invitar al usuario a recargar la app.
  • Forzar la actualización instantánea mediante mensajería `postMessage` y `skipWaiting`.

1. Detectar actualizaciones en la página

Al registrar el Service Worker, evaluamos si hay un nuevo script descargado en segundo plano listo para tomar el control, mostrando un aviso visual al usuario:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then(function(reg) {
    // Si ya existe un worker instalado y en espera de activación
    if (reg.waiting) {
      mostrarAvisoActualizacion(reg.waiting);
    }

    // Escuchamos si se encuentra una nueva instalación en curso
    reg.addEventListener('updatefound', function() {
      const nuevoWorker = reg.installing;
      nuevoWorker.addEventListener('statechange', function() {
        if (nuevoWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // El nuevo worker está instalado pero bloqueado en espera
          mostrarAvisoActualizacion(nuevoWorker);
        }
      });
    });
  });

  // Escucha cuando el nuevo Service Worker toma el control definitivo de la app
  navigator.serviceWorker.addEventListener('controllerchange', function() {
    // Recarga la página automáticamente para levantar los nuevos archivos de caché
    window.location.reload();
  });
}

2. Mostrar aviso y enviar SKIP_WAITING

El botón flotante (Toast) le indica al usuario que hay una nueva versión del sistema. Al hacer click, envía un mensaje al worker en espera para ordenarle saltarse la cola de activación:

function mostrarAvisoActualizacion(workerEnEspera) {
  const toastContainer = document.getElementById('toast-actualizacion');
  const btnToast = document.getElementById('btn-recargar-toast');
  
  toastContainer.classList.remove('d-none'); // Muestra la alerta flotante

  btnToast.addEventListener('click', function() {
    // Envía la orden directa al Service Worker en espera
    workerEnEspera.postMessage({ type: 'SKIP_WAITING' });
  });
}

3. Escuchar el mensaje en sw.js

En nuestro archivo de Service Worker (sw.js), debemos capturar el mensaje enviado por el hilo principal del navegador para ejecutar la orden nativa skipWaiting():

// Dentro del archivo sw.js
self.addEventListener('message', function(e) {
  if (e.data && e.data.type === 'SKIP_WAITING') {
    // Fuerza la activación inmediata del nuevo Service Worker apagando el viejo
    self.skipWaiting();
  }
});

Puntos clave

  • Un nuevo Service Worker no reemplaza al viejo de forma inmediata si existen pestañas abiertas con el sitio.
  • El estado waiting bloquea la entrada en servicio del nuevo script por seguridad de integridad de datos.
  • Usa postMessage para transferir órdenes de activación desde la ventana del DOM hacia el hilo del worker.
  • Escucha el evento controllerchange para refrescar la página una vez que el nuevo worker tomó el control.

24 · PWA offline completa

Avanzado ~18 min

Hemos estudiado las piezas del rompecabezas por separado: almacenamiento local, base de datos IndexedDB, Service Workers, estrategias de caché y manifiestos. Ahora unificaremos todos estos conceptos para transformar nuestro **Reportero de Evidencias** (del Taller 1) en una aplicación web progresiva (PWA) 100% instalable y capaz de operar y guardar reportes sin señal telefónica.

  • Configurar los archivos manifest.json y sw.js en la estructura real del servidor.
  • Enlazar las cabeceras HTML de la aplicación con soporte para visualización responsiva e iOS.
  • Escribir un script de Service Worker completo con estrategia Stale-while-revalidate e instalación limpia.
  • Comprender el concepto crítico de Ámbito (Scope) de un Service Worker en producción.

1. La cabecera HTML final (Mobile + PWA)

Asegúrate de incorporar los enlaces de manifest y las metaetiquetas móviles en la sección <head> de tu aplicación:

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="theme-color" content="#e11d48">
  
  <!-- Integración de iOS -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <link rel="apple-touch-icon" href="webcode_sq.png">
  
  <!-- Enlace al Manifiesto -->
  <link rel="manifest" href="manifest.json">
  <title>Evidencias Clínicas</title>
</head>

2. El Service Worker de Producción (sw.js)

Escribe el código del Service Worker que interceptará las llamadas web y proveerá de velocidad de carga instantánea cacheando los recursos del sistema táctil:

const NOMBRE_CACHE = 'evidencias-cache-v1';
const RECURSOS = [
  '/',
  'html_02_movil.html',
  'webcode_sq.png',
  'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
  'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css'
];

// Instalación: Guardar recursos estáticos indispensables
self.addEventListener('install', function(e) {
  e.waitUntil(
    caches.open(NOMBRE_CACHE).then(cache => cache.addAll(RECURSOS))
  );
});

// Mensajería: Activación forzada por el usuario
self.addEventListener('message', function(e) {
  if (e.data && e.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// Intercepción y respuesta dinámica (Stale-while-revalidate)
self.addEventListener('fetch', function(e) {
  // Ignoramos peticiones externas o de subida POST
  if (e.request.method !== 'GET') return;

  e.respondWith(
    caches.match(e.request).then(function(respuestaCacheada) {
      const fetchPromesa = fetch(e.request).then(function(respuestaRed) {
        return caches.open(NOMBRE_CACHE).then(function(cache) {
          cache.put(e.request, respuestaRed.clone());
          return respuestaRed;
        });
      }).catch(() => console.log("Offline: Serviendo recurso local"));

      return respuestaCacheada || fetchPromesa;
    })
  );
});

3. Integración en tu archivo principal

Agrega la inicialización y escucha de actualizaciones en tu script JS de UI. Esto enlazará el botón de Toast para notificar nuevas versiones y forzar el refresco de pantalla:

// Registro del Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js').then(function(reg) {
    // Si ya existe actualización lista
    if (reg.waiting) {
      mostrarBotonToastActualizar(reg.waiting);
    }
    
    // Escuchar nuevas descargas
    reg.addEventListener('updatefound', function() {
      const worker = reg.installing;
      worker.addEventListener('statechange', function() {
        if (worker.state === 'installed' && navigator.serviceWorker.controller) {
          mostrarBotonToastActualizar(worker);
        }
      });
    });
  });

  // Recarga al cambiar de controlador
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    window.location.reload();
  });
}

Puntos clave

  • Unificar el Manifiesto y el Service Worker convierte la web de campo en una PWA 100% instalable.
  • Coloca el archivo sw.js en la raíz del proyecto para asegurar un alcance (`scope`) completo sobre todo el sitio.
  • La estrategia Stale-while-revalidate ofrece cargas inmediatas mientras descarga datos nuevos en segundo plano.
  • iOS Safari requiere cabeceras meta adicionales para activar la experiencia standalone de pantalla completa.

25 · WebView en Android con Kotlin

Intermedio ~14 min

Una estrategia muy común en la industria móvil consiste en maquetar el diseño de la aplicación utilizando HTML5, CSS y JavaScript (para aprovechar la velocidad de desarrollo web) y luego envolver el sitio dentro de un contenedor nativo para poder subirlo a las tiendas de aplicaciones como un archivo APK/AAB de instalación directa. Hoy estudiaremos cómo estructurar este contenedor híbrido en Android utilizando Kotlin y el componente **WebView**.

  • Definir e integrar un objeto `WebView` en el XML de maquetación de Android.
  • Habilitar la ejecución de JavaScript y las bases de datos del DOM (`domStorageEnabled`) con Kotlin.
  • Implementar `WebViewClient` personalizados para retener la navegación dentro de la app.
  • Configurar permisos de internet obligatorios en el `AndroidManifest.xml`.

1. Definir el control WebView en XML

En el archivo de maquetación visual de tu Activity (por ejemplo, activity_main.xml), definimos el lienzo del WebView para que cubra la pantalla completa del celular:

<!-- activity_main.xml -->
<WebView
    android:id="@+id/webViewClinica"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

2. Permisos en el AndroidManifest.xml

Para permitir que el motor web del celular consulte recursos e imágenes a través de internet, debemos declarar el permiso correspondiente en la raíz del manifiesto de Android:

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

3. Configurar e Inicializar en Kotlin

Por motivos de seguridad heredada, los WebViews nativos vienen con la mayoría de APIs web desactivadas por defecto. Debemos configurar explícitamente el soporte de JS y de persistencia local (necesaria para LocalStorage e IndexedDB):

// MainActivity.kt
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.WebChromeClient
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val webView: WebView = findViewById(R.id.webViewClinica)

        // Configuración crítica de Web Settings
        webView.settings.apply {
            javaScriptEnabled = true      // Obligatorio para ejecutar lógica en JS
            domStorageEnabled = true      // Obligatorio para LocalStorage e IndexedDB
            databaseEnabled = true        // Habilita persistencia de base de datos
            mediaPlaybackRequiresUserGesture = false // Permite reproducción de video directa
        }

        // WebViewClient evita que los enlaces abran Chrome fuera de tu aplicación
        webView.webViewClient = WebViewClient()

        // WebChromeClient gestiona permisos de cámara, GPS y diálogos de alerta
        webView.webChromeClient = WebChromeClient()

        // Cargamos la URL del curso o de tu PWA de producción
        webView.loadUrl("https://webcode.net.pe/web/html_02_movil.html")
    }
}

El rol de los Clientes del WebView

Cliente Responsabilidad Primaria Si no se configura...
WebViewClient Controla la carga de la página, redirecciones de URL y errores de red en la web. Al hacer click en cualquier link, el celular abrirá la aplicación externa de Google Chrome.
WebChromeClient Gestiona elementos de la interfaz de Chrome (título, barra de progreso, alertas JS y diálogos de permisos de hardware). La cámara, el GPS y las alertas de JS (`alert()`, `confirm()`) fallarán de forma silenciosa.

Puntos clave

  • El control WebView de Android sirve para renderizar aplicaciones web optimizadas e híbridas dentro de apps nativas.
  • Es obligatorio declarar el permiso de INTERNET en el manifiesto para cargar urls externas.
  • javaScriptEnabled y domStorageEnabled deben habilitarse manualmente en Kotlin para que IndexedDB y JS funcionen.
  • Configurar un WebViewClient asegura que la navegación de los enlaces permanezca encerrada dentro de la app.

26 · JavaScriptInterface: El puente nativo

Avanzado ~15 min

Aunque un WebView carga y muestra tu aplicación web de forma impecable, tu código JavaScript corre aislado dentro del navegador embebido sin acceso directo al hardware profundo de Android (como la linterna, almacenamiento SD, o alertas Toast del sistema). Para romper este aislamiento, Android permite crear un canal de comunicación bidireccional inyectando objetos Kotlin directamente en el contexto global de JavaScript mediante **JavaScriptInterface**.

  • Crear clases puente en Kotlin anotando funciones públicas con `@JavascriptInterface`.
  • Inyectar el puente nativo en el contexto de JavaScript con `addJavascriptInterface`.
  • Invocar alertas Toast y leer metadatos de hardware nativo de Android desde JavaScript.
  • Enviar datos de regreso desde Kotlin hacia la web usando `evaluateJavascript()`.

1. Crear la clase puente en Kotlin

En Kotlin, definimos una clase que contendrá las funciones nativas que queremos exponer a la web. Para que JavaScript pueda verlas, debemos decorarlas con la anotación de seguridad @JavascriptInterface:

// WebAppInterface.kt
import android.content.Context
import android.webkit.JavascriptInterface
import android.widget.Toast
import android.os.Build

class WebAppInterface(private val mContext: Context) {

    // Hace que el teléfono muestre una notificación Toast nativa
    @JavascriptInterface
    fun mostrarToastNativo(mensaje: String) {
      Toast.makeText(mContext, mensaje, Toast.LENGTH_SHORT).show()
    }

    // Retorna un metadato de hardware directamente a la web
    @JavascriptInterface
    fun obtenerModeloTelefono(): String {
      return "${Build.MANUFACTURER} ${Build.MODEL}"
    }
}

2. Inyectar el puente en el WebView

En tu archivo `MainActivity.kt`, enlazamos esta clase puente asignándole un nombre identificador (que será la variable global visible en window en la web):

// Dentro de onCreate() en MainActivity.kt
val webView: WebView = findViewById(R.id.webViewClinica)
webView.settings.javaScriptEnabled = true

// Registramos el puente con el nombre global "AndroidPuente"
webView.addJavascriptInterface(WebAppInterface(this), "AndroidPuente")

3. Invocar a Android desde JavaScript

En el código JS de nuestra página web, podemos detectar si el puente existe en el objeto window y disparar funciones del dispositivo nativo de forma directa:

function dispararAlertaNativa() {
  if (window.AndroidPuente) {
    // Llama al Toast de Android
    window.AndroidPuente.mostrarToastNativo("¡Hola desde el navegador web!");
    
    // Consulta datos del teléfono
    const modelo = window.AndroidPuente.obtenerModeloTelefono();
    console.log(`Dispositivo: ${modelo}`);
  } else {
    console.log("No estás corriendo dentro del WebView nativo de Android.");
  }
}

4. El puente reverso: De Kotlin a JavaScript

Si la aplicación nativa necesita enviarle datos a la web de forma proactiva (por ejemplo, notificarle que el GPS nativo obtuvo una coordenada), ejecutamos código JavaScript usando evaluateJavascript():

// Código ejecutado desde Kotlin en MainActivity
webView.evaluateJavascript("javascript:actualizarDesdeNativo('Datos inyectados');") { resultado ->
    // Recibe el valor devuelto por la función JS si fuera necesario
    Log.d("WebView", "Retorno de JS: $resultado")
}

Puntos clave

  • El puente nativo permite que la web ejecute lógica compleja del sistema operativo móvil fuera de los límites de las Web APIs.
  • Las funciones Kotlin a exponer requieren obligatoriamente la anotación de seguridad @JavascriptInterface.
  • webView.addJavascriptInterface(objeto, "NombrePuente") inyecta la variable en el contexto global de la web.
  • Usa webView.evaluateJavascript("codigoJS", callback) para inyectar datos de forma asíncrona de nativo hacia JS.

27 · WebView en iOS con Swift (WKWebView)

Intermedio ~12 min

En el ecosistema de Apple (iPhones e iPads), el contenedor web moderno y oficial se llama **WKWebView** (dentro del framework WebKit). Al igual que su contraparte en Android, WKWebView requiere configuraciones explícitas de persistencia local y directivas de seguridad en Xcode para permitir que tu aplicación híbrida se comporte de forma robusta e integrada. Hoy estudiaremos la implementación nativa en Swift.

  • Inicializar un contenedor `WKWebView` programáticamente en Swift usando UIKit.
  • Habilitar el almacenamiento local persistente (`WKWebsiteDataStore`) para soportar bases de datos.
  • Cargar URLs seguras de forma síncrona en el hilo nativo de iOS.
  • Configurar excepciones de red (`App Transport Security`) en el `Info.plist` de Xcode.

1. Inicialización en Swift (ViewController.swift)

A diferencia de Android, en iOS es una excelente práctica inicializar el WebView de forma programática en la carga de la vista del controlador (ViewController), abstrayéndose del maquetador visual storyboard:

// ViewController.swift
import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate {
    // Definimos la propiedad del visor web
    var webView: WKWebView!

    override func loadView() {
        // Configuraciones iniciales del contenedor de WebKit
        let configuracionWeb = WKWebViewConfiguration()
        
        // Habilita explícitamente el almacén de datos persistentes del dispositivo
        configuracionWeb.websiteDataStore = WKWebsiteDataStore.default()
        
        // Inicializamos el WebView con el tamaño del contenedor principal de iOS
        webView = WKWebView(frame: .zero, configuration: configuracionWeb)
        webView.uiDelegate = self
        
        // Reemplazamos la vista del controlador por nuestro WebView
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Dirección HTTPS de tu PWA en producción
        if let urlDestino = URL(string: "https://webcode.net.pe/web/html_02_movil.html") {
            let peticion = URLRequest(url: urlDestino)
            webView.load(peticion)
        }
    }
}

2. Configurar excepciones en Info.plist

iOS tiene políticas de seguridad muy estrictas conocidas como **ATS (App Transport Security)** que bloquean la carga de cualquier página web que no corra bajo HTTPS estricto o que corra en servidores locales de desarrollo. Debemos añadir estas llaves en el archivo de configuración Info.plist de Xcode si deseamos probar localmente:

<!-- Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

Gestión de caída de procesos (White Screens)

En iOS, el motor del WebView se ejecuta como un subproceso del sistema operativo separado de tu aplicación. Si el celular se queda sin memoria RAM libre, el sistema operativo de Apple matará de forma silenciosa el renderizador del WebView, dejando al usuario con una pantalla blanca inmóvil. Para solucionar esto en producción, debemos escuchar el evento de terminación:

// Añade este método delegado en tu ViewController.swift
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
    // El proceso del WebView murió. Recargamos la vista de forma automática
    print("Contenedor Web colapsado. Recargando...")
    webView.reload()
}

Puntos clave

  • WKWebView es el componente moderno de WebKit para compilar apps híbridas en iPhones e iPads.
  • Configura WKWebsiteDataStore.default() para dar soporte de persistencia a IndexedDB y LocalStorage.
  • Declara excepciones ATS en Info.plist para poder conectarte a servidores de prueba HTTP en desarrollo.
  • Implementa webViewWebContentProcessDidTerminate para recargar automáticamente la app si Safari del WebView se cierra por RAM.

28 · WKScriptMessageHandler: El puente iOS

Avanzado ~15 min

En iOS, la inyección directa de objetos Swift arbitrarios en la ventana del navegador está prohibida. En su lugar, Apple proporciona un protocolo seguro de paso de mensajes unificado mediante **WKScriptMessageHandler**. A través de este sistema, JavaScript puede enviar información serializada y llamadas al hilo nativo de Swift usando la API unificada de mensajes de WebKit.

  • Registrar manejadores de mensajes (`messageHandlers`) en la configuración del WebView de Swift.
  • Implementar el protocolo obligatorio `WKScriptMessageHandler` en tu controlador de iOS.
  • Enviar textos y diccionarios JSON estructurados desde JavaScript hacia Swift.
  • Ejecutar llamadas de retorno y alterar el DOM desde Swift usando `evaluateJavaScript`.

1. Registrar el Puente en Swift

En el archivo de tu controlador de iOS, agregamos el manejador de mensajes a la configuración de carga de la página, definiendo el identificador que leerá JavaScript:

// ViewController.swift (Carga inicial)
override func loadView() {
    let configuracionWeb = WKWebViewConfiguration()
    configuracionWeb.websiteDataStore = WKWebsiteDataStore.default()

    // Controlador de contenido para inyección y escucha
    let controladorContenido = WKUserContentController()
    
    // Registramos nuestro puente con el identificador "IosPuente"
    controladorContenido.add(self, name: "IosPuente")
    configuracionWeb.userContentController = controladorContenido

    webView = WKWebView(frame: .zero, configuration: configuracionWeb)
    webView.uiDelegate = self
    view = webView
}

2. Escuchar y Parsear los mensajes en Swift

Hacemos que nuestro ViewController herede del protocolo WKScriptMessageHandler e implementamos el método encargado de capturar y decodificar el cuerpo del mensaje:

// Extendemos el controlador para capturar los mensajes de JavaScript
class ViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler {

    // Función gatillada cuando JavaScript hace un postMessage
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "IosPuente" {
            // Evaluamos si el mensaje recibido es un diccionario JSON
            if let datosDiccionario = message.body as? [String: Any] {
                let operacion = datosDiccionario["operacion"] as? String
                let registroId = datosDiccionario["id"] as? Int
                
                print("Operación nativa solicitada: \(operacion ?? "") con ID: \(registroId ?? 0)")
                
                if operacion == "vibrar" {
                    // Llamamos a motor háptico de iOS
                    UIImpactFeedbackGenerator(style: .medium).impactOccurred()
                }
            }
        }
    }
}

3. Enviar datos desde JavaScript

En el código de nuestra aplicación web, verificamos si el navegador posee la API WebKit activa de iOS y enviamos los datos en formato de objeto simple:

function solicitarVibracionNativaEnIphone() {
  if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.IosPuente) {
    // Enviamos un diccionario de parámetros a Swift
    window.webkit.messageHandlers.IosPuente.postMessage({
      "operacion": "vibrar",
      "id": 404
    });
  } else {
    console.log("No estás en un navegador WebKit nativo de iOS.");
  }
}

4. El puente reverso en iOS

Para inyectar datos de regreso desde Swift hacia la web, llamamos al método evaluateJavaScript pasándole la instrucción como cadena de texto:

// Ejecutado en Swift (iOS)
webView.evaluateJavaScript("actualizarDesdeSwift('Operación completada en iPhone');") { (resultado, error) in
    if let err = error {
        print("Error inyectando JS: \(err.localizedDescription)")
    } else {
        print("Resultado devuelto por JS: \(String(describing: resultado))")
    }
}

Puntos clave

  • iOS prohíbe inyectar objetos complejos y utiliza el protocolo seguro WKScriptMessageHandler.
  • Enlaza el receptor mediante controladorContenido.add(self, name: "NombrePuente") en Swift.
  • En JS, despacha la información invocando window.webkit.messageHandlers.NombrePuente.postMessage(...).
  • Decodifica las variables en Swift desempaquetando el cuerpo del mensaje asistiéndote de [String: Any].

29 · Gestión de archivos en WebViews

Avanzado ~15 min

Uno de los errores más frustrantes al empaquetar aplicaciones web dentro de un WebView de Android es el botón de subir archivos. Si el usuario pulsa un elemento <input type="file"> (como el que creamos en el Capítulo 6 para la cámara), el WebView **no hace absolutamente nada**. Esto sucede porque el control embebido de Android carece de una interfaz de selección de ficheros y delega esta lógica a la aplicación nativa receptora.

  • Comprender por qué fallan los inputs de archivos en WebViews por defecto.
  • Sobrescribir el método `onShowFileChooser` de `WebChromeClient` en Kotlin.
  • Crear Intents de selección de ficheros y abrir la galería o cámara nativa.
  • Retornar la URI del archivo seleccionado al WebView mediante la clase `ValueCallback`.

1. Guardar el Callback de subida

En nuestro MainActivity.kt, declaramos una variable de tipo `ValueCallback` para almacenar el canal de retorno del archivo solicitado por la web:

// Dentro de MainActivity.kt
private var callbackCargaArchivo: ValueCallback<Array<Uri>>? = null
private val CODIGO_PETICION_ARCHIVO = 101

2. Sobrescribir onShowFileChooser

Dentro del objeto WebChromeClient de tu WebView, interceptamos la pulsación del input y creamos un Intent para abrir los selectores del sistema operativo:

// Dentro de MainActivity.kt (onCreate)
webView.webChromeClient = object : WebChromeClient() {
    override fun onShowFileChooser(
        webView: WebView?,
        filePathCallback: ValueCallback<Array<Uri>>?,
        fileChooserParams: FileChooserParams?
    ): Boolean {
        // Si ya hay una transacción abierta, la cancelamos
        callbackCargaArchivo?.onReceiveValue(null)
        callbackCargaArchivo = filePathCallback

        // Creamos el selector de archivos nativo
        val intent = fileChooserParams?.createIntent()
        try {
            startActivityForResult(intent, CODIGO_PETICION_ARCHIVO)
        } catch (e: Exception) {
            callbackCargaArchivo = null
            return false
        }
        return true
    }
}

3. Recibir el archivo y liberar el Callback

En el método onActivityResult de tu actividad de Android, capturamos el resultado del selector. Si el usuario seleccionó un archivo con éxito, le pasamos su ruta (URI) al callback de la web. **Si canceló, debemos enviarle null obligatoriamente**:

// Dentro de MainActivity.kt
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    
    if (requestCode == CODIGO_PETICION_ARCHIVO) {
        if (callbackCargaArchivo == null) return

        // Extraemos las URIs del archivo seleccionado
        val resultado: Array<Uri>? = if (resultCode == RESULT_OK && data != null) {
            WebChromeClient.FileChooserParams.parseResult(resultCode, data)
        } else {
            null
        }

        // Enviamos las URIs al WebView para que JavaScript las procese
        callbackCargaArchivo?.onReceiveValue(resultado)
        callbackCargaArchivo = null // Liberamos el puntero
    }
}

Puntos clave

  • Los inputs de archivos en WebViews requieren que el Activity nativo gestione el diálogo y el selector del OS.
  • onShowFileChooser se dispara cuando el usuario pulsa un botón <input type="file"> en el HTML.
  • Usa startActivityForResult para abrir la cámara o galería y obtener la URI física de los archivos.
  • Es obligatorio ejecutar onReceiveValue (con la ruta del archivo o con null) para desbloquear el hilo del WebView.

30 · Carga de archivos en iOS (WebKit)

Avanzado ~12 min

A diferencia de Android (donde debes programar manualmente la delegación e Intents de archivos), Apple maneja de forma automática la pulsación de un elemento <input type="file"> dentro de un **WKWebView**, desplegando de inmediato una hoja de acción nativa que ofrece capturar una foto con la cámara, elegirla desde la fototeca o navegar en los archivos del teléfono. Sin embargo, si no declaras los permisos correspondientes en tu app, ésta se cerrará inmediatamente al pulsar el botón.

  • Aprender cómo procesa WKWebView de forma nativa la selección de ficheros HTML.
  • Declarar las llaves de privacidad de cámara y galería obligatorias en el `Info.plist` de Xcode.
  • Implementar los delegados de interfaz `WKUIDelegate` en el controlador Swift.
  • Evitar los colapsos repentinos (crashes) al abrir la hoja de acción en iPads.

1. Declarar Permisos de Privacidad en Xcode

En el ecosistema de iOS, cualquier intento de abrir la cámara o la fototeca (incluso si la petición proviene de un control WebView interno) requiere la declaración explícita de su propósito en el archivo Info.plist de tu proyecto nativo, mediante llaves de tipo String:

<!-- Xcode Info.plist (Formato XML de desarrollo) -->
<key>NSCameraUsageDescription</key>
<string>Necesitamos acceso a la cámara para tomar fotos de las evidencias clínicas de campo.</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>Necesitamos acceso a la fototeca para que selecciones capturas previas de tu galería.</string>

2. Adoptar el WKUIDelegate en Swift

Para asegurar que WKWebView pueda abrir menús de selección de archivos y ventanas de diálogo sin bloqueos, enlazamos el delegado WKUIDelegate a nuestro ViewController nativo:

// ViewController.swift
import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate {
    var webView: WKWebView!

    override func loadView() {
        let configuracion = WKWebViewConfiguration()
        webView = WKWebView(frame: .zero, configuration: configuracion)
        
        // Asignamos el delegado de interfaz
        webView.uiDelegate = self
        view = webView
    }
    
    // El UIDelegate se encarga de interceptar y abrir los menús nativos del navegador
}

Evitar el colapso fatal en iPads

En dispositivos iPad, iOS no puede mostrar hojas de acción nativas sueltas en la pantalla (las muestra como globos o popovers que deben apuntar a un botón o elemento de origen). Si tu WebView abre la galería en un iPad y tu código no controla el origen del popover, el sistema colapsará inmediatamente con un error fatal. Controlamos esto en Swift:

// Añadir en el ViewController para capturar comportamientos de visualización de popover
extension ViewController: UIPopoverPresentationControllerDelegate {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let popoverController = segue.destination.popoverPresentationController {
            // Asignamos la vista del WebView como origen seguro del globo para evitar caídas
            popoverController.sourceView = self.webView
            popoverController.sourceRect = CGRect(x: self.webView.bounds.midX, y: self.webView.bounds.midY, width: 0, height: 0)
        }
    }
}

Puntos clave

  • WKWebView en iOS maneja de forma automática los menús de carga y selección de archivos HTML.
  • Es obligatorio declarar `NSCameraUsageDescription` y `NSPhotoLibraryUsageDescription` en el `Info.plist`.
  • Omitir estos strings de privacidad causará un colapso instantáneo de la app al pulsar el botón de subir fotos.
  • En iPads, especifica el `sourceView` en los controles de popover para evitar crashes de pantalla.

31 · Geolocalización híbrida nativa

Avanzado ~12 min

Cuando ejecutas Geolocalización mediante la API web estándar (navigator.geolocation) dentro de un WebView, la precisión suele verse reducida. Esto sucede porque el motor embebido carece de permisos de antenas satelitales autónomos y depende de estimaciones rápidas de red. Para lograr una precisión métrica de grado médico, la mejor práctica en apps híbridas es consultar el GPS nativo del sistema y transmitir las coordenadas hacia la web.

  • Identificar las limitaciones de precisión de la Geolocalización web en contenedores embebidos.
  • Consultar el proveedor de ubicación de Google (`FusedLocationProviderClient`) en Kotlin.
  • Implementar el manejador de localización nativa (`CLLocationManager`) en iOS Swift.
  • Diseñar un adaptador de JavaScript que elija automáticamente el GPS nativo o el fallback de la web.

1. Consultar el GPS nativo en Android (Kotlin)

Utilizamos la librería de servicios de ubicación para solicitar la última coordenada física guardada por la antena del teléfono móvil e inyectarla dinámicamente en el DOM de la página:

// Dentro de MainActivity.kt
import com.google.android.gms.location.LocationServices

fun obtenerUbicacionGpsNativa() {
    val clienteLocalizacion = LocationServices.getFusedLocationProviderClient(this)
    
    // Verificamos permisos nativos previamente aceptados por el usuario
    if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
        clienteLocalizacion.lastLocation.addOnSuccessListener { ubicacion ->
            if (ubicacion != null) {
                val lat = ubicacion.latitude
                val lng = ubicacion.longitude
                // Inyectamos las coordenadas en el JavaScript del WebView
                webView.evaluateJavascript("javascript:recibirCoordenadasNativas($lat, $lng);", null)
            } else {
                webView.evaluateJavascript("javascript:notificarErrorGps('No se pudo obtener el GPS nativo');", null)
            }
        }
    }
}

2. Consultar el GPS nativo en iOS (Swift)

En Apple importamos CoreLocation, suscribimos el delegado correspondiente y disparamos la inyección en formato de punto flotante de alta precisión:

// ViewController.swift (CoreLocation)
import CoreLocation

class ViewController: UIViewController, WKUIDelegate, CLLocationManagerDelegate {
    let gestorUbicacion = CLLocationManager()
    
    func iniciarGpsNativo() {
        gestorUbicacion.delegate = self
        gestorUbicacion.desiredAccuracy = kCLLocationAccuracyBest // Máxima precisión
        gestorUbicacion.requestWhenInUseAuthorization() // Solicita permiso de GPS
        gestorUbicacion.startUpdatingLocation()
    }
    
    // Delegado que recibe los cambios de coordenadas de la antena física
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let ubicacion = locations.last {
            let lat = ubicacion.coordinate.latitude
            let lng = ubicacion.coordinate.longitude
            
            // Apagamos el GPS para evitar consumo excesivo de batería
            gestorUbicacion.stopUpdatingLocation()
            
            // Inyectamos los datos hacia WKWebView
            webView.evaluateJavaScript("recibirCoordenadasNativas(\(lat), \(lng));", completionHandler: nil)
        }
    }
}

3. Recibir las coordenadas en JavaScript

Nuestra aplicación web debe exponer una función global en window para que los contenedores nativos inyecten los datos y un adaptador de llamada de inicio:

// Expuesto de forma global en nuestra web
window.recibirCoordenadasNativas = function(lat, lng) {
  document.getElementById('coordenadas-gps').value = `${lat}, ${lng}`;
  console.log(`Ubicación recibida vía contenedor híbrido: ${lat}, ${lng}`);
};

// Adaptador para solicitar la posición
function solicitarUbicacion() {
  if (window.AndroidPuente) {
    // Solicita ubicación nativa en Android
    window.AndroidPuente.solicitarGPSNativo();
  } else if (window.webkit && window.webkit.messageHandlers.IosPuente) {
    // Solicita ubicación nativa en iOS
    window.webkit.messageHandlers.IosPuente.postMessage({ "operacion": "gps" });
  } else {
    // Fallback a geolocalización ordinaria del navegador
    navigator.geolocation.getCurrentPosition(function(pos) {
      window.recibirCoordenadasNativas(pos.coords.latitude, pos.coords.longitude);
    });
  }
}

Puntos clave

  • Los navegadores embebidos (WebViews) tienen precisión GPS deficiente para optimizar recursos del sistema.
  • Consultar las APIs de localización nativas permite acceder de forma directa a los chips y antenas GPS.
  • Utiliza evaluateJavascript desde Kotlin o Swift para devolver los valores de latitud y longitud.
  • El GPS nativo consume mucha energía; solicita coordenadas sólo a demanda del usuario y apágalo tras fijarlas.

32 · Taller 2: El contenedor híbrido completo

Avanzado ~20 min

En este segundo taller integrador, daremos el paso definitivo para unificar el desarrollo web con el desarrollo móvil nativo. Construiremos la estructura completa y el código fuente real del contenedor híbrido multiplataforma. Escribiremos el código de la actividad principal de Android (Kotlin), el controlador principal de iOS (Swift) y el puente conector JavaScript universal que corre del lado del navegador.

  • Implementar la Activity Kotlin unificando WebChromeClient, onShowFileChooser y la JavascriptInterface.
  • Implementar el ViewController Swift integrando WKScriptMessageHandler y los delegados de WebKit.
  • Escribir el script JS universal que automatiza llamadas a puentes de hardware o fallbacks web.
  • Resolver problemas comunes de compilación e inyección en emuladores y dispositivos reales.

1. Contenedor de Android Completo (Kotlin)

A continuación se detalla el código completo para tu archivo MainActivity.kt de Android. Este archivo inicializa el WebView, activa los permisos, inyecta el puente nativo y abre la galería de archivos:

package com.webcode.evidencias

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    private var webView: WebView? = null
    private var rutaArchivoCallback: ValueCallback<Array<Uri>>? = null
    private val CODIGO_SELECCION_ARCHIVO = 101

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        webView = WebView(this)
        setContentView(webView)

        webView?.settings?.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            allowFileAccess = true
        }

        // Puente nativo expuesto a JavaScript
        webView?.addJavascriptInterface(object {
            @JavascriptInterface
            fun dispararNotificacion(msg: String) {
                Toast.makeText(this@MainActivity, msg, Toast.LENGTH_SHORT).show()
            }
        }, "AndroidPuente")

        webView?.webViewClient = WebViewClient()
        webView?.webChromeClient = object : WebChromeClient() {
            // Manejador de selector de archivos
            override fun onShowFileChooser(
                wv: WebView?,
                cb: ValueCallback<Array<Uri>>?,
                params: FileChooserParams?
            ): Boolean {
                rutaArchivoCallback?.onReceiveValue(null)
                rutaArchivoCallback = cb
                
                val intent = params?.createIntent()
                startActivityForResult(intent, CODIGO_SELECCION_ARCHIVO)
                return true
            }
        }

        webView?.loadUrl("https://webcode.net.pe/web/html_02_movil.html")
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == CODIGO_SELECCION_ARCHIVO) {
            val urls = WebChromeClient.FileChooserParams.parseResult(resultCode, data)
            rutaArchivoCallback?.onReceiveValue(urls)
            rutaArchivoCallback = null
        }
    }
}

2. Contenedor de iOS Completo (Swift)

A continuación se detalla el código completo para tu archivo ViewController.swift de iOS en Xcode. Configura el visor WebKit y recibe los comandos inyectados:

import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler {
    var webView: WKWebView!

    override func loadView() {
        let configuracion = WKWebViewConfiguration()
        configuracion.websiteDataStore = WKWebsiteDataStore.default()

        // Canal de escucha JS
        let controlador = WKUserContentController()
        controlador.add(self, name: "IosPuente")
        configuracion.userContentController = controlador

        webView = WKWebView(frame: .zero, configuration: configuracion)
        webView.uiDelegate = self
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        if let url = URL(string: "https://webcode.net.pe/web/html_02_movil.html") {
            webView.load(URLRequest(url: url))
        }
    }

    // Receptor del postMessage
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "IosPuente", let cuerpo = message.body as? [String: Any] {
            if let accion = cuerpo["accion"] as? String {
                // Procesamos el comando
                print("Acción recibida desde la web: \(accion)")
                if accion == "alerta" {
                    let alert = UIAlertController(title: "iOS Nativo", message: cuerpo["mensaje"] as? String, preferredStyle: .alert)
                    alert.addAction(UIAlertAction(title: "OK", style: .default))
                    self.present(alert, animated: true)
                }
            }
        }
    }
}

3. Adaptador Puente en JavaScript (Lado Web)

Este script se implementa en la página web para detectar en qué plataforma se está ejecutando la aplicación y despachar el comando nativo correspondiente, manteniendo un fallback web si corre en un navegador tradicional:

// Adaptador universal para notificaciones nativas
function enviarNotificacionNativa(texto) {
  if (window.AndroidPuente) {
    // Comunicarse con el puente de Android (Kotlin)
    window.AndroidPuente.dispararNotificacion(texto);
  } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.IosPuente) {
    // Comunicarse con el puente de iOS (Swift)
    window.webkit.messageHandlers.IosPuente.postMessage({
      "accion": "alerta",
      "mensaje": texto
    });
  } else {
    // Fallback en navegador web de escritorio tradicional
    alert(`Web Alert: ${texto}`);
  }
}

Puntos clave

  • Un contenedor híbrido robusto requiere coordinar las Web APIs con código nativo Android e iOS.
  • En Android, el control del ciclo de subida de archivos se unifica a través de un `ValueCallback` activo.
  • En iOS, `WKScriptMessageHandler` captura las llamadas estructuradas que JavaScript envía en un objeto JSON.
  • El adaptador de JS centraliza la lógica, aislando los dialectos móviles nativos del resto de la app web.

33 · Seguridad y Content Security Policy (CSP)

Avanzado ~12 min

La seguridad en aplicaciones móviles híbridas es un aspecto sumamente crítico. Dado que inyectamos puentes nativos (Kotlin/Swift) dentro del contexto de JavaScript, cualquier vulnerabilidad de inyección de código (XSS) en la página web permitiría a un atacante ejecutar código con los mismos privilegios que la aplicación móvil nativa. Hoy aprenderemos a blindar nuestro WebView mediante directivas **Content Security Policy (CSP)**.

  • Mitigar los riesgos de XSS e inyección de código dentro de contenedores embebidos.
  • Diseñar una etiqueta meta CSP restrictiva optimizada para aplicaciones móviles híbridas.
  • Restringir las conexiones de datos externas (`connect-src`) únicamente a las APIs seguras de tu servidor.
  • Bloquear la ejecución de scripts no autorizados o inyecciones inline en producción.

1. El riesgo del puente expuesto

Si tu aplicación híbrida inyecta un puente nativo de hardware y tu código JavaScript permite la directiva unsafe-inline, un tercero podría inyectar código en tu base de datos (por ejemplo, en el nombre de un paciente) que al renderizarse llame al puente para robar datos o prender la cámara del celular:

// Ejemplo de inyección maliciosa (XSS) que intentará abusar del puente
const datosPacienteInyectados = "<script>AndroidPuente.dispararNotificacion('Ataque');</script>";

2. Diseñar la directiva CSP para el móvil

Para evitar este escenario, colocamos una etiqueta de cabecera de Content Security Policy en la sección <head> de nuestro documento HTML. Esta directiva bloquea de forma estricta todo script que no provenga de nuestro propio dominio o servidores CDN expresamente autorizados:

<!-- Colocado en el head de html_02_movil.html -->
<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; 
               script-src 'self' https://cdn.jsdelivr.net; 
               style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; 
               img-src 'self' data: blob:;
               connect-src 'self' https://webcode.net.pe;">

Análisis de directivas recomendadas

  • default-src 'self': Restringe de manera general que todos los recursos (imágenes, scripts, estilos) procedan por defecto únicamente de la propia carpeta local de la aplicación.
  • script-src 'self' ...: Declara de dónde se pueden descargar y ejecutar scripts. Al omitir `unsafe-inline` y `unsafe-eval`, el navegador se negará en redondo a ejecutar cualquier fragmento de script escrito directamente en los atributos HTML o tags de texto.
  • img-src 'self' data: blob:: Permite cargar imágenes locales y también imágenes binarias codificadas de cámara convertidas en `Blob` (Canvas) o strings en Base64.
  • connect-src 'self' ...: Limita a qué dominios de internet se pueden enviar datos vía `fetch` o `XMLHttpRequest`, previniendo que un script inyectado envíe tus coordenadas GPS o fotos a servidores extraños.

3. Forzar HTTPS en el AndroidManifest

Para bloquear el tráfico no cifrado en texto plano en la red móvil (susceptible a ataques Man-in-the-Middle), configuramos el manifiesto de Android para rechazar tráfico HTTP ordinario:

<!-- Dentro de <application> en AndroidManifest.xml -->
android:usesCleartextTraffic="false"

Puntos clave

  • Las apps híbridas exponen APIs nativas a JavaScript, lo que magnifica el peligro de vulnerabilidades XSS.
  • Implementa políticas CSP estrictas omitiendo `unsafe-inline` en la ejecución de scripts.
  • La directiva connect-src debe limitarse exclusivamente a los endpoints HTTPS de tu servidor web en producción.
  • Habilita directivas de tráfico seguro y restringe el cleartext en los manifiestos móviles nativos.

34 · Optimización y preparación para tiendas

Intermedio ~12 min

El empaquetado de una aplicación web dentro de un contenedor nativo no culmina con el código de compilación de desarrollo. Para publicar con éxito en las tiendas oficiales (Google Play Store y Apple App Store), es obligatorio realizar tareas de depuración, optimizar el peso del paquete final, ofuscar el código contra la ingeniería inversa y configurar las firmas digitales correspondientes. Hoy estudiaremos estos pasos finales.

  • Minimizar y comprimir el peso de los recursos web estáticos (HTML/CSS/JS).
  • Configurar reglas de ProGuard/R8 en Android para proteger las interfaces de JavaScript.
  • Optimizar los paquetes nativos generando formatos AAB en Android y archivos de firma en iOS.
  • Gestionar la memoria caché del WebView para evitar saturar el almacenamiento físico del móvil.

1. Conservar los puentes JS en Android (proguard-rules.pro)

Cuando compilas en modo Release para producción, Android activa **ProGuard/R8**, una herramienta que reduce el tamaño del código eliminando variables inútiles y renombrando clases y métodos para ofuscarlos (ej. `class InterfazNativa` pasa a llamarse `class a`). Si esto ocurre, JavaScript no podrá invocar los puentes. Debemos declarar reglas de exclusión:

# Archivo proguard-rules.pro en Android Studio
# Conserva las anotaciones críticas y evita ofuscar las clases con interfaces JS
-keepattributes *Annotation*,Signature

-keepclassmembers class * {
    @android.webkit.JavascriptInterface <methods>;
}

2. Compilar el formato AAB para Google Play

Google Play ya no acepta la subida de instaladores APK tradicionales para nuevas apps. En su lugar, exige el formato **Android App Bundle (.aab)**. Esta tecnología compila tu app de forma modular y el servidor de Google genera APKs personalizados al descargar según el procesador y densidad de pantalla del teléfono receptor:

# Comando de Gradle para compilar el paquete de distribución optimizado en modo release
./gradlew bundleRelease

3. Limpieza de memoria y caché en iOS (Swift)

Para evitar que el contenedor de WebKit de iOS acumule gigabytes de caché web y bases de datos obsoletas de sesiones previas en el iPhone de los usuarios, es recomendable realizar rutinas de mantenimiento asíncronas:

// Dentro de ViewController.swift (Mantenimiento de caché)
func limpiarAlmacenamientoObsoleto() {
    let almacenDatos = WKWebsiteDataStore.default()
    
    // Identifica todos los tipos de datos locales guardados por la web
    let tiposDatos = WKWebsiteDataStore.allWebsiteDataTypes()
    let fechaInicio = Date(timeIntervalSince1970: 0) // Desde el inicio de la app
    
    almacenDatos.removeData(ofTypes: tiposDatos, modifiedSince: fechaInicio) {
        print("Caché web de iOS limpiada y liberada con éxito.")
    }
}

Puntos clave

  • La ofuscación de código renombra variables y clases para reducir peso y dificultar la ingeniería inversa.
  • En Android, es vital evitar que ProGuard renombre las funciones decoradas con `@JavascriptInterface`.
  • El formato Android App Bundle (.aab) es mandatorio para subir nuevas aplicaciones híbridas a Google Play.
  • Implementar rutinas de limpieza de datos en `WKWebsiteDataStore` previene la saturación del espacio del teléfono.

35 · Anexo de equivalencias y graduación

Meta ~15 min

¡Felicitaciones! Has completado el recorrido completo de 35 capítulos del manual **HTML en móviles · de cero a experto**. A lo largo de esta guía práctica, hemos aprendido a explotar las APIs de hardware de la web moderna, optimizar la experiencia móvil con CSS y eventos táctiles, programar Service Workers offline first, configurar el Web App Manifest y compilar contenedores híbridos nativos en Android y en iOS.

Para cerrar con broche de oro, presentaremos la Tabla Maestra de Equivalencias Híbridas. Esta tabla sirve como referencia rápida para que sepas en todo momento qué API web implementar y cuál es su equivalente nativo en Kotlin y Swift.

Tabla Maestra de Equivalencias

Capacidad de Hardware Web / PWA (HTML5) Android Nativo (Kotlin) iOS Nativo (Swift)
Acceso a Cámara y Vídeo getUserMedia() o <input capture> CameraX / Camera2 API AVFoundation (AVCaptureSession)
Lectura de Coordenadas (GPS) navigator.geolocation FusedLocationProviderClient CLLocationManager
Almacenamiento Local Estructurado IndexedDB / localStorage Room Database / SQLite Core Data / SwiftData
Feedback Háptico (Vibración) navigator.vibrate() Vibrator / VibrationEffect UIImpactFeedbackGenerator
Bloqueo de Suspensión de Pantalla navigator.wakeLock keepScreenOn = true isIdleTimerDisabled = true
Compartir Contenido (Ficheros/Texto) navigator.share() Intent (ACTION_SEND) UIActivityViewController
Acceso a Agenda de Contactos navigator.contacts.select() ContactsContract (ContentProvider) CNContactPickerViewController
Registro del Puente Híbrido window.AndroidPuente o messageHandlers addJavascriptInterface() WKScriptMessageHandler

Rumbo al siguiente nivel

Con este manual has adquirido las bases sólidas para entender el potencial y los límites de la web móvil. Ahora sabes con argumentos técnicos cuándo implementar una PWA de distribución instantánea sin fricciones de instalación y cuándo es estrictamente necesario llevar el proyecto hacia un desarrollo nativo en Kotlin o Swift.

Te invitamos a regresar al panel principal para continuar perfeccionando tus habilidades en nuestra serie de guías profesionales.

¡Graduación Completada!

Has finalizado el manual de HTML en dispositivos móviles. Estás listo para diseñar aplicaciones web offline-first y conectarlas con el hardware físico.