JavaScript bridge API
Device and app information
What phone this is, which build, a stable per-install identifier, and the standard location and camera APIs.
getUserMedia web standard#
navigator.mediaDevices.getUserMedia — camera and microphone
Example
The standard web camera and microphone API works inside the app. The app shows Android's own permission prompt and gives the stream to the page.
Returns: a Promise of a MediaStream, or a rejection (NotAllowedError when the user refused or the switch was off at build time, NotFoundError when there is no such device). Needs: Camera and/or Mic in Step 4 (Access). When your code calls getUserMedia({ video: … }) or { audio: … } (or uses <input capture>, BarcodeDetector, SpeechRecognition), the build finds the call and turns the matching switch on for you; { video: false } does not turn Camera on.
Show the back camera in a video element.
<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>Record a voice note with the microphone only.
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);
}Take a photo from the camera stream and release the camera.
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);
}Notes: When you ask for camera and mic together, the app asks the user for both at once. If the switch was off when you built, the request is refused with no prompt at all - build again with it on. Always stop the tracks when you are done, or the camera/mic light stays on. DRM video (Widevine) is allowed without any switch. Camera and microphone are sensitive: declare them in your Play Data safety form.
IdleDetector web standard#
new IdleDetector() — Idle Detection
Example
The standard Idle Detection API: learn when the user stops using the phone (no input for a while) or locks the screen, and when they come back - for an "away" status, pausing a live session or locking a vault.
Returns: IdleDetector.requestPermission() → 'granted' or 'denied' (needs a tap); detector.start({threshold, signal}) → Promise (rejects NotAllowedError without the permission, TypeError under 60000 ms); then userState ('active'/'idle'), screenState ('locked'/'unlocked') and a change event. Needs: nothing to switch on and no Android permission.
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();
});Resume watching on the next launch without a new tap, once the user has already said yes:
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 the user change the auto-lock delay: stop the running detector, start a new one, and report the real errors:
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;
}
}Notes: in the app, "input" is the user's input to THIS app while it is in front; while another app is in front with the screen on, the user counts as active (an app cannot see other apps' input - Chrome on Android answers the same way); once the screen is off or locked, the idle time runs from the last touch. The permission is remembered, and navigator.permissions.query({name:'idle-detection'}) answers it. Internal transports: __idleState, __idlePermission; lock/unlock changes arrive as appmint:idle. AI-built apps use watchIdle().
TextDetector web standard#
new TextDetector().detect(image) — text recognition (OCR)
Example
Reads the text in a picture on the phone itself - a receipt total, a serial number, a business card - with Google's ML Kit recognizer. The shape is the Shape Detection API's TextDetector, the one Chrome ships behind a flag.
Returns: Promise of an array, one entry per line: { rawValue, boundingBox, cornerPoints } in the image's pixels. Rejects NotSupportedError without Google Play services and OperationError when recognition fails. Needs: nothing to switch on and no permission (to read from the camera, take the photo with <input type="file" accept="image/*" capture> or a camera stream).
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 + ')';
}
});Read a serial number from the live camera preview (a playing <video> is passed as it is, its current frame is read):
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.');
});Outline every recognised line on top of the photo, using boundingBox (image pixels):
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;
}Hide the scan button where text recognition is absent (no Google Play services), and pull contact details from a business card:
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],
});
});Notes: Latin scripts only (English, Spanish, French, German, Indonesian, Vietnamese, Turkish and others); Chinese, Devanagari, Japanese, Korean and Arabic are not recognised. The model comes from Google Play services: the build asks for it at install when your code uses it, otherwise the first call on a fresh phone waits a few seconds while it downloads. Images from another site must allow CORS (a tainted canvas rejects SecurityError). Internal transports: __ocrAvailable, __ocrRecognize (answered as appmint:ocr). AI-built apps use recognizeText().
__idlePermission bridge#
window.WebToApk.__idlePermission(request: Boolean): String
IdleDetector.requestPermission() ([request] true, called under a user gesture) records the grant; false only reads it. "granted" | "prompt".
Example
Internal transport behind IdleDetector.requestPermission() and the 'idle-detection' permission state. Pages use the standard API; never call this directly - the __ methods may change without notice.
Returns: 'granted' or 'prompt'. Needs: a user gesture for the request.
Use the public API:
button.onclick = async () => {
const state = await IdleDetector.requestPermission();
console.log(state); // 'granted'
};Feature-detect, and treat a refusal as "not allowed":
async function askIdlePermission() {
if (!('IdleDetector' in window)) return false;
try {
return (await IdleDetector.requestPermission()) === 'granted';
} catch (err) {
return false;
}
}Request it from a tap, then start detecting:
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 });
});Notes: Implemented in the app shell (IdleAndRelatedApps.kt).
__idleState bridge#
window.WebToApk.__idleState(): String
IdleDetector: `{idleMs, locked}` from the app's own input and the keyguard.
Example
Internal transport behind IdleDetector: how long since the user last gave input, and whether the screen is locked. Pages use the standard API; never call this directly - the __ methods may change without notice.
Returns: JSON {idleMs, locked}. Screen and app changes are pushed as the appmint:idle event with the same shape. Needs: nothing.
Use the public API:
const idle = new IdleDetector();
idle.onchange = () => console.log(idle.userState, idle.screenState);
await idle.start({ threshold: 60000 });Stop watching with an AbortController when the screen closes:
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();Lock the app when the screen locks:
if ('IdleDetector' in window && (await IdleDetector.requestPermission()) === 'granted') {
const d = new IdleDetector();
d.onchange = () => { if (d.screenState === 'locked') showPinScreen(); };
await d.start({ threshold: 60000 });
}Notes: Implemented in the app shell (IdleAndRelatedApps.kt).
__permissionState bridge#
window.WebToApk.__permissionState(name: String): String
Permissions API state of a W3C permission name, from Android's real grants: "granted" | "denied" | "prompt", or "" when the shell does not answer that name (the page's query then goes to WebView's own implementation).
Example
Internal transport behind navigator.permissions.query({ name }). Pages use the standard API; never call this directly - the __ methods may change without notice.
Returns: 'granted', 'denied', 'prompt', or '' for a name the app does not answer (the query then goes to the WebView's own). Needs: nothing to switch on.
Use the public API:
const status = await navigator.permissions.query({ name: 'camera' });
showCameraHint(status.state); // 'granted' | 'denied' | 'prompt'
status.addEventListener('change', () => showCameraHint(status.state));Check several permissions at once:
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]));Ask only when the state is not denied, and say what to do when it is:
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));
}Notes: Answers camera, microphone, geolocation, notifications, screen-wake-lock, clipboard-read, clipboard-write. A capability the build left off (or whose permission is not in the manifest) is denied. Live statuses re-read on appmint:permissions-changed (fired after every permission dialog) and when the page becomes visible again.
getAppName bridge#
window.WebToApk.getAppName(): String
The app name the creator chose in the wizard - what the launcher shows.
Example
Gives the app name you chose in the build wizard - the name under the launcher icon.
Returns: a string right away. Needs: nothing.
Use the real app name in a title and a share text.
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 + '!' });
});Put the app name in the page title and a heading:
if (window.WebToApk && WebToApk.getAppName) {
const name = WebToApk.getAppName();
document.title = name;
document.querySelector('h1').textContent = name;
}Build a support email that names the app and its version:
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:support@example.com?subject=' + subject;
}
document.getElementById('contact').href = supportMailto();Notes: This is the name from the wizard, not the page's <title>. It does not change at run time. For the version, use getAppVersion().
getAppVersion bridge#
window.WebToApk.getAppVersion(): String
The versionName from the installed package, e.g. "1.4.2" - the string a creator sets when building, not the versionCode. Falls back to "1.0" if the package cannot be read, so a page can print it without a null check.
Example
Gives the installed app's version name, for example "1.4.2".
Returns: a string right away. Needs: nothing.
Show the version in an About screen.
var version = (window.WebToApk && window.WebToApk.getAppVersion)
? WebToApk.getAppVersion()
: 'web';
document.getElementById('about').textContent = 'Version ' + version;Ask for an update when the installed version is old:
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;
}Add the version and the version code from getDeviceInfo() to a bug report:
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();Notes: This is the version NAME you set when building, not the version code. If the package cannot be read it returns "1.0", so it is never empty. The version code and install dates are in getDeviceInfo() under app.
getDeviceInfo bridge#
window.WebToApk.getDeviceInfo(): String
WebToApk.getDeviceInfo() -> JSON string, NO permission required WebToApk.getPhoneIdentity() -> carrier/SIM, needs the Phone permission WebToApk.getInstallId() -> stable per-install UUID
Described by its group, Device / app information, rather than on its own.
Example
Gives facts about the phone, Android, the screen, your app and the current state (battery, memory, network) in one call.
Returns: a JSON string right away - use JSON.parse. Top-level keys: android, hardware, screen, app, runtime, installId. Needs: nothing - no permission.
Show phone information.
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;
}All the keys:
{
"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-…"
}Warn on a low-storage or low-memory phone before a large download.
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;
}Notes: socManufacturer / socModel exist only on Android 12+. networkType is wifi, cellular, ethernet, other, none or unknown. installer is com.android.vending for Play installs and empty for side-loads. Values are read at the moment you call - call again to refresh battery or network. There is no IMEI or serial number: Android blocks them for all apps; use getInstallId().
getInstallId bridge#
window.WebToApk.getInstallId(): String
WebToApk.getDeviceInfo() -> JSON string, NO permission required WebToApk.getPhoneIdentity() -> carrier/SIM, needs the Phone permission WebToApk.getInstallId() -> stable per-install UUID
Described by its group, Device / app information, rather than on its own.
Example
Gives a random ID that stays the same for this install of your app.
Returns: a string (a UUID such as "3f2b…-…") right away. Needs: nothing.
Use it as a device key for your server or for a "one free trial per device" check.
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() })
});Send the id to your server with a header and keep it out of the 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();
}Notes: The ID is made on first use and kept in the app's storage. It stays the same after app updates. It changes when the user uninstalls the app or clears its data. It is not a hardware ID - Android gives no IMEI or serial to apps. The same value is also in getDeviceInfo() as installId.
getPhoneIdentity bridge#
window.WebToApk.getPhoneIdentity(): String
WebToApk.getDeviceInfo() -> JSON string, NO permission required WebToApk.getPhoneIdentity() -> carrier/SIM, needs the Phone permission WebToApk.getInstallId() -> stable per-install UUID
Described by its group, Device / app information, rather than on its own.
Example
Gives the mobile carrier and SIM facts: carrier name, SIM country, operator code, phone type and SIM state.
Returns: a JSON string right away - use JSON.parse: { carrierName, simCountryIso, networkCountryIso, simOperator, phoneType, simState, hardwareIdAvailable: false }, or { error }. Needs: turn on Phone in Step 4 (Access) when you build; otherwise it returns { "error": "not_enabled" }.
Show the carrier and pick the country code.
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"
}Pick a calling code from the SIM country for a sign-up form.
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();Tell a Wi-Fi-only tablet from a phone with 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
}
}Notes: phoneType is gsm, cdma, sip or none (Wi-Fi-only tablets). simOperator is the MCC+MNC code (e.g. "40445"). There is no IMEI, MEID or serial - hardwareIdAvailable is always false, because Android hides them from every app since Android 10; use getInstallId() for a stable id. The Phone switch adds a sensitive permission to your Play listing; declare what you use it for in the Data safety form.