Создать APK бесплатно

JavaScript bridge API

Информация об устройстве и приложении

Что это за телефон, какая сборка, стабильный идентификатор установки и стандартные API геолокации и камеры.

getUserMedia веб-стандарт#

navigator.mediaDevices.getUserMedia — camera and microphone

Пример

Стандартный веб-API камеры и микрофона работает внутри приложения. Приложение показывает собственный запрос разрешения Android и передаёт поток странице.

Возвращает: Promise с MediaStream или отказ (NotAllowedError, когда пользователь отказал или при сборке переключатель был выключен, NotFoundError, когда такого устройства нет). Требуется: Камера и/или Микрофон на шаге 4 (Доступ). Если вы собираете из ZIP или HTML-страницы и ваш код вызывает getUserMedia({ video: … }) или { audio: … } (или использует <input capture>, BarcodeDetector, SpeechRecognition), сборка находит вызов и сама добавляет нужное разрешение; { video: false } не добавляет «Камеру». Для сборки сайта (URL) отметьте их сами.

Покажите заднюю камеру в элементе video.

<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>

Запишите голосовую заметку, используя только микрофон.

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);
}

Сделайте фото из потока камеры и освободите камеру.

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);
}

Примечания: Когда вы запрашиваете камеру и микрофон вместе, приложение запрашивает у пользователя оба разрешения сразу. Если при сборке переключатель был выключен, запрос отклоняется вообще без диалога — соберите заново с включённым переключателем. Всегда останавливайте треки, когда закончили, иначе индикатор камеры/микрофона останется включённым. DRM-видео (Widevine) разрешено без какого-либо переключателя. Камера и микрофон — чувствительные разрешения: укажите их в форме Data safety в Play.

IdleDetector веб-стандарт#

new IdleDetector() — Idle Detection

Пример

Стандартный Idle Detection API: узнайте, когда пользователь перестал пользоваться телефоном (долго нет ввода) или заблокировал экран и когда он вернулся — для статуса «отошёл», приостановки живого сеанса или блокировки хранилища.

Возвращает: IdleDetector.requestPermission() → 'granted' или 'denied' (нужно нажатие); detector.start({threshold, signal}) → Promise (отклоняется с NotAllowedError без разрешения, TypeError при значении меньше 60000 мс); затем userState ('active'/'idle'), screenState ('locked'/'unlocked') и событие change. Требуется: ничего включать не нужно и разрешения 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();
});

Возобновите слежение при следующем запуске без нового нажатия, если пользователь уже согласился:

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();

Позвольте пользователю менять задержку автоблокировки: остановите работающий детектор, запустите новый и сообщайте о настоящих ошибках:

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;
  }
}

Примечания: в приложении «ввод» — это ввод пользователя в ЭТО приложение, пока оно на переднем плане; когда на переднем плане другое приложение при включённом экране, пользователь считается активным (приложение не видит ввод в других приложениях — Chrome на Android отвечает так же); когда экран выключен или заблокирован, время простоя отсчитывается от последнего касания. Разрешение запоминается, и ответ на него даёт navigator.permissions.query({name:'idle-detection'}). Внутренние каналы: __idleState, __idlePermission; изменения блокировки/разблокировки приходят как appmint:idle. Приложения, созданные ИИ, используют watchIdle().

navigator.geolocation веб-стандарт#

navigator.geolocation

Пример

Стандартный веб-API геолокации работает внутри приложения. Приложение показывает собственный запрос местоположения Android и передаёт ответ странице.

Возвращает: обычные обратные вызовы W3C: GeolocationPosition (coords.latitude, coords.longitude, coords.accuracy, …) или GeolocationPositionError (code 1 = разрешение отклонено, 2 = недоступно, 3 = время вышло). Требуется: GPS (точное) и/или Местоположение (приблизительное) на шаге 4 (Доступ). Если вы собираете из ZIP или HTML-страницы и ваш код вызывает navigator.geolocation, getCurrentPosition или watchPosition, сборка находит этот вызов и сама добавляет местоположение. Для сборки сайта (URL) отметьте его сами.

Получите положение один раз.

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 }
  );
}

Следите за пользователем, пока он перемещается (трекер бега, карта доставки). Прекращайте слежение, когда экран закрывается.

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; }
}

Сначала проверьте разрешение, а затем запрашивайте положение, только когда на запрос можно получить ответ:

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 }
  );
}

Примечания: Если включён GPS, приложение запрашивает точное и приблизительное местоположение вместе; в Android 12+ пользователь может выбрать «Приблизительное», и это тоже работает (с меньшей точностью). Если включено только Местоположение, запрашивается только приблизительное. Если не включено ничего, любой вызов завершается с кодом 1 и без запроса. Когда пользователь один раз разрешил, последующие вызовы обрабатываются без запроса. Должен быть включён и собственный переключатель геолокации телефона. Местоположение — это личные данные: укажите его в форме Data safety в Play; фоновое определение местоположения не предоставляется.

navigator.getInstalledRelatedApps веб-стандарт#

navigator.getInstalledRelatedApps()

Пример

Стандартный API связанных приложений: сообщает странице, какие из её собственных приложений для Android (перечисленных в манифесте веб-приложения) установлены — чтобы скрыть баннер «Установите наше другое приложение» или сделать глубокую ссылку в него.

Возвращает: Promise с массивом записей related_applications из манифеста, которые установлены: { platform: 'play', id, url?, version? }. Требуется: <link rel="manifest">, чьё манифест-описание перечисляет приложения с "platform": "play" и "id": "<package name>", входящий в состав приложения, чтобы сборка могла их объявить (Android позволяет приложению видеть только те пакеты, которые оно назвало).

// 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;

Откройте приложение-компаньон, если оно установлено (url записи берётся из вашего манифеста), а иначе отправьте пользователя на его страницу в магазине:

// 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';
});

Покажите на экране «Ещё от нас», какие из ваших приложений уже есть на телефоне:

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('');
}

Примечания: сборка читает манифест в вашем пакете и объявляет каждый перечисленный пакет; приложение, которого там нет, увидеть нельзя, и о нём никогда не сообщается. У приложений в режиме сайта манифест остаётся на сайте, поэтому ничего не объявляется и список пуст. Внутренний канал: __relatedAppsInstalled.

navigator.permissions веб-стандарт#

navigator.permissions.query({ name })

Пример

Стандартный Permissions API, ответы которого берутся из настоящего состояния разрешений Android на телефоне — поэтому страница может показывать «Разрешить камеру» только когда о ней ещё можно спросить, и «Включите в Настройках», когда нельзя.

Возвращает: Promise с PermissionStatus {name, state, onchange}; state — это 'granted', 'denied' или 'prompt', а change срабатывает сразу после закрытия диалога разрешения или когда пользователь возвращается из Настроек. Требуется: ничего включать не нужно.

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));
}

Спрашивайте, только когда страница ещё может спросить, а иначе отправляйте пользователя в Настройки:

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();
};

Реагируйте, когда пользователь меняет разрешение в Настройках, и сочетайте это с настоящим запросом:

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();

Примечания: denied также охватывает возможность, с которой приложение было собрано без неё (при сборке её переключатель был выключен, поэтому Android никогда не сможет её дать), и отказ с «Больше не спрашивать». screen-wake-lock, clipboard-read и clipboard-write равны 'granted' (приложение предоставляет их без запроса). Остальные имена (midi, accelerometer, …) обрабатываются WebView как раньше. Событие за этим: appmint:permissions-changed.

TextDetector веб-стандарт#

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

Пример

Читает текст на картинке прямо на телефоне — итог чека, серийный номер, визитку — с помощью распознавателя Google ML Kit. Формат — это TextDetector из Shape Detection API, который Chrome поставляет за флагом.

Возвращает: Promise с массивом, по записи на строку: { rawValue, boundingBox, cornerPoints } в пикселях изображения. Отклоняется с NotSupportedError без сервисов Google Play и с OperationError, когда распознавание не удалось. Требуется: ничего включать не нужно и разрешение не требуется (чтобы читать с камеры, сделайте фото через <input type="file" accept="image/*" capture> или поток камеры).

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 + ')';
  }
});

Считайте серийный номер с живого превью камеры (проигрываемый <video> передаётся как есть, читается его текущий кадр):

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.');
});

Обведите поверх фото каждую распознанную строку, используя boundingBox (пиксели изображения):

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;
}

Скройте кнопку сканирования там, где нет распознавания текста (нет сервисов Google Play), и извлеките контактные данные с визитки:

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],
  });
});

Примечания: Только латинские алфавиты (английский, испанский, французский, немецкий, индонезийский, вьетнамский, турецкий и другие); китайский, деванагари, японский, корейский и арабский не распознаются. Модель поставляется из сервисов Google Play: сборка запрашивает её при установке, если ваш код её использует, а иначе первый вызов на свежем телефоне ждёт несколько секунд, пока она загружается. Изображения с другого сайта должны разрешать CORS (испорченный canvas отклоняет с SecurityError). Внутренние каналы: __ocrAvailable, __ocrRecognize (отвечает как appmint:ocr). Приложения, созданные ИИ, используют recognizeText().

__idlePermission мост#

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

IdleDetector.requestPermission() ([request] true, вызывается по действию пользователя) фиксирует разрешение; false только читает его. "granted" | "prompt".

Пример

Внутренний канал за IdleDetector.requestPermission() и состоянием разрешения 'idle-detection'. Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: 'granted' или 'prompt'. Требуется: действие пользователя для запроса.

Используйте публичный API:

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

Проверяйте поддержку возможности и считайте отказ запретом:

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

Запросите по нажатию, а затем начинайте обнаружение:

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 });
});

Примечания: Реализовано в оболочке приложения (IdleAndRelatedApps.kt).

__idleState мост#

window.WebToApk.__idleState(): String

IdleDetector: `{idleMs, locked}` по собственному вводу приложения и блокировке экрана.

Пример

Внутренний канал за IdleDetector: как давно пользователь вводил что-либо в последний раз и заблокирован ли экран. Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {idleMs, locked}. Изменения экрана и приложения приходят в событии appmint:idle с тем же форматом. Требуется: ничего.

Используйте публичный API:

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

Прекращайте слежение через AbortController, когда экран закрывается:

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();

Блокируйте приложение, когда экран блокируется:

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

Примечания: Реализовано в оболочке приложения (IdleAndRelatedApps.kt).

__permissionState мост#

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

Состояние в Permissions API для имени разрешения W3C по настоящим разрешениям Android: "granted" | "denied" | "prompt" или "", когда оболочка не отвечает на это имя (тогда запрос страницы идёт в собственную реализацию WebView).

Пример

Внутренний транспорт для navigator.permissions.query({ name }). Страницы используют стандартный API; никогда не вызывайте это напрямую — методы __ могут измениться без предупреждения.

Возвращает: 'granted', 'denied', 'prompt' или '' для имени, на которое приложение не отвечает (тогда запрос уходит в собственный обработчик WebView). Требуется: ничего включать не нужно.

Используйте публичный API:

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

Проверьте несколько разрешений сразу:

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]));

Спрашивайте, только когда состояние не denied, а когда оно такое, подскажите, что делать:

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));
}

Примечания: Отвечает на camera, microphone, geolocation, notifications, screen-wake-lock, clipboard-read, clipboard-write. Возможность, которую сборка оставила выключенной (или чьего разрешения нет в манифесте), — denied. Текущие состояния перечитываются по appmint:permissions-changed (срабатывает после каждого диалога разрешения) и когда страница снова становится видимой.

__relatedAppsInstalled мост#

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

navigator.getInstalledRelatedApps(): какие из [idsJson] (имён пакетов из манифеста страницы) установлены и видны этому приложению.

Пример

Внутренний транспорт для navigator.getInstalledRelatedApps(). Страницы используют стандартный API; никогда не вызывайте это напрямую — методы __ могут измениться без предупреждения.

Возвращает: массив JSON из заданных имён пакетов, которые установлены и видны этому приложению. Требуется: пакеты, объявленные при сборке (из входящего в состав манифеста веб-приложения).

Используйте публичный API:

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

Показывайте кнопку «Скачать приложение-компаньон», только если оно не установлено:

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

Пакеты берутся из манифеста веб-приложения, на который ссылается страница:

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

Примечания: Реализовано в оболочке приложения (IdleAndRelatedApps.kt).

getAppName мост#

window.WebToApk.getAppName(): String

Название приложения, которое создатель выбрал в мастере, — то, что показывает лаунчер.

Пример

Выдаёт название приложения, которое вы выбрали в мастере сборки, — название под значком в лаунчере.

Возвращает: строку сразу. Требуется: ничего.

Используйте настоящее название приложения в заголовке и в тексте для отправки.

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 + '!' });
});

Поставьте название приложения в заголовок страницы и в заголовок на экране:

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

Составьте письмо в поддержку, где указаны приложение и его версия:

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();

Примечания: Это название из мастера, а не <title> страницы. Оно не меняется во время работы. Для версии используйте getAppVersion().

getAppVersion мост#

window.WebToApk.getAppVersion(): String

versionName установленного пакета, например «1.4.2» — строка, которую создатель задаёт при сборке, а не versionCode. Если пакет прочитать не удаётся, возвращается «1.0», так что страница может вывести её без проверки на null.

Пример

Выдаёт имя версии установленного приложения, например "1.4.2".

Возвращает: строку сразу. Требуется: ничего.

Покажите версию на экране «О приложении».

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

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

Попросите обновиться, когда установленная версия устарела:

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;
}

Добавьте в отчёт об ошибке версию и код версии из getDeviceInfo():

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();

Примечания: Это ИМЯ версии, заданное при сборке, а не код версии. Если пакет прочитать не удаётся, возвращается "1.0", так что результат никогда не пуст. Код версии и даты установки находятся в getDeviceInfo() под app.

getDeviceInfo мост#

window.WebToApk.getDeviceInfo(): String

WebToApk.getDeviceInfo() -> строка JSON, разрешение НЕ требуется WebToApk.getPhoneIdentity() -> оператор/SIM, нужно разрешение Phone WebToApk.getInstallId() -> стабильный UUID установки

Описывается в своей группе, Информация об устройстве / приложении, а не отдельно.

Пример

Выдаёт за один вызов сведения о телефоне, Android, экране, вашем приложении и текущем состоянии (батарея, память, сеть).

Возвращает: JSON-строку сразу — используйте JSON.parse. Ключи верхнего уровня: android, hardware, screen, app, runtime, installId. Требуется: ничего — разрешение не нужно.

Покажите информацию о телефоне.

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;
}

Все ключи:

{
  "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-…"
}

Предупредите, если на телефоне мало места или памяти, перед большой загрузкой.

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;
}

Примечания: socManufacturer / socModel есть только в Android 12+. networkType — это wifi, cellular, ethernet, other, none или unknown. installer равно com.android.vending для установок из Play и пусто для установок вручную. Значения читаются в момент вызова — вызовите снова, чтобы обновить батарею или сеть. IMEI и серийного номера нет: Android блокирует их для всех приложений; используйте getInstallId().

getInstallId мост#

window.WebToApk.getInstallId(): String

WebToApk.getDeviceInfo() -> строка JSON, разрешение НЕ требуется WebToApk.getPhoneIdentity() -> оператор/SIM, нужно разрешение Phone WebToApk.getInstallId() -> стабильный UUID установки

Описывается в своей группе, Информация об устройстве / приложении, а не отдельно.

Пример

Выдаёт случайный идентификатор, который остаётся неизменным для этой установки вашего приложения.

Возвращает: строку (UUID, например "3f2b…-…") сразу. Требуется: ничего.

Используйте его как ключ устройства для вашего сервера или для проверки «одна бесплатная пробная версия на устройство».

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() })
});

Отправляйте идентификатор на ваш сервер в заголовке и не держите его в 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();
}

Прикрепляйте его к отчётам о сбоях, чтобы можно было найти проблемы конкретной установки.

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');
}

Примечания: Идентификатор создаётся при первом использовании и хранится в хранилище приложения. После обновлений приложения он не меняется. Он меняется, если пользователь удалит приложение или очистит его данные. Это не аппаратный идентификатор — Android не выдаёт приложениям ни IMEI, ни серийный номер. То же значение есть и в getDeviceInfo() как installId.

getPhoneIdentity мост#

window.WebToApk.getPhoneIdentity(): String

WebToApk.getDeviceInfo() -> строка JSON, разрешение НЕ требуется WebToApk.getPhoneIdentity() -> оператор/SIM, нужно разрешение Phone WebToApk.getInstallId() -> стабильный UUID установки

Описывается в своей группе, Информация об устройстве / приложении, а не отдельно.

Пример

Выдаёт данные о мобильном операторе и SIM-карте: название оператора, страна SIM, код оператора, тип телефона и состояние SIM.

Возвращает: JSON-строку сразу — используйте JSON.parse: { carrierName, simCountryIso, networkCountryIso, simOperator, phoneType, simState, hardwareIdAvailable: false } или { error }. Требуется: при сборке включите Телефон на шаге 4 (Доступ); иначе возвращается { "error": "not_enabled" }.

Покажите оператора связи и выберите код страны.

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"
}

Выберите телефонный код по стране SIM-карты для формы регистрации.

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();

Отличите планшет только с Wi-Fi от телефона с помощью 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
  }
}

Примечания: phoneType — это gsm, cdma, sip или none (планшеты только с Wi-Fi). simOperator — это код MCC+MNC (например, "40445"). IMEI, MEID и серийного номера нет — hardwareIdAvailable всегда false, потому что Android скрывает их от всех приложений начиная с Android 10; для стабильного идентификатора используйте getInstallId(). Переключатель «Телефон» добавляет в вашу карточку Play чувствительное разрешение; укажите, для чего вы его используете, в форме Data safety.

isTv мост#

window.WebToApk.isTv(): Boolean

True, когда приложение работает на телевизоре (Android TV, Google TV, Fire TV).

Пример

Сообщает странице, работает ли приложение на телевизоре (Android TV, Google TV, Fire TV), чтобы она могла показать макет, удобный для пульта.

Возвращает: true или false (синхронно). Требуется: ничего. Если в мастере включена поддержка Android TV, приложение также получает баннер ТВ в лаунчере ТВ, а на ТВ страница получает чёткую рамку фокуса, и ссылки, кнопки, поля и элементы с onclick или ролью кнопки становятся доступны стрелками пульта; OK нажимает на элемент в фокусе, а «Назад» работает как на телефоне.

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); }

Выберите другой главный экран для ТВ и оставьте вариант для телефона по умолчанию.

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; });

Примечания: Проверяйте в эмуляторе Android TV с его D-pad. Публикация для ТВ в Google Play — отдельный шаг: в Play Console подключите приложение к форм-фактору Android TV (Release → Advanced settings → Form factors) и добавьте скриншот для ТВ.