JavaScript bridge API
Crash reports and device integrity
Errors the page logs, the last native crash, and whether the phone looks rooted or modified, with an optional Play Integrity token for your server.
AppMint.integrity helper#
AppMint.integrity.check() / AppMint.integrity.token(nonce, cloudProjectNumber?)
Example
Promise versions of getDeviceIntegrity and requestIntegrityToken, present before your page's first script when the build turned on Device integrity checks (Integrate step, Step 3).
Returns: check() resolves with the heuristics object ({rooted, emulator, debuggerAttached, signals, installer, installedFromPlay, signatureSha256, method: 'heuristic'}); token(nonce) resolves with the Play Integrity token string, or rejects with an Error whose message is the error name (not_enabled, nonce_too_short, network_error, …) and code the Play error code. The heuristics are hints; only your server's decode of the token is a verdict.
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);
}
}getDeviceIntegrity bridge#
window.WebToApk.getDeviceIntegrity(): String
Local root / emulator / debugger HEURISTICS (see DeviceIntegrity) as JSON: `{rooted, emulator, debuggerAttached, signals:[…], installer, installedFromPlay, signatureSha256, method:"heuristic", sdk}`. A clean result means "no sign found", not proof - for a verdict you can trust, send a Play Integrity token to your server (requestIntegrityToken). `{"error":"not_enabled"}` unless the build turned on Device integrity. Blocks for a few milliseconds (it reads files): call it once.
Example
Local root, emulator and debugger HEURISTICS as a JSON string. A clean result means "no sign found", not proof: a determined user with a root-hiding module passes. Use it to warn or to add a check; for a verdict you can trust, send a Play Integrity token to your server (requestIntegrityToken).
Returns: '{"rooted":false,"emulator":false,"debuggerAttached":false,"signals":[],"installer":"com.android.vending","installedFromPlay":true,"signatureSha256":"AB:CD:…","method":"heuristic","sdk":34}', or '{"error":"not_enabled"}'. Signals name what was found: 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. Needs: turn on Device integrity checks in the Integrate step (Step 3). It takes a few milliseconds (it reads files): call it once, not in a loop.
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)');Check your own signature - a repackaged copy of your app is signed with another key:
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();getLastCrash bridge#
window.WebToApk.getLastCrash(): String
The newest crash this install recorded (the app closing on an uncaught error, or the page renderer crashing), as JSON `{kind, message, stack, url, at, count, appVersion, user, fp}`, or "" when there has been none (or Crash reports are off). Kept after upload, so a page can say "sorry, the app closed last time".
Example
The newest crash this install recorded - the app closing on an uncaught error (kind: "native") or the page renderer crashing (kind: "renderer") - as a JSON string, or '' when there has been none.
Returns: '{"kind","message","stack","url","at","count","appVersion","user","fp"}' or ''. Also '' when the build did not turn on Crash reports. Needs: Crash reports on in the Integrate step (Step 3).
Say sorry once after a crash:
(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.');
})();Notes: it stays after the report is uploaded, so compare at with what you stored to show the message once. Page errors and logError calls are not crashes and never appear here.
logError bridge#
window.WebToApk.logError(message: String, stack: String): Boolean
Crash reports: records an error your page CAUGHT (a failed payment call, a parse error) so it appears in AppMint's Crash reports screen next to the crashes, grouped and counted. [stack]: `err.stack` or "". Returns false when the build did not turn on Crash reports, when both are empty, or when the same error was already logged many times this session. Never put personal data (e-mail, phone, tokens) in the message.
Example
Records an error your page CAUGHT (a failed payment call, a JSON parse error) so it appears in AppMint's Crash reports screen (Update the App → your app → Crash reports), grouped with the crashes and counted. Uncaught errors and unhandled promise rejections are recorded for you; this is for the ones you handled.
Returns: true when recorded; false when the build did not turn on Crash reports, when both arguments are empty, or when the same error was already logged many times this session. Reports upload the next time the app opens. Needs: turn on Crash reports in the Integrate step (Step 3) when you build. Never put personal data (e-mail address, phone number, tokens) in the message.
Log a handled error:
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');
}
}Report failed API calls without the address' query string (it may carry tokens):
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;
}Notes: reports with the same cause are grouped even when numbers in the message differ (item 42 and item 7). The page address is stored without its query and fragment.
requestIntegrityToken bridge#
window.WebToApk.requestIntegrityToken(nonce: String, cloudProjectNumber: String): String
Asks Google Play for a Play Integrity token (classic request). [nonce]: URL-safe Base64, 16-500 characters, made by YOUR server for this one request. [cloudProjectNumber]: your Google Cloud project number, or "" to use the one set in the build (an app installed from Play can leave both empty). Returns a request id at once ("" when the build did not turn on Device integrity); the answer arrives as `appmint:integrity` / `window.onAppMintIntegrity({requestId, token})` or `{requestId, error, code}`. Send the token to your server, which decodes it with Google (playintegrity.googleapis.com … :decodeIntegrityToken) and checks the nonce.
Example
Asks Google Play for a Play Integrity token (classic request). The token is opaque on the phone: your SERVER sends it to Google, reads the verdict (genuine app? genuine device? licensed?) and checks that the nonce is the one it issued. That server check is what makes the answer trustworthy.
Returns: a request id at once ('' when the build did not turn on Device integrity). The answer arrives as the appmint:integrity event (and window.onAppMintIntegrity) with {requestId, token} or {requestId, error, code} - errors such as nonce_too_short, nonce_is_not_base64, network_error, play_store_not_found, cloud_project_number_is_invalid, too_many_requests. Needs: Device integrity checks on in the Integrate step (Step 3), the Play Integrity API enabled for the app (Play Console → App integrity), and - for an app NOT installed from Google Play - your Google Cloud project number, in the wizard or as the second argument ('' uses the build's).
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());The server step - call POST https://playintegrity.googleapis.com/v1/<your.package>:decodeIntegrityToken with {"integrity_token": token} using a service account of the linked Cloud project, then check:
const p = decoded.tokenPayloadExternal;
const ok = p.requestDetails.nonce === issuedNonce &&
p.appIntegrity.appRecognitionVerdict === 'PLAY_RECOGNIZED' &&
p.deviceIntegrity.deviceRecognitionVerdict.includes('MEETS_DEVICE_INTEGRITY');The callback form, for code that cannot listen to events:
window.onAppMintIntegrity = function (d) {
if (d.error) console.warn('integrity failed', d.error, d.code);
else sendToServer(d.token);
};
WebToApk.requestIntegrityToken(nonceFromServer, '123456789012');setCrashUserId bridge#
window.WebToApk.setCrashUserId(id: String): Boolean
Crash reports: YOUR id for the signed-in user (letters, digits and _ @ . : + -, up to 128), attached to reports from now on so you can count affected users and find one user's crashes. "" clears it. Never an e-mail address or phone number. Returns false for an id with other characters, or when Crash reports are off.
Example
Attaches YOUR id for the signed-in user to crash reports from now on, so the Crash reports screen can count how many users a problem affects. Call it after sign-in; call it with '' on sign-out.
Returns: true when saved; false for an id with characters other than letters, digits and _ @ . : + - (or longer than 128), or when the build did not turn on Crash reports. Needs: Crash reports on in the Integrate step (Step 3). Use your database id, never an e-mail address or phone number.
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
}Notes: the id stays on the phone until you change it and is only sent inside crash reports.