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.
AppMint.enableLocation helper#
AppMint.enableLocation()
Example
"Turn on location" the official Android way, in one call: Google's own system dialog while Location is off, then - with { accuracy: true } - Google's Location Accuracy screen while "Improve Location Accuracy" is off, and the new state when the user comes back. Use it before asking for a position: Capacitor's Geolocation.getCurrentPosition gives up with OS-PLUG-GLOC-0007 ("Location services are not enabled.") while Location is off and never offers the dialog itself.
Returns: AppMint.enableLocation({ accuracy? }) - a Promise of { result, shown, status }: result is 'enabled' (Location is on, and Location Accuracy too when you asked for it), 'declined' (the user said no) or 'unavailable' (nothing could be shown); shown names what was used: '', 'dialog' (Google's dialog), 'settings' (Android's Location screen, on phones without Google Play services), 'accuracy', comma-joined when several. AppMint.getLocationStatus() - a Promise of { location, gps, network, accuracy, googlePlay }; accuracy is Google Location Accuracy (true/false), or null while Location is off or on a phone without Google Play services. Neither needs a location permission. Needs: call enableLocation() from a button the user tapped; reading the position afterwards needs Location in the build (ticked automatically when your code uses geolocation). Exists in apps built with AppMint from November 2026; rebuild an older app to get it (the examples feature-detect, so older builds simply skip it).
// "Find me": make sure location is on (Google's dialog), then read the position.
async function findMe() {
if (window.AppMint && AppMint.enableLocation) {
const r = await AppMint.enableLocation({ accuracy: true });
if (r.result !== 'enabled' && !(r.status && r.status.location)) {
showBanner('Location is off. Tap "Find me" to turn it on.');
return;
}
}
navigator.geolocation.getCurrentPosition(
(pos) => showOnMap(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
(err) => showBanner('Could not get your location: ' + err.message),
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }
);
}
document.getElementById('find-me').onclick = findMe;Show a hint only while something is off, without opening anything:
async function locationHint() {
if (!(window.AppMint && AppMint.getLocationStatus)) return;
const s = await AppMint.getLocationStatus();
const hint = document.getElementById('location-hint');
if (!s.location) hint.textContent = 'Location is off.';
else if (s.accuracy === false) hint.textContent = 'Turn on Location Accuracy for a faster, more precise fix indoors.';
hint.hidden = s.location && s.accuracy !== false;
}
locationHint();Every finished flow is also announced:
window.addEventListener('appmint:location-settings', (e) => {
// e.detail = { result: 'enabled' | 'declined' | 'unavailable', shown, status }
if (e.detail.result === 'enabled') refreshNearbyList();
});Notes: Google's dialog turns on Location with one tap and comes back to your page; Location Accuracy has no such dialog, so its own Google screen opens and the answer arrives when the user returns. A user who declines gets 'declined'; call again from a button to ask again. With Location Accuracy off the position still comes from GPS alone - slower and often unavailable indoors.
AppMint.getLocationStatus helper#
window.AppMint.getLocationStatus
Example
Documented together with AppMint.enableLocation - the example there shows this one too.
"Turn on location" the official Android way, in one call: Google's own system dialog while Location is off, then - with { accuracy: true } - Google's Location Accuracy screen while "Improve Location Accuracy" is off, and the new state when the user comes back. Use it before asking for a position: Capacitor's Geolocation.getCurrentPosition gives up with OS-PLUG-GLOC-0007 ("Location services are not enabled.") while Location is off and never offers the dialog itself.
Returns: AppMint.enableLocation({ accuracy? }) - a Promise of { result, shown, status }: result is 'enabled' (Location is on, and Location Accuracy too when you asked for it), 'declined' (the user said no) or 'unavailable' (nothing could be shown); shown names what was used: '', 'dialog' (Google's dialog), 'settings' (Android's Location screen, on phones without Google Play services), 'accuracy', comma-joined when several. AppMint.getLocationStatus() - a Promise of { location, gps, network, accuracy, googlePlay }; accuracy is Google Location Accuracy (true/false), or null while Location is off or on a phone without Google Play services. Neither needs a location permission. Needs: call enableLocation() from a button the user tapped; reading the position afterwards needs Location in the build (ticked automatically when your code uses geolocation). Exists in apps built with AppMint from November 2026; rebuild an older app to get it (the examples feature-detect, so older builds simply skip it).
// "Find me": make sure location is on (Google's dialog), then read the position.
async function findMe() {
if (window.AppMint && AppMint.enableLocation) {
const r = await AppMint.enableLocation({ accuracy: true });
if (r.result !== 'enabled' && !(r.status && r.status.location)) {
showBanner('Location is off. Tap "Find me" to turn it on.');
return;
}
}
navigator.geolocation.getCurrentPosition(
(pos) => showOnMap(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
(err) => showBanner('Could not get your location: ' + err.message),
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 }
);
}
document.getElementById('find-me').onclick = findMe;Show a hint only while something is off, without opening anything:
async function locationHint() {
if (!(window.AppMint && AppMint.getLocationStatus)) return;
const s = await AppMint.getLocationStatus();
const hint = document.getElementById('location-hint');
if (!s.location) hint.textContent = 'Location is off.';
else if (s.accuracy === false) hint.textContent = 'Turn on Location Accuracy for a faster, more precise fix indoors.';
hint.hidden = s.location && s.accuracy !== false;
}
locationHint();Every finished flow is also announced:
window.addEventListener('appmint:location-settings', (e) => {
// e.detail = { result: 'enabled' | 'declined' | 'unavailable', shown, status }
if (e.detail.result === 'enabled') refreshNearbyList();
});Notes: Google's dialog turns on Location with one tap and comes back to your page; Location Accuracy has no such dialog, so its own Google screen opens and the answer arrives when the user returns. A user who declines gets 'declined'; call again from a button to ask again. With Location Accuracy off the position still comes from GPS alone - slower and often unavailable indoors.
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 you build from a ZIP or an HTML page and your code calls getUserMedia({ video: … }) or { audio: … } (or uses <input capture>, BarcodeDetector, SpeechRecognition), the build finds the call and adds the matching permission for you; { video: false } does not add Camera. For a website (URL) build, tick them yourself.
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.
enableLocation bridge#
window.WebToApk.enableLocation(callbackId: String, optionsJson: String)
Turns location on the official way: Google's system dialog while Location is off, then (with {"accuracy":true}) Google's Location Accuracy screen while that is off. Resolves `window.__appmintReplies[callbackId]` with {result, shown, status} (AppMint.enableLocation() is the promise wrapper).
Example
Turns location on the official way: Google's system dialog while Location is off (Android's Location screen on phones without Google Play services), then - with {"accuracy":true} - Google's Location Accuracy screen while that is off, answering when the user is back.
Returns: nothing directly. The answer {result, shown, status} is delivered to window.__appmintReplies[callbackId]: result is 'enabled' | 'declined' | 'unavailable', shown is '' | 'dialog' | 'settings' | 'accuracy' (comma-joined), status is what getLocationStatus() returns. Already on answers at once with shown: ''. Needs: call it from a button the user tapped. Exists in apps built with AppMint from November 2026; rebuild an older app to get it (the examples feature-detect, so older builds simply skip it).
Prefer the promise wrapper AppMint.enableLocation(). The raw call, for code that manages its own replies:
function enableLocationRaw(opts) {
return new Promise(function (resolve, reject) {
if (!(window.WebToApk && WebToApk.enableLocation)) return reject(new Error('not in the app'));
var replies = window.__appmintReplies = window.__appmintReplies || {};
var id = 'my_loc_' + Date.now();
replies[id] = resolve;
WebToApk.enableLocation(id, JSON.stringify(opts || {}));
});
}
document.getElementById('find-me').onclick = async function () {
var r = await enableLocationRaw({ accuracy: true });
if (r.result === 'enabled') startTracking();
};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:[email protected]?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();
}Attach it to crash reports so one install's problems can be found.
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');
}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.
getLocationStatus bridge#
window.WebToApk.getLocationStatus(): String
Location as the OS has it right now, as JSON {location, gps, network, accuracy, googlePlay}; `accuracy` is Google Location Accuracy (null when unknown). Needs no permission. See LocationSettingsFlow.
Example
Location as the phone has it right now, without opening anything and without a location permission.
Returns: a JSON string {location, gps, network, accuracy, googlePlay} - location is Android's Location switch, gps / network the two providers, accuracy Google Location Accuracy (true/false; null while Location is off or without Google Play services), googlePlay whether Google Play services are there. Needs: nothing. Exists in apps built with AppMint from November 2026; rebuild an older app to get it (the examples feature-detect, so older builds simply skip it).
var s = JSON.parse(window.WebToApk && WebToApk.getLocationStatus ? WebToApk.getLocationStatus() : 'null');
if (s && !s.location) document.getElementById('turn-on-location').hidden = false;
if (s && s.accuracy === false) document.getElementById('accuracy-tip').hidden = false;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.
isTv bridge#
window.WebToApk.isTv(): Boolean
True when the app runs on a television (Android TV, Google TV, Fire TV).
Example
Tells the page whether the app is running on a television (Android TV, Google TV, Fire TV), so it can show a remote-friendly layout.
Returns: true or false (synchronously). Needs: nothing. With Android TV support on in the wizard the app also gets a TV banner in the TV launcher, and on a TV the page gets a clear focus ring and links, buttons, fields and elements wired with onclick or a button role become reachable with the remote’s arrows; OK clicks the focused element and Back works as on a phone.
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); }Pick a different home screen for TV and keep the phone layout as the default.
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; });Notes: Test with the Android TV emulator and its D-pad. Publishing to TVs on Google Play is a separate step: in Play Console, opt the app into the Android TV form factor (Release → Advanced settings → Form factors) and add a TV screenshot.