Crear APK gratis

API del puente de JavaScript

Información del dispositivo y de la app

Qué teléfono es, qué compilación, un identificador estable por instalación y las API estándar de ubicación y cámara.

getUserMedia estándar web#

navigator.mediaDevices.getUserMedia — camera and microphone

Ejemplo

La API web estándar de cámara y micrófono funciona dentro de la app. La app muestra la solicitud de permiso propia de Android y entrega el flujo a la página.

Devuelve: una Promise de un MediaStream, o un rechazo (NotAllowedError cuando el usuario lo rechazó o el interruptor estaba desactivado al compilar, NotFoundError cuando no existe ese dispositivo). Requiere: Cámara y/o Micrófono en el Paso 4 (Acceso). Cuando compilas desde un ZIP o una página HTML y tu código llama a getUserMedia({ video: … }) o { audio: … } (o usa <input capture>, BarcodeDetector, SpeechRecognition), la compilación encuentra la llamada y añade el permiso correspondiente por ti; { video: false } no añade Cámara. En una compilación de sitio web (URL), márcalos tú mismo.

Muestra la cámara trasera en un elemento de vídeo.

<video id="preview" autoplay playsinline muted></video>
<button id="startCam">Start camera</button>
<script>
document.getElementById('startCam').addEventListener('click', async function () {
  if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
    showMessage('Camera is not available here.');
    return;
  }
  try {
    var stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: 'environment' },   // 'user' = front camera
      audio: false
    });
    document.getElementById('preview').srcObject = stream;
  } catch (err) {
    if (err.name === 'NotAllowedError') showMessage('Camera permission was refused.');
    else showMessage('Camera error: ' + err.name);
  }
});
</script>

Graba una nota de voz solo con el micrófono.

async function recordVoice(seconds) {
  var stream;
  try {
    stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  } catch (err) {
    showMessage('Microphone permission was refused.');
    return;
  }
  var chunks = [];
  var rec = new MediaRecorder(stream);
  rec.ondataavailable = function (e) { chunks.push(e.data); };
  rec.onstop = function () {
    stream.getTracks().forEach(function (t) { t.stop(); });   // turn the mic off
    var blob = new Blob(chunks, { type: rec.mimeType });
    document.getElementById('player').src = URL.createObjectURL(blob);
  };
  rec.start();
  setTimeout(function () { rec.stop(); }, seconds * 1000);
}

Toma una foto del flujo de la cámara y libera la cámara.

async function snapshot() {
  if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) return null;
  let stream;
  try {
    stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
  } catch (err) {
    showMessage(err.name === 'NotFoundError' ? 'No camera on this device.' : 'Camera permission was refused.');
    return null;
  }
  const video = document.createElement('video');
  video.srcObject = stream;
  await video.play();
  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth; canvas.height = video.videoHeight;
  canvas.getContext('2d').drawImage(video, 0, 0);
  stream.getTracks().forEach(function (t) { t.stop(); });
  return canvas.toDataURL('image/jpeg', 0.9);
}

Notas: Cuando pides cámara y micrófono juntos, la app pide ambos permisos al usuario a la vez. Si el interruptor estaba desactivado al compilar, la solicitud se rechaza sin ninguna petición: vuelve a compilar con él activado. Detén siempre las pistas cuando termines, o la luz de la cámara/micrófono seguirá encendida. El vídeo con DRM (Widevine) se permite sin ningún interruptor. La cámara y el micrófono son sensibles: decláralos en tu formulario de seguridad de los datos de Play.

IdleDetector estándar web#

new IdleDetector() — Idle Detection

Ejemplo

La API estándar Idle Detection: sabe cuándo el usuario deja de usar el teléfono (sin entrada durante un rato) o bloquea la pantalla, y cuándo vuelve, para un estado de "ausente", pausar una sesión en vivo o bloquear una caja fuerte.

Devuelve: IdleDetector.requestPermission() → 'granted' o 'denied' (necesita un toque); detector.start({threshold, signal}) → Promise (rechaza con NotAllowedError sin el permiso, TypeError por debajo de 60000 ms); luego userState ('active'/'idle'), screenState ('locked'/'unlocked') y un evento change. Requiere: nada que activar y ningún permiso de Android.

document.getElementById('away').addEventListener('click', async () => {
  if (!('IdleDetector' in window)) return;
  if ((await IdleDetector.requestPermission()) !== 'granted') return;
  const ac = new AbortController();
  const idle = new IdleDetector();
  idle.addEventListener('change', () => {
    setStatus(idle.userState === 'idle' || idle.screenState === 'locked' ? 'Away' : 'Online');
  });
  await idle.start({ threshold: 5 * 60000, signal: ac.signal });
  stopButton.onclick = () => ac.abort();
});

Reanuda la observación en el siguiente inicio sin un nuevo toque, una vez que el usuario ya ha dicho que sí:

async function watchAwayIfAllowed() {
  if (!('IdleDetector' in window)) return;
  const perm = await navigator.permissions.query({ name: 'idle-detection' });
  if (perm.state !== 'granted') return;          // 'prompt' until requestPermission() ran from a tap
  const idle = new IdleDetector();
  idle.onchange = () => {
    if (idle.screenState === 'locked') pauseLiveSession();
    else if (idle.userState === 'active') resumeLiveSession();
  };
  await idle.start({ threshold: 60000 });         // 1 minute is the smallest threshold
}
watchAwayIfAllowed();

Deja que el usuario cambie el retardo del bloqueo automático: detén el detector en marcha, inicia uno nuevo e informa de los errores reales:

let vaultWatch = null;
async function setAutoLock(minutes) {
  if (vaultWatch) vaultWatch.abort();             // aborting stops the old detector
  vaultWatch = new AbortController();
  const idle = new IdleDetector();
  idle.addEventListener('change', () => {
    if (idle.userState === 'idle' || idle.screenState === 'locked') lockVault();
  });
  try {
    await idle.start({ threshold: minutes * 60000, signal: vaultWatch.signal });
  } catch (err) {
    if (err.name === 'NotAllowedError') showMessage('Tap "Enable auto-lock" first.');
    else if (err instanceof TypeError) showMessage('Pick at least 1 minute.');
    else throw err;
  }
}

Notas: en la app, "input" es la entrada del usuario en ESTA app mientras está al frente; mientras otra app está al frente con la pantalla encendida, el usuario cuenta como activo (una app no puede ver la entrada de otras apps; Chrome en Android responde igual); cuando la pantalla está apagada o bloqueada, el tiempo de inactividad se cuenta desde el último toque. El permiso se recuerda y navigator.permissions.query({name:'idle-detection'}) lo responde. Transportes internos: __idleState, __idlePermission; los cambios de bloqueo/desbloqueo llegan como appmint:idle. Las apps creadas con IA usan watchIdle().

navigator.geolocation estándar web#

navigator.geolocation

Ejemplo

La API web estándar de ubicación funciona dentro de la app. La app muestra la solicitud de ubicación propia de Android y pasa la respuesta a la página.

Devuelve: los callbacks W3C normales: un GeolocationPosition (coords.latitude, coords.longitude, coords.accuracy, …) o un GeolocationPositionError (code 1 = permiso denegado, 2 = no disponible, 3 = tiempo agotado). Requiere: GPS (preciso) y/o Ubicación (aproximada) en el Paso 4 (Acceso). Cuando compilas desde un ZIP o una página HTML y tu código llama a navigator.geolocation, getCurrentPosition o watchPosition, la compilación encuentra esa llamada y añade la ubicación por ti. En una compilación de sitio web (URL), márcalo tú mismo.

Obtén la posición una vez.

function whereAmI() {
  if (!('geolocation' in navigator)) { showMessage('Location is not available here.'); return; }

  navigator.geolocation.getCurrentPosition(
    function (pos) {
      showMessage('You are at ' + pos.coords.latitude.toFixed(5) + ', ' +
                  pos.coords.longitude.toFixed(5) + ' (±' + Math.round(pos.coords.accuracy) + ' m)');
    },
    function (err) {
      if (err.code === 1) showMessage('Location permission was refused.');
      else if (err.code === 3) showMessage('Location timed out. Try again outside.');
      else showMessage('Location is unavailable. Is location turned on in phone settings?');
    },
    { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
  );
}

Sigue al usuario mientras se mueve (un registro de carreras, un mapa de reparto). Deja de observar cuando se cierre la pantalla.

var watchId = null;

function startTracking() {
  if (!('geolocation' in navigator)) return;
  watchId = navigator.geolocation.watchPosition(
    function (pos) { addPointToMap(pos.coords.latitude, pos.coords.longitude); },
    function (err) { showMessage('Tracking stopped: ' + err.message); },
    { enableHighAccuracy: true }
  );
}

function stopTracking() {
  if (watchId !== null) { navigator.geolocation.clearWatch(watchId); watchId = null; }
}

Comprueba primero el permiso y pide una posición solo cuando pueda responderse:

async function locateIfAllowed() {
  if (!('geolocation' in navigator)) return;
  if (navigator.permissions) {
    const s = await navigator.permissions.query({ name: 'geolocation' });
    if (s.state === 'denied') { showMessage('Location is off for this app. Turn it on in Settings.'); return; }
  }
  navigator.geolocation.getCurrentPosition(
    (pos) => centerMap(pos.coords.latitude, pos.coords.longitude),
    (err) => showMessage('Location error ' + err.code + ': ' + err.message),
    { timeout: 10000 }
  );
}

Notas: Con GPS activado, la app pide ubicación precisa y aproximada juntas; en Android 12+ el usuario puede elegir "Aproximada", que sigue funcionando (menor precisión). Con solo Ubicación activada, solo se pide ubicación aproximada. Si ninguna está activada, cada llamada falla con el código 1 y sin petición. Cuando el usuario la permite una vez, las llamadas posteriores se responden sin petición. El interruptor de ubicación del propio teléfono también debe estar activado. La ubicación son datos personales: decláralos en tu formulario de seguridad de los datos de Play; no se ofrece ubicación en segundo plano.

navigator.getInstalledRelatedApps estándar web#

navigator.getInstalledRelatedApps()

Ejemplo

La API estándar de apps relacionadas: indica a la página cuáles de sus propias apps de Android (listadas en su manifiesto de app web) están instaladas, para ocultar un banner de "Instala nuestra otra app" o enlazar directamente a ella.

Devuelve: una Promise con un array de las entradas related_applications del manifiesto que están instaladas: { platform: 'play', id, url?, version? }. Requiere: un <link rel="manifest"> cuyo manifiesto liste las apps con "platform": "play" y "id": "<package name>", incluido en la app para que la compilación pueda declararlas (Android solo deja que una app vea los paquetes que nombra).

// manifest.webmanifest: { "related_applications": [{ "platform": "play", "id": "com.example.scanner" }] }
const apps = await navigator.getInstalledRelatedApps();
const hasScanner = apps.some((a) => a.id === 'com.example.scanner');
document.getElementById('get-scanner').hidden = hasScanner;

Abre la app complementaria cuando está instalada (el url de la entrada viene de tu manifiesto); si no, lleva al usuario a su página de la tienda:

// manifest: { "platform": "play", "id": "com.example.scanner", "url": "https://example.com/scan" }
document.getElementById('open-scanner').addEventListener('click', async () => {
  const apps = typeof navigator.getInstalledRelatedApps === 'function'
    ? await navigator.getInstalledRelatedApps() : [];
  const scanner = apps.find((a) => a.id === 'com.example.scanner');
  location.href = scanner && scanner.url
    ? scanner.url
    : 'https://play.google.com/store/apps/details?id=com.example.scanner';
});

Muestra cuáles de tus apps ya están en el teléfono en una pantalla de "Más de nosotros":

async function renderMoreApps(catalog) {         // [{ id, name }] matching your manifest entries
  if (typeof navigator.getInstalledRelatedApps !== 'function') return; // e.g. inside an iframe
  const installed = new Set((await navigator.getInstalledRelatedApps()).map((a) => a.id));
  document.getElementById('more-apps').innerHTML = catalog.map((app) =>
    `<li>${app.name} — ${installed.has(app.id) ? 'Installed' : '<a href="https://play.google.com/store/apps/details?id=' + app.id + '">Get it</a>'}</li>`
  ).join('');
}

Notas: la compilación lee el manifiesto de tu paquete y declara cada paquete listado; una app que no esté listada no puede verse y nunca se informa. Las apps en modo sitio web mantienen su manifiesto en el sitio, así que no se declara nada y la lista queda vacía. Transporte interno: __relatedAppsInstalled.

navigator.permissions estándar web#

navigator.permissions.query({ name })

Ejemplo

La API estándar Permissions, respondida desde el estado real de permisos de Android del teléfono: así una página puede mostrar "Permitir cámara" solo cuando aún se puede pedir, y "Actívalo en Ajustes" cuando no.

Devuelve: una Promise de un PermissionStatus {name, state, onchange}; state es 'granted', 'denied' o 'prompt', y change se dispara justo después de que se cierre un diálogo de permiso o cuando el usuario vuelve de Ajustes. Requiere: nada que activar.

async function cameraButton() {
  const btn = document.getElementById('scan');
  const status = await navigator.permissions.query({ name: 'camera' });
  function render() {
    if (status.state === 'denied') {
      btn.textContent = 'Camera is off — enable it in Settings';
      btn.disabled = true;
    } else {
      btn.textContent = status.state === 'granted' ? 'Scan' : 'Allow camera and scan';
      btn.disabled = false;
    }
  }
  render();
  status.onchange = render;   // e.g. after the Android dialog, or back from Settings
}
cameraButton();

// The other names the app answers the same way:
for (const name of ['microphone', 'geolocation', 'notifications']) {
  navigator.permissions.query({ name }).then((s) => console.log(name, s.state));
}

Pide permiso solo cuando la página aún pueda hacerlo y, si no, envía al usuario a Ajustes:

async function ensureMic() {
  if (!(navigator.permissions && navigator.permissions.query)) return true;   // a browser without it
  const s = await navigator.permissions.query({ name: 'microphone' });
  if (s.state === 'denied') {
    showMessage('Microphone is off for this app. Turn it on in Settings.');
    return false;
  }
  return true;   // 'granted', or 'prompt' (Android asks on first use)
}

document.getElementById('record').onclick = async () => {
  if (await ensureMic()) startRecording();
};

Reacciona cuando el usuario cambia un permiso en Ajustes y combínalo con una solicitud real:

async function watchLocationSwitch() {
  const status = await navigator.permissions.query({ name: 'geolocation' });
  status.addEventListener('change', () => {
    if (status.state === 'granted') {
      navigator.geolocation.getCurrentPosition((p) => showNearby(p.coords.latitude, p.coords.longitude));
    } else {
      hideNearby();
    }
  });
}
watchLocationSwitch();

Notas: denied también cubre una capacidad con la que se compiló la app sin activarla (su interruptor estaba desactivado en la compilación, así que Android nunca puede concederla) y un rechazo con "No volver a preguntar". screen-wake-lock, clipboard-read y clipboard-write son 'granted' (la app los ofrece sin petición). Otros nombres (midi, accelerometer, …) los responde el WebView como antes. Evento detrás: appmint:permissions-changed.

TextDetector estándar web#

new TextDetector().detect(image) — text recognition (OCR)

Ejemplo

Lee el texto de una imagen en el propio teléfono (el total de un recibo, un número de serie, una tarjeta de visita) con el reconocedor ML Kit de Google. La forma es el TextDetector de la Shape Detection API, el que Chrome incluye detrás de una bandera.

Devuelve: una Promise con un array, una entrada por línea: { rawValue, boundingBox, cornerPoints } en píxeles de la imagen. Rechaza con NotSupportedError sin los servicios de Google Play y con OperationError cuando falla el reconocimiento. Requiere: nada que activar y ningún permiso (para leer desde la cámara, toma la foto con <input type="file" accept="image/*" capture> o un flujo de cámara).

document.getElementById('photo').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file || !('TextDetector' in window)) return;
  status.textContent = 'Reading…';
  try {
    const lines = await new TextDetector().detect(file);   // a Blob, <img>, <canvas>, <video> or ImageBitmap
    const text = lines.map((l) => l.rawValue).join('\n');
    const total = /total\s*[:$€£]?\s*([\d.,]+)/i.exec(text);
    status.textContent = total ? 'Total: ' + total[1] : text;
  } catch (err) {
    status.textContent = 'Could not read the text (' + err.name + ')';
  }
});

Lee un número de serie desde la vista previa en vivo de la cámara (un <video> en reproducción se pasa tal cual y se lee su fotograma actual):

const video = document.querySelector('video');
async function startPreview() {
  video.srcObject = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
  await video.play();
}

document.getElementById('read-serial').addEventListener('click', async () => {
  if (!('TextDetector' in window)) return;
  const lines = await new TextDetector().detect(video);
  const hit = lines.map((l) => l.rawValue).find((t) => /S\/?N[:\s]*[A-Z0-9-]{6,}/i.test(t));
  serialField.value = hit ? hit.replace(/^.*S\/?N[:\s]*/i, '') : '';
  if (!hit) showMessage('No serial number in view — move closer and try again.');
});

Dibuja el contorno de cada línea reconocida sobre la foto, usando boundingBox (píxeles de la imagen):

async function outlineText(img, canvas) {
  await img.decode();                             // detect() rejects InvalidStateError before it has loaded
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;
  const g = canvas.getContext('2d');
  g.drawImage(img, 0, 0);
  const lines = await new TextDetector().detect(img);
  g.strokeStyle = '#ff3b30';
  g.lineWidth = 3;
  for (const { boundingBox: b, rawValue } of lines) {
    g.strokeRect(b.x, b.y, b.width, b.height);
    console.log(rawValue);
  }
  return lines.length;
}

Oculta el botón de escaneo donde no hay reconocimiento de texto (sin servicios de Google Play) y extrae los datos de contacto de una tarjeta de visita:

const scanBtn = document.getElementById('scan-card');
scanBtn.hidden = !('TextDetector' in window);
document.getElementById('card-photo').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const text = (await new TextDetector().detect(file)).map((l) => l.rawValue).join('\n');
  fillContact({
    email: (text.match(/[\w.+-]+@[\w-]+\.[\w.]+/) || [''])[0],
    phone: (text.match(/\+?\d[\d\s().-]{7,}\d/) || [''])[0],
  });
});

Notas: Solo escrituras latinas (inglés, español, francés, alemán, indonesio, vietnamita, turco y otras); no se reconocen chino, devanagari, japonés, coreano ni árabe. El modelo viene de los servicios de Google Play: la compilación lo pide en la instalación cuando tu código lo usa; si no, la primera llamada en un teléfono nuevo espera unos segundos mientras se descarga. Las imágenes de otro sitio deben permitir CORS (un canvas contaminado rechaza SecurityError). Transportes internos: __ocrAvailable, __ocrRecognize (respondido como appmint:ocr). Las apps creadas con IA usan recognizeText().

__idlePermission puente#

window.WebToApk.__idlePermission(request: Boolean): String

IdleDetector.requestPermission() ([request] true, llamado con un gesto del usuario) registra la concesión; false solo la lee. "granted" | "prompt".

Ejemplo

Transporte interno detrás de IdleDetector.requestPermission() y el estado del permiso 'idle-detection'. Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin aviso.

Devuelve: 'granted' o 'prompt'. Requiere: un gesto del usuario para la solicitud.

Usa la API pública:

button.onclick = async () => {
  const state = await IdleDetector.requestPermission();
  console.log(state); // 'granted'
};

Detecta si la función existe y trata un rechazo como "no permitido":

async function askIdlePermission() {
  if (!('IdleDetector' in window)) return false;
  try {
    return (await IdleDetector.requestPermission()) === 'granted';
  } catch (err) {
    return false;
  }
}

Pídelo desde un toque y luego empieza a detectar:

startBtn.addEventListener('click', async () => {
  if (!(await askIdlePermission())) { showMessage('Idle detection was not allowed.'); return; }
  const idle = new IdleDetector();
  idle.addEventListener('change', () => console.log(idle.userState));
  await idle.start({ threshold: 60000 });
});

Notas: Implementado en el shell de la app (IdleAndRelatedApps.kt).

__idleState puente#

window.WebToApk.__idleState(): String

IdleDetector: `{idleMs, locked}` a partir de la propia entrada de la app y del bloqueo de pantalla.

Ejemplo

Transporte interno detrás de IdleDetector: cuánto tiempo ha pasado desde la última entrada del usuario y si la pantalla está bloqueada. Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin aviso.

Devuelve: JSON {idleMs, locked}. Los cambios de pantalla y de app se envían como el evento appmint:idle con la misma forma. Requiere: nada.

Usa la API pública:

const idle = new IdleDetector();
idle.onchange = () => console.log(idle.userState, idle.screenState);
await idle.start({ threshold: 60000 });

Deja de observar con un AbortController cuando se cierre la pantalla:

const ctl = new AbortController();
const idle = new IdleDetector();
idle.addEventListener('change', () => {
  if (idle.userState === 'idle') showAwayBanner();
});
await idle.start({ threshold: 60000, signal: ctl.signal });
// later: ctl.abort();

Bloquea la app cuando se bloquee la pantalla:

if ('IdleDetector' in window && (await IdleDetector.requestPermission()) === 'granted') {
  const d = new IdleDetector();
  d.onchange = () => { if (d.screenState === 'locked') showPinScreen(); };
  await d.start({ threshold: 60000 });
}

Notas: Implementado en el shell de la app (IdleAndRelatedApps.kt).

__permissionState puente#

window.WebToApk.__permissionState(name: String): String

Estado de la API Permissions de un nombre de permiso W3C, a partir de las concesiones reales de Android: "granted" | "denied" | "prompt", o "" cuando el shell no responde a ese nombre (la consulta de la página pasa entonces a la implementación propia del WebView).

Ejemplo

Transporte interno detrás de navigator.permissions.query({ name }). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: 'granted', 'denied', 'prompt', o '' para un nombre que la app no responde (la consulta pasa entonces al propio WebView). Requiere: nada que activar.

Usa la API pública:

const status = await navigator.permissions.query({ name: 'camera' });
showCameraHint(status.state);                       // 'granted' | 'denied' | 'prompt'
status.addEventListener('change', () => showCameraHint(status.state));

Comprueba varios permisos a la vez:

const names = ['camera', 'microphone', 'geolocation', 'notifications'];
const states = await Promise.all(names.map((name) =>
  navigator.permissions.query({ name }).then((s) => s.state, () => 'unknown')));
names.forEach((name, i) => console.log(name, states[i]));

Pide permiso solo cuando el estado no sea denied, e indica qué hacer cuando lo es:

async function ensureLocation() {
  const s = await navigator.permissions.query({ name: 'geolocation' });
  if (s.state === 'denied') { showMessage('Turn location on in the phone settings.'); return null; }
  return new Promise((ok, fail) => navigator.geolocation.getCurrentPosition(ok, fail));
}

Notas: Responde camera, microphone, geolocation, notifications, screen-wake-lock, clipboard-read, clipboard-write. Una capacidad que la compilación dejó desactivada (o cuyo permiso no está en el manifiesto) es denied. Los estados en vivo se vuelven a leer con appmint:permissions-changed (disparado tras cada diálogo de permiso) y cuando la página vuelve a ser visible.

__relatedAppsInstalled puente#

window.WebToApk.__relatedAppsInstalled(idsJson: String): String

navigator.getInstalledRelatedApps(): cuáles de [idsJson] (nombres de paquete del manifiesto de la página) están instalados y visibles para esta app.

Ejemplo

Transporte interno detrás de navigator.getInstalledRelatedApps(). Las páginas usan la API estándar; nunca llames a esto directamente: los métodos __ pueden cambiar sin previo aviso.

Devuelve: un array JSON con los nombres de paquete dados que están instalados y visibles para esta app. Requiere: los paquetes declarados al compilar (del manifiesto de app web incluido).

Usa la API pública:

const apps = await navigator.getInstalledRelatedApps();
console.log(apps.map((a) => a.id));

Muestra el botón "Obtén la app complementaria" solo cuando no está instalada:

async function showCompanionLink() {
  if (!navigator.getInstalledRelatedApps) return;
  const apps = await navigator.getInstalledRelatedApps();
  document.getElementById('get-companion').hidden = apps.length > 0;
}

Los paquetes vienen del manifiesto de la app web que enlaza la página:

<link rel="manifest" href="/manifest.webmanifest">
<!-- manifest.webmanifest: "related_applications": [{ "platform": "play", "id": "com.example.companion" }] -->

Notas: Implementado en el shell de la app (IdleAndRelatedApps.kt).

getAppName puente#

window.WebToApk.getAppName(): String

El nombre de la app que el creador eligió en el asistente: lo que muestra el launcher.

Ejemplo

Da el nombre de app que elegiste en el asistente de compilación: el nombre bajo el icono del launcher.

Devuelve: una cadena al instante. Requiere: nada.

Usa el nombre real de la app en un título y en un texto para compartir.

var appName = (window.WebToApk && window.WebToApk.getAppName)
  ? WebToApk.getAppName()
  : document.title;                       // in a browser: the page title

document.getElementById('welcome').textContent = 'Welcome to ' + appName;
document.getElementById('shareBtn').addEventListener('click', function () {
  if (navigator.share) navigator.share({ text: 'Try ' + appName + '!' });
});

Pon el nombre de la app en el título de la página y en un encabezado:

if (window.WebToApk && WebToApk.getAppName) {
  const name = WebToApk.getAppName();
  document.title = name;
  document.querySelector('h1').textContent = name;
}

Crea un correo de soporte que nombre la app y su versión:

function supportMailto() {
  const b = window.WebToApk;
  const name = b && b.getAppName ? b.getAppName() : 'our app';
  const version = b && b.getAppVersion ? b.getAppVersion() : 'web';
  const subject = encodeURIComponent('Help with ' + name + ' ' + version);
  return 'mailto:[email protected]?subject=' + subject;
}
document.getElementById('contact').href = supportMailto();

Notas: Este es el nombre del asistente, no el <title> de la página. No cambia en tiempo de ejecución. Para la versión, usa getAppVersion().

getAppVersion puente#

window.WebToApk.getAppVersion(): String

El versionName del paquete instalado, p. ej. "1.4.2": la cadena que el creador define al compilar, no el versionCode. Si no se puede leer el paquete, vuelve a "1.0", así que una página puede imprimirlo sin comprobar si es nulo.

Ejemplo

Da el nombre de versión de la app instalada, por ejemplo "1.4.2".

Devuelve: una cadena al instante. Requiere: nada.

Muestra la versión en una pantalla Acerca de.

var version = (window.WebToApk && window.WebToApk.getAppVersion)
  ? WebToApk.getAppVersion()
  : 'web';

document.getElementById('about').textContent = 'Version ' + version;

Pide una actualización cuando la versión instalada sea antigua:

function isOlder(a, b) {            // "1.4.2" < "1.10.0"
  const x = a.split('.').map(Number), y = b.split('.').map(Number);
  for (let i = 0; i < Math.max(x.length, y.length); i++) {
    if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0);
  }
  return false;
}

if (window.WebToApk && WebToApk.getAppVersion && isOlder(WebToApk.getAppVersion(), '1.5.0')) {
  document.getElementById('update-banner').hidden = false;
}

Añade la versión y el código de versión de getDeviceInfo() a un informe de error:

function reportInfo() {
  const b = window.WebToApk;
  if (!(b && b.getAppVersion)) return 'web';
  let text = 'v' + b.getAppVersion();
  if (b.getDeviceInfo) {
    const info = JSON.parse(b.getDeviceInfo());
    text += ' (build ' + info.app.versionCode + ')';
  }
  return text;
}
document.getElementById('report').value = reportInfo();

Notas: Esta es la versión NOMBRE que defines al compilar, no el código de versión. Si no se puede leer el paquete, devuelve "1.0", así que nunca está vacío. El código de versión y las fechas de instalación están en getDeviceInfo() bajo app.

getDeviceInfo puente#

window.WebToApk.getDeviceInfo(): String

WebToApk.getDeviceInfo() -> cadena JSON, NO requiere permiso WebToApk.getPhoneIdentity() -> operador/SIM, necesita el permiso de Teléfono WebToApk.getInstallId() -> UUID estable por instalación

Se describe en su grupo, Información del dispositivo / de la app, y no por separado.

Ejemplo

Da datos del teléfono, de Android, de la pantalla, de tu app y del estado actual (batería, memoria, red) en una sola llamada.

Devuelve: una cadena JSON al instante; usa JSON.parse. Claves de nivel superior: android, hardware, screen, app, runtime, installId. Requiere: nada, ningún permiso.

Muestra la información del teléfono.

function showPhoneInfo() {
  if (!(window.WebToApk && window.WebToApk.getDeviceInfo)) {
    document.getElementById('out').textContent = navigator.userAgent;   // browser: only the user agent
    return;
  }
  var info = JSON.parse(WebToApk.getDeviceInfo());

  document.getElementById('out').innerHTML =
    'Android: ' + info.android.release + ' (SDK ' + info.android.sdkInt + ')<br>' +
    'Version ID: ' + info.android.buildId + '<br>' +
    'Build number: ' + info.android.incremental + '<br>' +
    'Phone: ' + info.hardware.manufacturer + ' ' + info.hardware.model + '<br>' +
    'Screen: ' + info.screen.widthPx + 'x' + info.screen.heightPx + ' @' + info.screen.refreshRate + 'Hz<br>' +
    'Battery: ' + info.runtime.batteryLevel + '%' + (info.runtime.charging ? ' (charging)' : '') + '<br>' +
    'Network: ' + info.runtime.networkType + '<br>' +
    'App: ' + info.app.versionName + ' (' + info.app.versionCode + ')<br>' +
    'WebView: ' + info.app.webViewPackage;
}

Todas las claves:

{
  "android":  { "release": "14", "sdkInt": 34, "codename": "REL", "buildId": "UP1A.231005.007", "incremental": "R.1a2b3c", "display": "…", "fingerprint": "…", "securityPatch": "2026-08-05" },
  "hardware": { "manufacturer": "OPPO", "brand": "OPPO", "model": "CPH2269", "device": "…", "product": "…", "board": "…", "hardware": "…", "abis": ["arm64-v8a"], "socManufacturer": "…", "socModel": "…", "isEmulator": false },
  "screen":   { "widthPx": 720, "heightPx": 1600, "density": 2, "densityDpi": 320, "refreshRate": 90, "nightMode": true },
  "app":      { "packageName": "com.example.app", "versionName": "1.0", "versionCode": 1, "targetSdk": 35, "firstInstallTime": 1737000000000, "lastUpdateTime": 1737000000000, "installer": "com.android.vending", "webViewPackage": "com.google.android.webview 131.0" },
  "runtime":  { "totalMemoryBytes": 4000000000, "availableMemoryBytes": 1500000000, "lowMemory": false, "storageFreeBytes": 20000000000, "storageTotalBytes": 64000000000, "batteryLevel": 80, "charging": false, "locale": "en-IN", "timezone": "Asia/Kolkata", "networkType": "wifi" },
  "installId": "3f2b8c1e-…"
}

Avisa en un teléfono con poco almacenamiento o poca memoria antes de una descarga grande.

function canDownload(bytesNeeded) {
  if (!(window.WebToApk && WebToApk.getDeviceInfo)) return true;   // browser: let the download try
  const r = JSON.parse(WebToApk.getDeviceInfo()).runtime;
  if (r.storageFreeBytes < bytesNeeded * 2) { showMessage('Not enough free storage.'); return false; }
  if (r.lowMemory) showMessage('The phone is low on memory; this may be slow.');
  return true;
}

Notas: socManufacturer / socModel existen solo en Android 12+. networkType es wifi, cellular, ethernet, other, none o unknown. installer es com.android.vending para instalaciones de Play y está vacío para instalaciones manuales. Los valores se leen en el momento de la llamada: llama de nuevo para actualizar la batería o la red. No hay IMEI ni número de serie: Android los bloquea para todas las apps; usa getInstallId().

getInstallId puente#

window.WebToApk.getInstallId(): String

WebToApk.getDeviceInfo() -> cadena JSON, NO requiere permiso WebToApk.getPhoneIdentity() -> operador/SIM, necesita el permiso de Teléfono WebToApk.getInstallId() -> UUID estable por instalación

Se describe en su grupo, Información del dispositivo / de la app, y no por separado.

Ejemplo

Da un ID aleatorio que se mantiene igual para esta instalación de tu app.

Devuelve: una cadena (un UUID como "3f2b…-…") al instante. Requiere: nada.

Úsalo como clave de dispositivo para tu servidor o para una comprobación de "una prueba gratuita por dispositivo".

function deviceKey() {
  if (window.WebToApk && window.WebToApk.getInstallId) {
    return WebToApk.getInstallId();
  }
  // In a browser: keep your own random ID in localStorage (per browser, cleared with site data).
  var k = localStorage.getItem('installId');
  if (!k) {
    k = 'w-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
    localStorage.setItem('installId', k);
  }
  return k;
}

fetch('https://api.example.com/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ device: deviceKey() })
});

Envía el id a tu servidor en una cabecera y mantenlo fuera de la URL.

async function apiGet(path) {
  const id = (window.WebToApk && WebToApk.getInstallId) ? WebToApk.getInstallId() : null;
  const res = await fetch('https://api.example.com' + path, {
    headers: id ? { 'X-Install-Id': id } : {}
  });
  return res.json();
}

Adjúntalo a los informes de fallos para poder encontrar los problemas de una instalación concreta.

if (window.WebToApk && WebToApk.setCrashUserId && WebToApk.getInstallId) {
  // returns false when Crash reports are off or the id has other characters
  const ok = WebToApk.setCrashUserId(WebToApk.getInstallId());
  if (!ok) console.log('crash user id not set');
}

Notas: El ID se crea en el primer uso y se guarda en el almacenamiento de la app. Sigue igual tras las actualizaciones de la app. Cambia cuando el usuario desinstala la app o borra sus datos. No es un ID de hardware: Android no da IMEI ni número de serie a las apps. El mismo valor también está en getDeviceInfo() como installId.

getPhoneIdentity puente#

window.WebToApk.getPhoneIdentity(): String

WebToApk.getDeviceInfo() -> cadena JSON, NO requiere permiso WebToApk.getPhoneIdentity() -> operador/SIM, necesita el permiso de Teléfono WebToApk.getInstallId() -> UUID estable por instalación

Se describe en su grupo, Información del dispositivo / de la app, y no por separado.

Ejemplo

Da los datos del operador móvil y de la SIM: nombre del operador, país de la SIM, código de operador, tipo de teléfono y estado de la SIM.

Devuelve: una cadena JSON al instante; usa JSON.parse: { carrierName, simCountryIso, networkCountryIso, simOperator, phoneType, simState, hardwareIdAvailable: false }, o { error }. Requiere: activar Teléfono en el Paso 4 (Acceso) al compilar; si no, devuelve { "error": "not_enabled" }.

Muestra el operador y elige el prefijo del país.

function showCarrier() {
  if (!(window.WebToApk && window.WebToApk.getPhoneIdentity)) return;
  var p = JSON.parse(WebToApk.getPhoneIdentity());

  if (p.error === 'not_enabled') { showMessage('Turn on Phone when you build the app'); return; }
  if (p.error) { showMessage('Could not read SIM info: ' + p.error); return; }

  if (p.simState !== 'ready') {
    showMessage('No SIM ready (' + p.simState + ')');   // absent | pin_required | network_locked | unknown
    return;
  }
  document.getElementById('carrier').textContent = p.carrierName;                 // e.g. "Jio 4G"
  document.getElementById('country').value = p.simCountryIso.toUpperCase();        // e.g. "IN"
}

Elige un prefijo telefónico según el país de la SIM para un formulario de registro.

const DIAL = { IN: '+91', US: '+1', GB: '+44', DE: '+49' };
function defaultDialCode() {
  if (!(window.WebToApk && WebToApk.getPhoneIdentity)) return '+1';
  const p = JSON.parse(WebToApk.getPhoneIdentity());
  if (p.error || p.simState !== 'ready') return '+1';
  return DIAL[p.simCountryIso.toUpperCase()] || '+1';
}
document.getElementById('dial').value = defaultDialCode();

Distingue una tableta solo con Wi-Fi de un teléfono con phoneType.

if (window.WebToApk && WebToApk.getPhoneIdentity) {
  const p = JSON.parse(WebToApk.getPhoneIdentity());
  if (!p.error && p.phoneType === 'none') {
    document.getElementById('sms-login').hidden = true;   // no cellular radio: hide SMS sign-in
  }
}

Notas: phoneType es gsm, cdma, sip o none (tabletas solo con Wi-Fi). simOperator es el código MCC+MNC (p. ej. "40445"). No hay IMEI, MEID ni número de serie: hardwareIdAvailable siempre es false, porque Android los oculta a todas las apps desde Android 10; usa getInstallId() para un id estable. El interruptor de Teléfono añade un permiso sensible a tu ficha de Play; declara para qué lo usas en el formulario de seguridad de los datos.

isTv puente#

window.WebToApk.isTv(): Boolean

Verdadero cuando la app se ejecuta en una televisión (Android TV, Google TV, Fire TV).

Ejemplo

Indica a la página si la app se ejecuta en una televisión (Android TV, Google TV, Fire TV), para que pueda mostrar un diseño adecuado para el mando a distancia.

Devuelve: true o false (de forma síncrona). Requiere: nada. Con Compatibilidad con Android TV activada en el asistente, la app también obtiene un banner de TV en el launcher de TV, y en una TV la página obtiene un anillo de foco claro, y los enlaces, botones, campos y elementos conectados con onclick o un rol de botón se pueden alcanzar con las flechas del mando; OK hace clic en el elemento enfocado y Atrás funciona como en un teléfono.

var onTv = !!(window.WebToApk && typeof window.WebToApk.isTv === 'function' && window.WebToApk.isTv());
if (onTv) {
  document.documentElement.classList.add('tv');     /* bigger text, no hover-only menus */
  document.querySelector('#play').focus();           /* start the remote on the main action */
}
html.tv body { font-size: 22px; }
html.tv :focus-visible { transform: scale(1.05); }

Elige una pantalla de inicio distinta para TV y mantén el diseño del teléfono como predeterminado.

function isTv() {
  return !!(window.WebToApk && typeof WebToApk.isTv === 'function' && WebToApk.isTv());
}
history.replaceState({}, '', isTv() ? '/tv-home' : '/home');
if (isTv()) document.querySelectorAll('.touch-only').forEach(function (el) { el.hidden = true; });

Notas: Prueba con el emulador de Android TV y su D-pad. Publicar en TV en Google Play es un paso aparte: en Play Console, activa en la app el factor de forma de Android TV (Lanzamiento → Configuración avanzada → Factores de forma) y añade una captura de pantalla de TV.

Generado a partir del runtime de la app y sus archivos de ejemplo en cada compilación de la documentación. Léelo como Markdown · Todas las familias.

Comprobado con el puente publicado el 2026-10-22.