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

JavaScript bridge API

Отчёты о сбоях и целостность устройства

Ошибки, которые записывает страница, последний нативный сбой и то, похож ли телефон на взломанный или изменённый, с необязательным токеном Play Integrity для вашего сервера.

AppMint.integrity помощник#

AppMint.integrity.check() / AppMint.integrity.token(nonce, cloudProjectNumber?)

Пример

Версии getDeviceIntegrity и requestIntegrityToken на промисах, доступные до первого скрипта вашей страницы, когда в сборке включены Проверки целостности устройства (шаг «Интеграция», шаг 3).

Возвращает: check() возвращает объект эвристик ({rooted, emulator, debuggerAttached, signals, installer, installedFromPlay, signatureSha256, method: 'heuristic'}); token(nonce) возвращает строку токена Play Integrity или отклоняется с Error, сообщение которого — имя ошибки (not_enabled, nonce_too_short, network_error, …), а code — код ошибки Play. Эвристики — лишь подсказки; вердиктом является только расшифровка токена вашим сервером.

async function guard() {
  if (!(window.AppMint && AppMint.integrity)) return;          // browser, or option off
  const local = await AppMint.integrity.check();
  if (local.rooted) console.warn('rooted signals:', local.signals);

  const { nonce } = await fetch('/api/integrity/nonce').then(r => r.json());
  try {
    const token = await AppMint.integrity.token(nonce);          // project number from the build
    await fetch('/api/integrity/verify', { method: 'POST', body: token });
  } catch (e) {
    console.warn('Play Integrity unavailable:', e.message, e.code);
  }
}

Предупредите, если приложение — перепакованная копия:

if (window.AppMint && AppMint.integrity) {
  const d = await AppMint.integrity.check();
  if (!d.installedFromPlay) console.log('installed by', d.installer || 'side-loaded');
  if (d.signatureSha256 && d.signatureSha256 !== MY_CERT_SHA256) reportTamper(d.signatureSha256);
}

Обработайте сбой токена по имени:

async function getToken(nonce) {
  if (!(window.AppMint && AppMint.integrity)) return null;
  try {
    return await AppMint.integrity.token(nonce);
  } catch (e) {
    if (e.message === 'not_enabled') console.warn('Device integrity checks are off in this build');
    else if (e.message === 'nonce_too_short') console.warn('the server nonce must be longer');
    else console.warn('Play Integrity error', e.message, e.code);
    return null;
  }
}

getDeviceIntegrity мост#

window.WebToApk.getDeviceIntegrity(): String

Локальные ЭВРИСТИКИ root / эмулятора / отладчика (см. DeviceIntegrity) в виде JSON: `{rooted, emulator, debuggerAttached, signals:[…], installer, installedFromPlay, signatureSha256, method:"heuristic", sdk}`. Чистый результат означает «признаков не найдено», а не доказательство — для надёжного вердикта отправьте токен Play Integrity на ваш сервер (requestIntegrityToken). `{"error":"not_enabled"}`, если в сборке не включена проверка целостности устройства. Блокирует на несколько миллисекунд (читает файлы): вызывайте один раз.

Пример

Локальные ЭВРИСТИКИ root, эмулятора и отладчика в виде строки JSON. Чистый результат означает «признаков не найдено», а не доказательство: настойчивый пользователь с модулем, скрывающим root, пройдёт. Используйте это, чтобы предупредить или добавить проверку; для надёжного вердикта отправьте токен Play Integrity на ваш сервер (requestIntegrityToken).

Возвращает: '{"rooted":false,"emulator":false,"debuggerAttached":false,"signals":[],"installer":"com.android.vending","installedFromPlay":true,"signatureSha256":"AB:CD:…","method":"heuristic","sdk":34}' или '{"error":"not_enabled"}'. Сигналы называют то, что найдено: test-keys, su-binary:/system/xbin/su, root-path:/data/adb/magisk, root-app:com.topjohnwu.magisk, system-writable, ro.debuggable=1, ro.secure=0, selinux-permissive, bootloader-unlocked, emulator:*, debugger-attached. Требуется: включите Проверки целостности устройства на шаге «Интеграция» (шаг 3). Занимает несколько миллисекунд (читает файлы): вызывайте один раз, а не в цикле.

function deviceCheck() {
  if (!(window.WebToApk && window.WebToApk.getDeviceIntegrity)) return null;
  const r = JSON.parse(WebToApk.getDeviceIntegrity());
  if (r.error) return null;                       // 'not_enabled'
  return r;
}

const d = deviceCheck();
if (d && d.rooted) {
  showNotice('This phone looks rooted (' + d.signals.join(', ') + '). Some features are turned off.');
}
if (d && !d.installedFromPlay) console.log('installed by', d.installer || 'unknown (side-loaded)');

Проверьте собственную подпись — перепакованная копия вашего приложения подписана другим ключом:

const MY_CERT = 'AB:CD:EF:…';   // Play Console → App integrity → App signing key SHA-256
const d = deviceCheck();
if (d && d.signatureSha256 && d.signatureSha256 !== MY_CERT) reportTamper();

Запросите у сервера надёжный вердикт с помощью токена Play Integrity (ответ приходит как событие).

window.addEventListener('appmint:integrity', function (e) {
  if (e.detail.error) { showNotice('Check failed: ' + e.detail.error); return; }
  fetch('https://api.example.com/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: e.detail.token })
  });
});
if (window.WebToApk && WebToApk.requestIntegrityToken) {
  const nonce = crypto.randomUUID().replace(/-/g, '') + '0000';   // your server issues and checks this
  WebToApk.requestIntegrityToken(nonce, '123456789012');          // your Google Cloud project number
}

getLastCrash мост#

window.WebToApk.getLastCrash(): String

Последний сбой, записанный этой установкой (закрытие приложения из-за неперехваченной ошибки или сбой рендерера страницы), в виде JSON `{kind, message, stack, url, at, count, appVersion, user, fp}` или "", если сбоев не было (или отчёты о сбоях выключены). Сохраняется после отправки, чтобы страница могла сказать «извините, в прошлый раз приложение закрылось».

Пример

Последний сбой, записанный этой установкой — закрытие приложения из-за неперехваченной ошибки (kind: "native") или сбой рендерера страницы (kind: "renderer") — в виде строки JSON или '', если сбоев не было.

Возвращает: '{"kind","message","stack","url","at","count","appVersion","user","fp"}' или ''. Также '', если в сборке не включены отчёты о сбоях. Требуется: включённые Отчёты о сбоях на шаге «Интеграция» (шаг 3).

Извинитесь один раз после сбоя:

(function () {
  if (!(window.WebToApk && window.WebToApk.getLastCrash)) return;
  const raw = WebToApk.getLastCrash();
  if (!raw) return;
  const crash = JSON.parse(raw);
  if (localStorage.getItem('seenCrash') === String(crash.at)) return;   // shown already
  localStorage.setItem('seenCrash', String(crash.at));
  showBanner('Sorry, the app closed unexpectedly last time. We have been told about it.');
})();

Отправьте сохранённый сбой на ваш собственный сервер один раз, вместе с версией приложения, на которой он произошёл.

(function () {
  if (!(window.WebToApk && WebToApk.getLastCrash)) return;
  const raw = WebToApk.getLastCrash();
  if (!raw) return;                         // no crash, or Crash reports are off
  const c = JSON.parse(raw);
  if (localStorage.getItem('sentCrash') === String(c.at)) return;
  fetch('https://api.example.com/crashes', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ kind: c.kind, message: c.message, version: c.appVersion, count: c.count })
  }).then(function () { localStorage.setItem('sentCrash', String(c.at)); });
})();

Разветвляйте логику по типу, чтобы показать подходящее сообщение.

const raw = window.WebToApk && WebToApk.getLastCrash && WebToApk.getLastCrash();
if (raw) {
  const c = JSON.parse(raw);
  showBanner(c.kind === 'renderer'
    ? 'The page ran out of memory last time. Try closing other apps.'
    : 'The app closed unexpectedly last time.');
}

Примечания: он остаётся после отправки отчёта, поэтому сравнивайте at с тем, что вы сохранили, чтобы показать сообщение один раз. Ошибки страницы и вызовы logError — не сбои и здесь никогда не появляются.

logError мост#

window.WebToApk.logError(message: String, stack: String): Boolean

Отчёты о сбоях: записывает ошибку, которую ваша страница ПЕРЕХВАТИЛА (сбойный вызов платежа, ошибка разбора), чтобы она появилась на экране AppMint «Отчёты о сбоях» рядом со сбоями, сгруппированная и подсчитанная. [stack]: `err.stack` или "". Возвращает false, если в сборке не включены отчёты о сбоях, если оба аргумента пусты или если та же ошибка уже много раз записывалась за этот сеанс. Никогда не помещайте в сообщение персональные данные (почту, телефон, токены).

Пример

Записывает ошибку, которую ваша страница ПЕРЕХВАТИЛА (сбойный вызов платежа, ошибка разбора JSON), чтобы она появилась на экране AppMint Отчёты о сбоях (Обновить приложение → ваше приложение → Отчёты о сбоях), сгруппированная со сбоями и подсчитанная. Неперехваченные ошибки и необработанные отклонения промисов записываются автоматически; это для тех, что вы обработали.

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

Запишите обработанную ошибку:

function logError(err, context) {
  if (!(window.WebToApk && window.WebToApk.logError)) return false;   // browser, or an older build
  var msg = (context ? context + ': ' : '') + (err && err.message ? err.message : String(err));
  return WebToApk.logError(msg, (err && err.stack) || '');
}

async function checkout(cart) {
  try {
    await payments.charge(cart.total);
  } catch (err) {
    logError(err, 'checkout');          // shows up as "Logged by your page"
    showToast('Payment failed, please try again');
  }
}

Сообщайте о неудачных вызовах API без строки запроса из адреса (в ней могут быть токены):

async function api(path) {
  const res = await fetch('/api' + path);
  if (!res.ok && window.WebToApk && WebToApk.logError) {
    WebToApk.logError('API ' + res.status + ' ' + path.split('?')[0], '');
  }
  return res;
}

Запишите перехваченную ошибку с идентификатором пользователя, чтобы позже можно было найти проблемы конкретного пользователя.

if (window.WebToApk && WebToApk.setCrashUserId) WebToApk.setCrashUserId('user_' + currentUser.id);

try {
  JSON.parse(savedState);
} catch (err) {
  if (window.WebToApk && WebToApk.logError) {
    const logged = WebToApk.logError('state parse: ' + err.message, err.stack || '');
    if (!logged) console.warn('not recorded (Crash reports off, or repeated too often)');
  }
}

Примечания: отчёты с одной и той же причиной группируются, даже если числа в сообщении различаются (item 42 и item 7). Адрес страницы сохраняется без параметров запроса и фрагмента.

requestIntegrityToken мост#

window.WebToApk.requestIntegrityToken(nonce: String, cloudProjectNumber: String): String

Запрашивает у Google Play токен Play Integrity (классический запрос). [nonce]: Base64, безопасный для URL, 16–500 символов, созданный ВАШИМ сервером для этого единственного запроса. [cloudProjectNumber]: номер вашего проекта Google Cloud или "", чтобы использовать указанный в сборке (приложение, установленное из Play, может оставить оба пустыми). Сразу возвращает идентификатор запроса ("", если в сборке не включена проверка целостности устройства); ответ приходит как `appmint:integrity` / `window.onAppMintIntegrity({requestId, token})` или `{requestId, error, code}`. Отправьте токен на ваш сервер, который расшифрует его через Google (playintegrity.googleapis.com … :decodeIntegrityToken) и проверит nonce.

Пример

Запрашивает у Google Play токен Play Integrity (классический запрос). На телефоне токен непрозрачен: ваш СЕРВЕР отправляет его в Google, читает вердикт (подлинное приложение? подлинное устройство? лицензировано?) и проверяет, что nonce — тот, который он выдавал. Именно эта проверка на сервере делает ответ надёжным.

Возвращает: идентификатор запроса сразу ('', если в сборке не включена проверка целостности устройства). Ответ приходит как событие appmint:integrity (и window.onAppMintIntegrity) с {requestId, token} или {requestId, error, code} — ошибки вроде nonce_too_short, nonce_is_not_base64, network_error, play_store_not_found, cloud_project_number_is_invalid, too_many_requests. Требуется: включённые Проверки целостности устройства на шаге «Интеграция» (шаг 3), включённый для приложения Play Integrity API (Play Console → Целостность приложения) и — для приложения, НЕ установленного из Google Play, — номер вашего проекта Google Cloud в мастере или вторым аргументом ('' использует номер из сборки).

async function integrityToken() {
  // 1. Your server makes a one-time nonce: URL-safe Base64, 16-500 characters.
  const { nonce } = await fetch('/api/integrity/nonce').then(r => r.json());
  return new Promise((resolve, reject) => {
    const id = WebToApk.requestIntegrityToken(nonce, '');
    if (!id) return reject(new Error('not_enabled'));
    window.addEventListener('appmint:integrity', function on(e) {
      if (e.detail.requestId !== id) return;
      window.removeEventListener('appmint:integrity', on);
      e.detail.token ? resolve(e.detail.token) : reject(new Error(e.detail.error));
    });
  });
}

// 2. Your server decodes it with Google and decides.
const token = await integrityToken();
const verdict = await fetch('/api/integrity/verify', { method: 'POST', body: token }).then(r => r.json());

Шаг на сервере — вызовите POST https://playintegrity.googleapis.com/v1/<your.package>:decodeIntegrityToken с {"integrity_token": token}, используя служебный аккаунт связанного проекта Cloud, затем проверьте:

const p = decoded.tokenPayloadExternal;
const ok = p.requestDetails.nonce === issuedNonce &&
  p.appIntegrity.appRecognitionVerdict === 'PLAY_RECOGNIZED' &&
  p.deviceIntegrity.deviceRecognitionVerdict.includes('MEETS_DEVICE_INTEGRITY');

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

window.onAppMintIntegrity = function (d) {
  if (d.error) console.warn('integrity failed', d.error, d.code);
  else sendToServer(d.token);
};
WebToApk.requestIntegrityToken(nonceFromServer, '123456789012');

setCrashUserId мост#

window.WebToApk.setCrashUserId(id: String): Boolean

Отчёты о сбоях: ВАШ идентификатор вошедшего пользователя (буквы, цифры и _ @ . : + -, до 128), прикрепляется к отчётам с этого момента, чтобы вы могли считать затронутых пользователей и находить сбои конкретного пользователя. "" сбрасывает его. Никогда не адрес электронной почты и не номер телефона. Возвращает false для идентификатора с другими символами или когда отчёты о сбоях выключены.

Пример

Прикрепляет ВАШ идентификатор вошедшего пользователя к отчётам о сбоях с этого момента, чтобы экран «Отчёты о сбоях» мог считать, скольких пользователей затрагивает проблема. Вызывайте после входа; при выходе вызывайте с ''.

Возвращает: true при сохранении; false для идентификатора с символами, отличными от букв, цифр и _ @ . : + - (или длиннее 128), или если в сборке не включены отчёты о сбоях. Требуется: включённые Отчёты о сбоях на шаге «Интеграция» (шаг 3). Используйте идентификатор из вашей базы данных, никогда не адрес электронной почты и не номер телефона.

function crashUser(id) {
  if (!(window.WebToApk && window.WebToApk.setCrashUserId)) return false;
  return WebToApk.setCrashUserId(id);
}

async function signIn(email, password) {
  const user = await myBackend.signIn(email, password);
  crashUser(user.id);            // e.g. 'u_42'
}

function signOut() {
  crashUser('');                 // later reports carry no user
}

Отклонённый идентификатор или сборка без отчётов о сбоях возвращает false:

function setCrashUser(id) {
  if (!(window.WebToApk && WebToApk.setCrashUserId)) return;
  if (!WebToApk.setCrashUserId(id)) {
    console.warn('Crash user id not saved: bad characters, over 128 long, or Crash reports are off');
  }
}
setCrashUser('u_42');       // your database id, never an e-mail address

Сообщите пользователю о последнем сбое после перезапуска:

if (window.WebToApk && WebToApk.getLastCrash) {
  const raw = WebToApk.getLastCrash();          // '' when there has been none
  if (raw) {
    const c = JSON.parse(raw);
    showMessage('Sorry, the app closed last time (' + c.kind + ').');
  }
}

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