# AppMint hybrid HTML API

Every native method and API a page inside a generated app can use, extracted from the template
runtime itself (`WebViewActivity.kt` and the scripts it injects), so this list is always exactly
what ships — each with a runnable example.

**Read the "Writing HTML for your app" guide first** — it covers feature detection, the async
callback convention, and the rules that a method list cannot express.

Total: **208 bridge methods** across 2 namespace(s), plus
261 helper, web-standard, Capacitor and appmintNative APIs — 469 of 469 with examples.

## `window.AndroidNFC`

### `startScan`

```js
window.AndroidNFC.startScan(): Boolean
```

*(Web NFC)* Legacy availability probe, present only when the creator enabled NFC. Real NFC work goes through the standard NDEFReader polyfill (window.WebToApk.__nfc*), which is what a page should use; this only reports whether the adapter is on.

**Example**

Tells you whether the phone's NFC is switched on. It lives on `window.AndroidNFC`, not on `window.WebToApk`, and it does not read a tag — reading is done with `NDEFReader`.

**Returns:** `true` (synchronously) when the phone has NFC and it is on; `false` when the phone has no NFC, or when NFC is off (then the app also shows a short "Please enable NFC" message). **Needs:** **Web NFC Support** turned on in Step 4 (Access) when you build; without it `window.AndroidNFC` does not exist.

Check before showing an NFC button:

```js
function nfcReady() {
  if (!window.AndroidNFC || typeof window.AndroidNFC.startScan !== 'function') return false;
  return window.AndroidNFC.startScan();
}

document.getElementById('scan-tag').addEventListener('click', async function () {
  if (!nfcReady()) { showStatus('Turn on NFC in the phone settings, then try again.'); return; }
  var reader = new NDEFReader();
  reader.addEventListener('reading', function (e) { showStatus('Tag ' + e.serialNumber); });
  await reader.scan();
  showStatus('Hold a tag to the back of the phone.');
});
```

Check right before a write: with NFC switched off the write would wait for a tap that never comes, while `startScan()` answers at once and shows the app's "Please enable NFC" message:

```js
document.getElementById('write-pass').addEventListener('click', async function () {
  if (!window.AndroidNFC || typeof window.AndroidNFC.startScan !== 'function') {
    showStatus('NFC is not available in this app.');
    return;
  }
  if (!window.AndroidNFC.startScan()) { showStatus('NFC is off, or this phone has no NFC.'); return; }
  showStatus('Tap a tag to write it...');
  try {
    await new NDEFReader().write('Visitor pass #1042');
    showStatus('Tag written.');
  } catch (err) {
    showStatus('Write failed: ' + err.message);
  }
});
```

Re-check when the user comes back and taps "I've turned it on":

```js
var retry = document.getElementById('nfc-retry');
retry.addEventListener('click', function () {
  if (!window.AndroidNFC || typeof window.AndroidNFC.startScan !== 'function') {
    showStatus('NFC is not available in this app.');
    return;
  }
  if (window.AndroidNFC.startScan()) {
    retry.hidden = true;
    document.getElementById('nfc-panel').hidden = false;
    startReading(); // your own NDEFReader.scan() code
  } else {
    showStatus('NFC is still off, or this phone has no NFC.');
  }
});
```

**Notes:** `window.AndroidNFC` exists from the first script on the page. After the user turns NFC on in settings and comes back, tag reading starts by itself.

## `window.WebToApk`

### `__badgeSupported`

```js
window.WebToApk.__badgeSupported(): Boolean
```

Whether this launcher can show a badge count (navigator.setAppBadge exists only then).

**Example**

Internal transport behind the presence of `navigator.setAppBadge`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `true` when this phone's launcher shows badge counts. **Needs:** nothing to switch on.

**Use the public API:**

```js
if ('setAppBadge' in navigator) navigator.setAppBadge(unreadCount);
```

Show the "badge on app icon" setting only on phones whose launcher can show a count:

```js
function renderBadgeSetting() {
  const row = document.getElementById('badge-setting');
  // setAppBadge exists only when this launcher shows counts
  row.hidden = !('setAppBadge' in navigator);
}
renderBadgeSetting();
```

Clear the count when the user opens the inbox, on any launcher that has one:

```js
function openInbox() {
  showInbox();
  if ('clearAppBadge' in navigator) {
    navigator.clearAppBadge().catch(function (e) {
      console.warn('badge not cleared', e.name);   // NotAllowedError
    });
  }
}
```

**Notes:** The Badging API is installed only where the launcher can show a count (Samsung, Huawei, Xiaomi, OPPO, Sony, …); on Pixel-style launchers it is absent, like in a browser without badging.

### `__clipboardRead`

```js
window.WebToApk.__clipboardRead(): String
```

navigator.clipboard.read(): `{parts:[{type, text|base64}]}`, or `{denied:true}` for a page that is not one of this app's own origins.

**Example**

Internal transport behind `navigator.clipboard.read()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `{parts:[{type, text|base64}]}`, or `{denied:true}` on a page that is not one of the app's own origins. **Needs:** nothing to switch on.

**Use the public API:**

```js
canvas.toBlob(async (png) => {
  await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
});
const [item] = await navigator.clipboard.read();
if (item && item.types.includes('image/png')) preview(await item.getType('image/png'));
```

Paste formatted text into an editor, keeping the formatting when the clipboard has HTML:

```js
async function pasteRich(editor) {
  try {
    var items = await navigator.clipboard.read();
    if (!items.length) { showMessage('The clipboard is empty'); return; }
    var item = items[0];
    if (item.types.includes('text/html')) {
      editor.insertHtml(sanitize(await (await item.getType('text/html')).text()));
    } else if (item.types.includes('text/plain')) {
      editor.insertText(await (await item.getType('text/plain')).text());
    }
  } catch (e) {
    showMessage('Could not read the clipboard');
  }
}
```

Paste any copied picture (PNG, JPEG, WebP…) as a profile photo, and tell a refused read apart:

```js
document.getElementById('paste-photo').onclick = async function () {
  try {
    var items = await navigator.clipboard.read();
    var item = items[0];
    var type = item && item.types.find(function (t) { return t.indexOf('image/') === 0; });
    if (!type) { showMessage('Copy a picture first'); return; }
    uploadAvatar(await item.getType(type));   // a Blob of that type
  } catch (e) {
    if (e.name === 'NotAllowedError') showMessage('This page cannot read the clipboard');
    else showMessage('Could not paste the picture');
  }
};
```

**Notes:** Android 12+ shows its own "pasted from your clipboard" message when an app reads the clipboard.

### `__clipboardWrite`

```js
window.WebToApk.__clipboardWrite(partsJson: String): Boolean
```

navigator.clipboard.write(): [partsJson] is `[{type, text}|{type, base64}]` for one ClipboardItem — text/plain, text/html, or an image that is copied as a content URI.

**Example**

Internal transport behind `navigator.clipboard.write([ClipboardItem])`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `true` when the clip was placed. **Needs:** nothing to switch on.

**Use the public API:**

```js
canvas.toBlob(async (png) => {
  await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
});
const [item] = await navigator.clipboard.read();
if (item && item.types.includes('image/png')) preview(await item.getType('image/png'));
```

Copy formatted text and its plain version in one item, so rich editors get the table and plain fields get the text:

```js
async function copyTable(table) {
  if (!(navigator.clipboard && navigator.clipboard.write && window.ClipboardItem)) return;
  try {
    await navigator.clipboard.write([new ClipboardItem({
      'text/html': new Blob([table.outerHTML], { type: 'text/html' }),
      'text/plain': new Blob([table.innerText], { type: 'text/plain' })
    })]);
    showMessage('Table copied');
  } catch (e) {
    showMessage('Could not copy (' + e.name + ')');   // NotAllowedError when the write fails
  }
}
```

Copy an image the page shows, such as a QR code, so it can be pasted into a chat:

```js
async function copyImage(img) {
  try {
    var blob = await (await fetch(img.src)).blob();   // an image/* type, up to 16 MB
    await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
    showMessage('Image copied');
  } catch (e) {
    showMessage('Could not copy the image');
  }
}
```

**Notes:** Text, HTML and one image per item. The image is copied as a content URI through the app's FileProvider, so it pastes into WhatsApp, Gmail or Keep.

### `__closeNotification`

```js
window.WebToApk.__closeNotification(tag: String): Boolean
```

Notification.close(): removes a notification the page posted, by its tag.

**Example**

Internal plumbing behind `Notification.prototype.close()` — pages use `new Notification(...)` and call `close()` on it. It removes a notification from the shade by its tag.

**Returns:** `true` when the remove request was made (also when nothing had that tag); `false` on an internal error. **Needs:** nothing.

**Use the public API:**

```js
let uploadNote = null;

function showUploading() {
  if (!('Notification' in window) || Notification.permission !== 'granted') return;
  uploadNote = new Notification('Uploading…', { body: 'Keep the app open', tag: 'upload', silent: true });
  uploadNote.onclose = function () { uploadNote = null; };
}

function uploadFinished() {
  if (uploadNote) uploadNote.close();   // removes it from the notification shade
}
```

Open the right chat when the notification is tapped, then remove it from the shade:

```js
function notifyNewMessage(msg) {
  if (!('Notification' in window) || Notification.permission !== 'granted') return;
  const n = new Notification(msg.from, {
    body: msg.text,
    tag: 'chat-' + msg.threadId,          // one notification per chat
    data: { threadId: msg.threadId }
  });
  n.onclick = function () {
    openThread(n.data.threadId);
    n.close();
  };
}
```

Show a short confirmation that removes itself after five seconds:

```js
function confirmCopied() {
  if (!('Notification' in window) || Notification.permission !== 'granted') return;
  const n = new Notification('Link copied', { body: 'Paste it anywhere', tag: 'copied', silent: true });
  setTimeout(function () { n.close(); }, 5000);
}
```

**Notes:** do not call `WebToApk.__closeNotification` directly; the `__` methods may change without notice. `close()` fires the notification's `close` event.

### `__dlAbort`

```js
window.WebToApk.__dlAbort(token: String)
```

Drops download [token] (the page's read failed or it gave up).

**Example**

Documented together with `WebToApk.__dlBegin` — the example there shows this one too.

Internal transport behind `<a download>`, Blob downloads, `window.open(blobUrl)` and `AppMint.saveBlob` / `AppMint.downloadFile`: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the `__` methods may change without notice.

**Returns:** `__dlBegin(name, type, size)` returns a token (or `""` when the phone has no room); `__dlChunk(token, base64)` returns `false` if a slice could not be written; `__dlEnd(token, action)` finishes (`save` = Downloads, `open` = show it, `share`, `copy`). **Needs:** nothing.

**Use the public API:**

```js
const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"
```

Export a drawing from a canvas through the same route:

```js
canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');
```

Save a JSON backup the user can keep, with a message when it is done:

```js
function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}
```

**Notes:** Implemented in the app shell (ShellDownloads.kt).

### `__dlBegin`

```js
window.WebToApk.__dlBegin(name: String, type: String, size: Double): String
```

Opens a download of [size] bytes named [name] ([type] = its MIME type) that the page will stream with __dlChunk. Returns a token, or "" when there is no room.

**Example**

Internal transport behind `<a download>`, Blob downloads, `window.open(blobUrl)` and `AppMint.saveBlob` / `AppMint.downloadFile`: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the `__` methods may change without notice.

**Returns:** `__dlBegin(name, type, size)` returns a token (or `""` when the phone has no room); `__dlChunk(token, base64)` returns `false` if a slice could not be written; `__dlEnd(token, action)` finishes (`save` = Downloads, `open` = show it, `share`, `copy`). **Needs:** nothing.

**Use the public API:**

```js
const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"
```

Export a drawing from a canvas through the same route:

```js
canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');
```

Save a JSON backup the user can keep, with a message when it is done:

```js
function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}
```

**Notes:** Implemented in the app shell (ShellDownloads.kt).

### `__dlChunk`

```js
window.WebToApk.__dlChunk(token: String, base64: String): Boolean
```

One base64 slice of the download [token]; false when the transfer failed.

**Example**

Documented together with `WebToApk.__dlBegin` — the example there shows this one too.

Internal transport behind `<a download>`, Blob downloads, `window.open(blobUrl)` and `AppMint.saveBlob` / `AppMint.downloadFile`: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the `__` methods may change without notice.

**Returns:** `__dlBegin(name, type, size)` returns a token (or `""` when the phone has no room); `__dlChunk(token, base64)` returns `false` if a slice could not be written; `__dlEnd(token, action)` finishes (`save` = Downloads, `open` = show it, `share`, `copy`). **Needs:** nothing.

**Use the public API:**

```js
const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"
```

Export a drawing from a canvas through the same route:

```js
canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');
```

Save a JSON backup the user can keep, with a message when it is done:

```js
function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}
```

**Notes:** Implemented in the app shell (ShellDownloads.kt).

### `__dlEnd`

```js
window.WebToApk.__dlEnd(token: String, action: String): Boolean
```

Finishes download [token] and acts on it: [action] "save" (Downloads, with the "Downloaded · Open" bar and notification), "open" (PDF viewer or the phone's app for the type), "share" (share sheet) or "copy" (image to the clipboard).

**Example**

Documented together with `WebToApk.__dlBegin` — the example there shows this one too.

Internal transport behind `<a download>`, Blob downloads, `window.open(blobUrl)` and `AppMint.saveBlob` / `AppMint.downloadFile`: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the `__` methods may change without notice.

**Returns:** `__dlBegin(name, type, size)` returns a token (or `""` when the phone has no room); `__dlChunk(token, base64)` returns `false` if a slice could not be written; `__dlEnd(token, action)` finishes (`save` = Downloads, `open` = show it, `share`, `copy`). **Needs:** nothing.

**Use the public API:**

```js
const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"
```

Export a drawing from a canvas through the same route:

```js
canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');
```

Save a JSON backup the user can keep, with a message when it is done:

```js
function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}
```

**Notes:** Implemented in the app shell (ShellDownloads.kt).

### `__fsChild`

```js
window.WebToApk.__fsChild(uri: String, name: String, kind: String, create: Boolean): String
```

getFileHandle / getDirectoryHandle (with `create`).

**Example**

Internal transport behind `getFileHandle(name, {create})` and `getDirectoryHandle(name, {create})`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok, kind, name, uri, canWrite}` or `{ok:false, error}` (`NotFoundError`, `TypeMismatchError`, `NoModificationAllowedError`). **Needs:** a folder from `showDirectoryPicker()`.

**Use the public API:**

```js
const dir = await showDirectoryPicker();
const photos = await dir.getDirectoryHandle('photos', { create: true });
const file = await photos.getFileHandle('list.json', { create: true });
```

Check whether a file already exists in the folder before creating it:

```js
async function exists(dir, name) {
  try {
    await dir.getFileHandle(name);                     // no {create} = look up only
    return true;
  } catch (e) {
    if (e.name === 'NotFoundError') return false;
    if (e.name === 'TypeMismatchError') return true;   // a folder already has that name
    throw e;
  }
}
```

Save the app settings as JSON into a "config" sub-folder, creating both as needed:

```js
async function saveSettings(root, settings) {
  const config = await root.getDirectoryHandle('config', { create: true });
  const file = await config.getFileHandle('settings.json', { create: true });
  const w = await file.createWritable();
  await w.write(JSON.stringify(settings, null, 2));
  await w.close();
}
// root = await showDirectoryPicker()  (from a button tap)
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsFile`

```js
window.WebToApk.__fsFile(uri: String): String
```

getFile(): `{ok, name, type, size, lastModified, url}` (the body streams from url).

**Example**

Internal transport behind `FileSystemFileHandle.getFile()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok, name, type, size, lastModified, url}` — the body streams from `url` (the app's own origin, with ranges) — or `{ok:false, error}` (`NotFoundError`, `TypeMismatchError`, `foreign_origin`). **Needs:** a handle from one of the pickers.

**Use the public API:**

```js
const [handle] = await showOpenFilePicker();
const file = await handle.getFile();
console.log(file.name, file.size, await file.text());
```

Show a picked image on the page without reading it into a string:

```js
document.querySelector('#choose').addEventListener('click', async function () {
  const [handle] = await showOpenFilePicker({ types: [{ accept: { 'image/*': [] } }] });
  const file = await handle.getFile();                    // a real File, streamed from the app
  const img = document.querySelector('#preview');
  if (img.src) URL.revokeObjectURL(img.src);
  img.src = URL.createObjectURL(file);
  img.alt = file.name + ' (' + file.type + ')';
});
```

Re-read a file you opened earlier and notice when it has changed or been deleted:

```js
async function reloadIfChanged(handle, lastSeen) {
  try {
    const file = await handle.getFile();
    if (file.lastModified === lastSeen) return null;      // unchanged
    return { data: JSON.parse(await file.text()), lastModified: file.lastModified };
  } catch (e) {
    if (e.name === 'NotFoundError') alert(handle.name + ' was moved or deleted.');
    else throw e;
    return null;
  }
}
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsList`

```js
window.WebToApk.__fsList(uri: String): String
```

A directory handle's entries: `{ok, entries:[{kind, name, uri, canWrite}]}`.

**Example**

Internal transport behind iterating a `FileSystemDirectoryHandle` (`entries()`, `keys()`, `values()`, `for await`). Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok, entries:[{kind, name, uri, canWrite}]}` or `{ok:false, error}`. **Needs:** a folder from `showDirectoryPicker()`.

**Use the public API:**

```js
const dir = await showDirectoryPicker();
for await (const [name, entry] of dir.entries()) console.log(entry.kind, name);
```

Count the files in a folder and all its sub-folders:

```js
async function countFiles(dir) {
  let n = 0;
  for await (const entry of dir.values()) {
    if (entry.kind === 'file') n++;
    else n += await countFiles(entry);                 // entry is a FileSystemDirectoryHandle
  }
  return n;
}
document.querySelector('#scan').addEventListener('click', async function () {
  const dir = await showDirectoryPicker();
  console.log(dir.name, 'holds', await countFiles(dir), 'files');
});
```

Build a sorted list of the photos in a folder the user chose:

```js
document.querySelector('#open-album').addEventListener('click', async function () {
  const dir = await showDirectoryPicker({ startIn: 'pictures' });
  const names = [];
  for await (const name of dir.keys()) {
    if (/\.(jpe?g|png|webp)$/i.test(name)) names.push(name);
  }
  names.sort(function (a, b) { return a.localeCompare(b); });
  renderAlbum(dir, names);                             // dir.getFileHandle(name) opens each one
});
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsPermission`

```js
window.WebToApk.__fsPermission(rootUri: String, uri: String): String
```

queryPermission() / requestPermission(): `{ok, state:'granted'|'prompt'}` for a handle whose picked root is [rootUri] — 'granted' while the app holds that pick's grant, across relaunches (the grant is persisted; a handle comes back from IndexedDB).

**Example**

Internal transport behind `FileSystemHandle.queryPermission()` and `requestPermission()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok:true, state:'granted'}` while the app holds the grant of the picked folder or file the handle comes from (kept across restarts), `{ok:true, state:'prompt'}` once it is gone, or `{ok:false, error:'foreign_origin'}`. A read-only file answers `'denied'` for `{mode:'readwrite'}` in the page, without a call. **Needs:** a handle from one of the pickers, also one read back from IndexedDB.

**Use the public API:**

```js
const dir = await kvGet('backupDir');                 // a handle stored in IndexedDB earlier
if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') {
  const f = await dir.getFileHandle('backup.json', { create: true });
  const w = await f.createWritable();
  await w.write(JSON.stringify(state));
  await w.close();
}
```

Ask again from a tap when the grant is gone — the folder picker opens at that folder, and only the same folder grants:

```js
document.getElementById('reconnect').addEventListener('click', async () => {
  const dir = await kvGet('backupDir');
  const state = await dir.requestPermission({ mode: 'readwrite' });   // 'granted' | 'denied'
  status.textContent = state === 'granted' ? 'Backups resumed' : 'Choose a backup folder';
});
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt). See `showOpenFilePicker` for the whole File System Access API.

### `__fsPick`

```js
window.WebToApk.__fsPick(requestId: String, kind: String, optionsJson: String): String
```

Opens the [kind] picker (open | save | directory); handles arrive as `appmint:fs`.

**Example**

Internal transport behind `showOpenFilePicker()`, `showSaveFilePicker()` and `showDirectoryPicker()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `{ok:true}` once the picker is on screen, or `{ok:false, error}` (`NotAllowedError` when one is already open, `foreign_origin`). The handles arrive as the `appmint:fs` event `{requestId, handles:[{kind, name, uri, canWrite}]}` or `{requestId, error:'cancelled'}`. **Needs:** nothing to switch on.

**Use the public API:**

```js
const [handle] = await showOpenFilePicker({ types: [{ accept: { 'image/*': ['.png', '.jpg'] } }] });
const file = await handle.getFile();
```

Save a report with a suggested name, from a button tap (the pickers need a user gesture):

```js
document.querySelector('#export').addEventListener('click', async function () {
  try {
    const handle = await showSaveFilePicker({
      suggestedName: 'report.csv',
      types: [{ description: 'CSV', accept: { 'text/csv': ['.csv'] } }]
    });
    const w = await handle.createWritable();
    await w.write(buildCsv());
    await w.close();
  } catch (e) {
    if (e.name !== 'AbortError') alert('Export failed: ' + e.message);   // AbortError = cancelled
  }
});
```

Let the user pick several photos at once, starting in the Pictures folder:

```js
document.querySelector('#add-photos').addEventListener('click', async function () {
  let handles;
  try {
    handles = await showOpenFilePicker({
      multiple: true,
      startIn: 'pictures',
      types: [{ accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
    });
  } catch (e) { return; }                                 // cancelled or already open
  for (const h of handles) addToGallery(await h.getFile());
});
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt). The same page code works unchanged in desktop Chrome.

### `__fsRemove`

```js
window.WebToApk.__fsRemove(uri: String, name: String, recursive: Boolean): String
```

removeEntry(name, {recursive}).

**Example**

Internal transport behind `FileSystemDirectoryHandle.removeEntry(name, {recursive})`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}` (`NotFoundError`, `InvalidModificationError` for a non-empty folder without `recursive`). **Needs:** a folder from `showDirectoryPicker()`.

**Use the public API:**

```js
const dir = await showDirectoryPicker();
await dir.removeEntry('old-drafts', { recursive: true });
```

Delete one file and treat "already gone" as done:

```js
async function deleteFile(dir, name) {
  try {
    await dir.removeEntry(name);
  } catch (e) {
    if (e.name !== 'NotFoundError') throw e;   // NotFoundError: nothing left to delete
  }
}
```

Remove a folder, asking first only when it still has something in it:

```js
async function removeFolder(dir, name) {
  try {
    await dir.removeEntry(name);                          // succeeds for an empty folder
  } catch (e) {
    if (e.name !== 'InvalidModificationError') throw e;
    if (!confirm('"' + name + '" is not empty. Delete it and everything inside?')) return false;
    await dir.removeEntry(name, { recursive: true });
  }
  return true;
}
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableAbort`

```js
window.WebToApk.__fsWritableAbort(token: String)
```

abort(): the swap is dropped; the document is untouched.

**Example**

Internal transport behind `FileSystemWritableFileStream.abort()` — the swap copy is dropped and the file stays as it was. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** nothing. **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable();
try { await w.write(await buildExport()); await w.close(); }
catch (e) { await w.abort(); }
```

Let the user cancel a long export; nothing half-written reaches the file:

```js
let cancelled = false;
document.getElementById('cancel').onclick = function () { cancelled = true; };

async function exportAll(handle, pages) {
  const w = await handle.createWritable();
  for (const page of pages) {
    if (cancelled) { await w.abort(); return 'cancelled'; }   // the file keeps its old content
    await w.write(await renderPage(page));
  }
  await w.close();
  return 'saved';
}
```

Download straight into a file with `pipeTo`; stopping the download aborts the write:

```js
const ctl = new AbortController();
document.getElementById('stop').onclick = function () { ctl.abort(); };

async function downloadInto(handle, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error('HTTP ' + res.status);
  const w = await handle.createWritable();
  try {
    await res.body.pipeTo(w, { signal: ctl.signal });     // closes the file when the body ends
    return 'saved';
  } catch (e) {
    return e.name === 'AbortError' ? 'stopped, file unchanged' : 'failed: ' + e.message;
  }
}
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableClose`

```js
window.WebToApk.__fsWritableClose(token: String): String
```

close(): the swap replaces the document.

**Example**

Internal transport behind `FileSystemWritableFileStream.close()` — the swap copy replaces the file. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}` (`NoModificationAllowedError`, `InvalidModificationError` when the file's app cannot shorten it). **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable();
await w.write(json);
await w.close();   // nothing changes on disk before this
```

Tell the user why a save did not go through (`close()` is where it can fail):

```js
async function saveText(handle, text) {
  const w = await handle.createWritable();
  await w.write(text);
  try {
    await w.close();
    return true;
  } catch (e) {
    if (e.name === 'InvalidModificationError') alert('This file\'s app cannot make it shorter. Save a copy instead.');
    else if (e.name === 'NoModificationAllowedError') alert('This file cannot be changed.');
    else alert('Save failed: ' + e.message);
    return false;
  }
}
```

Write a file in several pieces; readers still see the old content until the one `close()`:

```js
async function saveRows(handle, rows) {
  const w = await handle.createWritable();
  await w.write('id,name,total\n');
  for (const r of rows) await w.write(r.id + ',' + r.name + ',' + r.total + '\n');
  console.log('before close:', (await handle.getFile()).size, 'bytes (old file)');
  await w.close();
  console.log('after close:', (await handle.getFile()).size, 'bytes');
}
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableOpen`

```js
window.WebToApk.__fsWritableOpen(uri: String, keepExisting: Boolean): String
```

createWritable(): a swap file, copied from the file when [keepExisting].

**Example**

Internal transport behind `FileSystemFileHandle.createWritable({keepExistingData})`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok, token}` for a private swap copy, or `{ok:false, error}` (`NoModificationAllowedError` for a read-only file). **Needs:** a handle from one of the pickers.

**Use the public API:**

```js
const handle = await showSaveFilePicker({ suggestedName: 'report.csv' });
const w = await handle.createWritable({ keepExistingData: false });
await w.write('a,b\n1,2\n');
await w.close();
```

Append a line to the end of an existing log file, keeping what is already in it:

```js
async function appendLine(handle, line) {
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);                                     // write after the old content
  await w.write(new Date().toISOString() + ' ' + line + '\n');
  await w.close();                                        // only now does the file change
}
```

Save a canvas as a PNG, and leave the file untouched if anything goes wrong:

```js
document.querySelector('#save-png').addEventListener('click', async function () {
  const handle = await showSaveFilePicker({
    suggestedName: 'drawing.png',
    types: [{ accept: { 'image/png': ['.png'] } }]
  });
  const blob = await new Promise(function (r) { document.querySelector('canvas').toBlob(r, 'image/png'); });
  const w = await handle.createWritable();
  try {
    await w.write(blob);
    await w.close();
  } catch (e) {
    try { await w.abort(); } catch (x) {}                 // nothing reaches the file before close()
    alert('Could not save: ' + e.message);
  }
});
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableTruncate`

```js
window.WebToApk.__fsWritableTruncate(token: String, size: Double): String
```

truncate(size) on a writable stream.

**Example**

Internal transport behind `FileSystemWritableFileStream.truncate(size)`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}`. **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable({ keepExistingData: true });
await w.truncate(0);
await w.write('fresh start');
await w.close();
```

Keep only the first 1,000 bytes of a file and cut everything after them:

```js
async function keepFirstKilobyte(handle) {
  const w = await handle.createWritable({ keepExistingData: true });
  await w.truncate(1000);   // a file shorter than this grows instead (see the next example)
  await w.close();
}
```

Reserve space: truncating to a larger size grows the file, and the new bytes read back as zeros:

```js
async function makeEmptySlotFile(handle, slots, slotSize) {
  const w = await handle.createWritable();
  await w.truncate(slots * slotSize);                  // e.g. 100 slots of 256 bytes, all zero
  await w.write({ type: 'write', position: 0, data: new Uint8Array([1, 0, 0, 0]) });
  await w.close();
  console.log('file is now', (await handle.getFile()).size, 'bytes');
}
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt).

### `__fsWritableWrite`

```js
window.WebToApk.__fsWritableWrite(token: String, position: Double, base64: String): String
```

One chunk of a writable stream's write(), at [position].

**Example**

Internal transport behind `FileSystemWritableFileStream.write()` — one chunk at a position in the swap copy. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON `{ok:true}` or `{ok:false, error}`. **Needs:** an open writable stream.

**Use the public API:**

```js
const w = await handle.createWritable({ keepExistingData: true });
await w.write({ type: 'write', position: 0, data: new Blob(['HEADER\n']) });
await w.close();
```

Add a line to the end of a log file the user picked (a file picker needs a tap):

```js
document.getElementById('addLine').addEventListener('click', async () => {
  const [handle] = await showOpenFilePicker({
    types: [{ description: 'Text', accept: { 'text/plain': ['.txt', '.log'] } }]
  });
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);                                   // start writing after the old content
  await w.write(new Date().toISOString() + ' checked in\n');
  await w.close();
});
```

Save a canvas drawing as a new PNG with "Save as" (a Blob is written as its raw bytes):

```js
document.getElementById('saveDrawing').addEventListener('click', async () => {
  const handle = await showSaveFilePicker({
    suggestedName: 'drawing.png',
    types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }]
  });
  const blob = await new Promise(function (ok) { document.querySelector('canvas').toBlob(ok, 'image/png'); });
  const w = await handle.createWritable();
  await w.write(blob);
  await w.close();
});
```

**Notes:** Implemented in the app shell (FileSystemAccess.kt). Large data is sent in chunks.

### `__idlePermission`

```js
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:**

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

Feature-detect, and treat a refusal as "not allowed":

```js
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:

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

```js
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:**

```js
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:

```js
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:

```js
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).

### `__lockOrientation`

```js
window.WebToApk.__lockOrientation(type: String): String
```

screen.orientation.lock(type): "ok", "type" for a string that is not an OrientationLockType, or "unsupported" (Android TV is always landscape).

**Example**

Internal transport behind `screen.orientation.lock(type)`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `'ok'`, `'type'` for a string that is not an orientation lock type, or `'unsupported'` (Android TV). **Needs:** nothing to switch on.

**Use the public API:**

```js
document.getElementById('play').addEventListener('click', async () => {
  try { await screen.orientation.lock('landscape'); } catch (e) { /* NotSupportedError on TV */ }
});
document.getElementById('exit').addEventListener('click', () => screen.orientation.unlock());
```

Tell a bad lock type apart from a device that cannot rotate:

```js
async function lockTo(type) {
  if (!screen.orientation || typeof screen.orientation.lock !== 'function') return 'unsupported';
  try {
    await screen.orientation.lock(type);
    return 'ok';
  } catch (e) {
    if (e instanceof TypeError) return 'bad-type';   // not an OrientationLockType string
    return 'unsupported';                            // NotSupportedError (Android TV)
  }
}

lockTo('portrait-primary').then((r) => console.log('orientation lock:', r));
```

Freeze the screen the way the user is holding it while they sign, then give rotation back:

```js
async function startSigning() {
  const current = screen.orientation.type;   // e.g. 'landscape-primary'
  try { await screen.orientation.lock(current); } catch (e) { /* keep going unlocked */ }
  showSignaturePad();
}

function finishSigning() {
  hideSignaturePad();
  screen.orientation.unlock();
}
```

**Notes:** Implemented in the app shell (WebApiPolyfills.kt). The same page code works unchanged in Chrome.

### `__nfcCancelWrite`

```js
window.WebToApk.__nfcCancelWrite()
```

Cancels the waiting write/lock (the polyfill calls it when the operation's `signal` aborts).

**Example**

Drops a queued NFC write or lock so the next tag tap is read normally. Pages cancel through the web standard — an `AbortController` `signal` — and the `NDEFReader` polyfill calls this for them.

**Returns:** nothing. **Needs:** **Web NFC Support** turned on in Step 4 (Access) when you build.

The standard way, which the app honours — the waiting `write()` rejects with `AbortError`:

```js
var controller = null;

async function writeWithCancel(text) {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  controller = new AbortController();
  showStatus('Tap a tag to write it... (or press Cancel)');
  try {
    await new NDEFReader().write(text, { signal: controller.signal });
    showStatus('Tag written.');
  } catch (err) {
    showStatus(err.name === 'AbortError' ? 'Cancelled.' : 'Write failed: ' + err.message);
  }
}

document.getElementById('cancel').addEventListener('click', function () {
  if (controller) controller.abort();
});
```

Give up by itself when no tag arrives in time; a timer that aborts the signal cancels the same way:

```js
async function writeWithTimeout(url, ms) {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  var controller = new AbortController();
  var timer = setTimeout(function () { controller.abort(); }, ms);
  showStatus('Tap a tag within ' + Math.round(ms / 1000) + ' seconds...');
  try {
    await new NDEFReader().write({ records: [{ recordType: 'url', data: url }] }, { signal: controller.signal });
    showStatus('Tag written.');
  } catch (err) {
    showStatus(err.name === 'AbortError' ? 'Timed out. Tap Write to try again.' : 'Write failed: ' + err.message);
  } finally {
    clearTimeout(timer);
  }
}
```

Cancel a waiting lock (or write) when the user leaves the screen, so a later tap does not change a tag:

```js
var pendingTagOp = null;

window.addEventListener('hashchange', function () {
  if (pendingTagOp) { pendingTagOp.abort(); pendingTagOp = null; }
});

document.getElementById('lock').addEventListener('click', function () {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  if (pendingTagOp) pendingTagOp.abort();
  var controller = pendingTagOp = new AbortController();
  showStatus('Tap the tag to lock it...');
  new NDEFReader().makeReadOnly({ signal: controller.signal })
    .then(function () { showStatus('Tag locked.'); })
    .catch(function (err) { if (err.name !== 'AbortError') showStatus('Lock failed: ' + err.message); })
    .finally(function () { if (pendingTagOp === controller) pendingTagOp = null; });
});
```

**Notes:** Do not call `WebToApk.__nfcCancelWrite()` yourself: called directly it drops the native wait but cannot settle the page's Promise. The `signal` does both. Cancelling also drops a queued `makeReadOnly()`.

### `__nfcMakeReadOnly`

```js
window.WebToApk.__nfcMakeReadOnly(): String
```

Queues a permanent lock (`NDEFReader.makeReadOnly()`) for the next tag tap; returns its op id, "" when NFC is off in this app.

**Example**

Internal transport behind `NDEFReader.makeReadOnly()`. Pages use `new NDEFReader().makeReadOnly()`; never call this directly.

**Returns:** (through `makeReadOnly`) a Promise that resolves after the user taps a tag and it is locked, or rejects with a `DOMException`. **Needs:** **Web NFC Support** turned on in Step 4 (Access) when you build.

Locking is permanent. Write the tag first, then lock it, and ask the user before both:

```js
document.getElementById('publish-tag').addEventListener('click', async function () {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  if (!confirm('Write and lock this tag? A locked tag can never be changed.')) return;
  var reader = new NDEFReader();
  try {
    showStatus('Tap the tag to write it...');
    await reader.write({ records: [{ recordType: 'url', data: 'https://example.com/item/42' }] });
    showStatus('Now tap the same tag again to lock it...');
    await reader.makeReadOnly();
    showStatus('Tag written and locked.');
  } catch (err) {
    showStatus('Failed: ' + err.message);
  }
});
```

Lock a tag that is already written, and give up if no tag arrives within 20 seconds:

```js
async function lockTag() {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  if (!confirm('Lock this tag for good? It can never be changed again.')) return;
  var controller = new AbortController();
  var timer = setTimeout(function () { controller.abort(); }, 20000);
  showStatus('Tap the tag to lock it...');
  try {
    await new NDEFReader().makeReadOnly({ signal: controller.signal });
    showStatus('Tag locked.');
  } catch (err) {
    if (err.name === 'AbortError') showStatus('No tag was tapped. Try again when ready.');
    else if (err.name === 'NotAllowedError') showStatus('This kind of tag cannot be locked.');
    else showStatus('Lock failed: ' + err.message);
  } finally {
    clearTimeout(timer);
  }
}
```

Read a tag first and only offer to lock it when it holds the expected link:

```js
async function checkThenLock(expectedUrl) {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  var reader = new NDEFReader();
  var scanning = new AbortController();
  var lockBtn = document.getElementById('lock-btn');
  reader.onreading = function (e) {
    var rec = e.message.records.find(function (r) { return r.recordType === 'url'; });
    var url = rec ? new TextDecoder().decode(rec.data) : null;
    showStatus(url ? 'Tag points to ' + url : 'This tag holds no link.');
    lockBtn.disabled = url !== expectedUrl;
  };
  await reader.scan({ signal: scanning.signal });
  showStatus('Hold the tag to the phone to check it.');

  lockBtn.onclick = async function () {
    if (!confirm('Lock this tag? It can never be changed again.')) return;
    scanning.abort();
    showStatus('Tap the tag again to lock it...');
    try { await reader.makeReadOnly(); showStatus('Tag locked.'); }
    catch (err) { showStatus('Lock failed: ' + err.message); }
  };
}
```

**Notes:** Each step needs its own tap. The call returns an operation id and the result arrives through `window.__appmintNfcDone`. Rejections: `NotSupportedError` "Tag does not support NDEF", `NotAllowedError` "This tag cannot be made read-only" (many tags cannot be locked), `NetworkError` when the tag moves away. Starting a `write()` while a lock waits replaces the lock, which then rejects with `AbortError`. A `signal` passed to `makeReadOnly({ signal })` cancels the wait.

### `__nfcState`

```js
window.WebToApk.__nfcState(): String
```

"on", "off" (NFC switched off in the phone's settings) or "none" (no NFC, or not enabled in this app) — what `NDEFReader.scan()` checks first.

**Example**

Internal check behind `NDEFReader.scan()`: whether this app can use NFC right now. Pages call `scan()` and read its rejection instead.

**Returns:** `'on'`, `'off'` (NFC is switched off in the phone's settings) or `'none'` (the phone has no NFC, or the app was built without Web NFC Support). **Needs:** **Web NFC Support** turned on in Step 4 (Access) when you build.

The standard way — `scan()` rejects with the same answer:

```js
async function startReading() {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  try {
    await new NDEFReader().scan();
    showStatus('Hold a tag to the back of the phone.');
  } catch (err) {
    if (err.name === 'NotReadableError') showStatus('Turn on NFC in the phone settings, then try again.');
    else if (err.name === 'NotSupportedError') showStatus('This phone has no NFC.');
    else showStatus('NFC error: ' + err.message);
  }
}
```

Check once when the screen opens, to choose between the NFC panel, a "turn on NFC" hint and hiding NFC altogether. Aborting the probe's scan right away leaves nothing running:

```js
async function nfcStatus() {
  if (!('NDEFReader' in window)) return 'none';
  var probe = new AbortController();
  try {
    await new NDEFReader().scan({ signal: probe.signal });
    return 'on';
  } catch (err) {
    return err.name === 'NotReadableError' ? 'off' : 'none';
  } finally {
    probe.abort();
  }
}

async function refreshNfcUi() {
  var state = await nfcStatus();
  document.getElementById('nfc-panel').hidden = state !== 'on';
  document.getElementById('nfc-off-hint').hidden = state !== 'off';
}

refreshNfcUi();
document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'visible') refreshNfcUi(); // back from the phone settings
});
```

Read tags only while one screen is open, and tell an empty or unreadable tag apart from NFC being off:

```js
var tagScreen = null;

async function openTagScreen() {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  tagScreen = new AbortController();
  var reader = new NDEFReader();
  reader.onreading = function (e) {
    var n = e.message.records.length;
    showStatus(n ? 'Tag ' + e.serialNumber + ' holds ' + n + ' record(s).' : 'This tag is empty.');
  };
  reader.onreadingerror = function () { showStatus('This tag cannot be read. Try another one.'); };
  try {
    await reader.scan({ signal: tagScreen.signal });
    showStatus('Hold a tag to the back of the phone.');
  } catch (err) {
    showStatus(err.name === 'NotReadableError' ? 'NFC is off. Turn it on in the phone settings.' : 'NFC error: ' + err.message);
  }
}

function closeTagScreen() {
  if (tagScreen) { tagScreen.abort(); tagScreen = null; }
}
```

**Notes:** Plumbing for the polyfill; use `scan()`. `window.AndroidNFC.startScan()` answers the same question as a boolean and shows a "Please enable NFC" message when NFC is off.

### `__nfcWrite`

```js
window.WebToApk.__nfcWrite(messageJson: String): String
```

Queues [messageJson] (`{records:[…], overwrite}`, built by `NDEFReader.write()`) for the next tag tap and returns its op id; "" when NFC is not enabled in this app.

**Example**

Internal transport behind `NDEFReader.write(message)`. Pages use `new NDEFReader().write(...)`; never call this directly.

**Returns:** (through `write`) a Promise that resolves after the user taps a tag and it is written, or rejects with a `DOMException`. **Needs:** **Web NFC Support** turned on in Step 4 (Access) when you build.

```js
document.getElementById('write-table-tag').addEventListener('click', async function () {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  showStatus('Tap a tag to write it...');
  try {
    await new NDEFReader().write({
      records: [
        { recordType: 'url', data: 'https://example.com/menu' },
        { recordType: 'text', data: 'Table 12', lang: 'en' },
        { recordType: 'mime', mediaType: 'application/json',
          data: new TextEncoder().encode(JSON.stringify({ table: 12 })) }
      ]
    });
    showStatus('Tag written.');
  } catch (err) {
    showStatus('Write failed: ' + err.message);
  }
});
```

Write only to a blank tag, so a tag that already holds data is never replaced by mistake:

```js
async function writeToBlankTag(url) {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  showStatus('Tap a blank tag...');
  try {
    await new NDEFReader().write(
      { records: [{ recordType: 'url', data: url }] },
      { overwrite: false }
    );
    showStatus('Tag written.');
  } catch (err) {
    if (err.name === 'NotAllowedError') showStatus('That tag already holds data or is locked. Use a blank tag.');
    else if (err.name === 'NetworkError') showStatus('Hold the tag still until it is written.');
    else showStatus('Write failed: ' + err.message);
  }
}
```

Write a smart poster, a link together with its title, as one record:

```js
document.getElementById('write-poster').addEventListener('click', async function () {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  showStatus('Tap a tag to write the poster...');
  try {
    await new NDEFReader().write({
      records: [{
        recordType: 'smart-poster',
        data: { records: [
          { recordType: 'url', data: 'https://example.com/events/spring-fair' },
          { recordType: 'text', data: 'Spring Fair tickets', lang: 'en' }
        ] }
      }]
    });
    showStatus('Poster tag written.');
  } catch (err) {
    showStatus(err.name === 'NotSupportedError' ? 'This tag is too small or cannot hold NFC data.' : 'Write failed: ' + err.message);
  }
});
```

**Notes:** The polyfill checks the records, turns them into `{records:[{recordType, id, data(base64), encoding, lang, mediaType, records?}], overwrite}` and passes the JSON string here. The call returns an operation id; the result comes back to the page through `window.__appmintNfcDone(id, errorName, message)`, so an answer for a replaced write never settles a newer one. Unformatted tags are formatted first. Rejections: `NotAllowedError` ("Tag is read-only", or a tag with records when `overwrite` is false), `NotSupportedError` ("Tag does not support NDEF", "Tag too small (N bytes available)"), `NetworkError` (the tag moved away), `SyntaxError` (a record the tag format cannot hold). The app also shows its own short "Ready to write" and "NFC tag written" messages.

### `__ocrAvailable`

```js
window.WebToApk.__ocrAvailable(): Boolean
```

TextDetector / recognizeText(): Google Play services is here (the OCR module's home).

**Example**

Internal transport behind `TextDetector`: whether Google Play services (where the recognizer comes from) is on this phone. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `true` or `false`. When `false`, `TextDetector` is not installed at all. **Needs:** nothing.

**Use the public API:**

```js
if ('TextDetector' in window) scanButton.hidden = false;
```

Choose between the scan flow and a typed form once, at start-up. When the phone has no Google Play services the detector is simply absent, so the page must offer another way in:

```js
function setUpReceiptEntry() {
  var canScan = typeof window.TextDetector === 'function';
  document.getElementById('scan-receipt').hidden = !canScan;
  document.getElementById('type-receipt').hidden = canScan;
  if (!canScan) document.getElementById('entry-hint').textContent =
    'Text scanning is not available on this phone. Type the total below.';
}
document.addEventListener('DOMContentLoaded', setUpReceiptEntry);
```

A detector that exists can still answer that recognition is unavailable at the moment of the call. Tell the two failures apart by the error name:

```js
async function readLabel(img) {
  if (typeof window.TextDetector !== 'function') return null;
  try {
    var lines = await new TextDetector().detect(img);
    return lines.map(function (l) { return l.rawValue; }).join('\n');
  } catch (e) {
    if (e.name === 'NotSupportedError') showMessage('Text recognition is not available right now.');
    else showMessage('Could not read the label: ' + e.message);
    return null;
  }
}
```
**Notes:** Implemented in the app shell (TextRecognitionBridge.kt).

### `__ocrRecognize`

```js
window.WebToApk.__ocrRecognize(requestId: String, base64: String)
```

Recognises the text in [base64] (PNG/JPEG); the answer arrives as `appmint:ocr {requestId, blocks|error}`.

**Example**

Internal transport behind `TextDetector.detect()`: recognises the text in a PNG/JPEG. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** nothing; the answer arrives as the `appmint:ocr` event `{requestId, blocks:[{text, box, corners, lines}]}` or `{requestId, error}` (`unavailable`, `bad-image`, `failed: …`). **Needs:** nothing.

**Use the public API:**

```js
const lines = await new TextDetector().detect(document.getElementById('receipt'));
console.log(lines.map((l) => l.rawValue));
```

Read the text of a photo the user picks. `detect()` accepts a `Blob`/`File` as well as an `<img>`, `<canvas>` or `ImageData`:

```html
<input type="file" id="doc" accept="image/*" capture="environment">
<pre id="out"></pre>
<script>
document.getElementById('doc').addEventListener('change', async function (e) {
  var file = e.target.files[0];
  if (!file || typeof window.TextDetector !== 'function') return;
  try {
    var lines = await new TextDetector().detect(file);
    document.getElementById('out').textContent = lines.map(function (l) { return l.rawValue; }).join('\n');
  } catch (err) {
    document.getElementById('out').textContent = 'Could not read the photo (' + err.name + ')';
  }
});
</script>
```

Outline each recognised line on top of the image. `boundingBox` is in the image's own pixels, so scale it to the displayed size. The image must have finished loading, or `detect()` rejects with `InvalidStateError`:

```js
async function highlightLines(img, overlay) {
  if (!img.complete) await new Promise(function (r) { img.onload = r; });
  var lines = await new TextDetector().detect(img);
  var sx = img.clientWidth / img.naturalWidth, sy = img.clientHeight / img.naturalHeight;
  overlay.innerHTML = '';
  lines.forEach(function (l) {
    var b = l.boundingBox, box = document.createElement('div');
    box.className = 'ocr-box';
    box.title = l.rawValue;
    box.style.cssText = 'position:absolute;left:' + b.x * sx + 'px;top:' + b.y * sy +
      'px;width:' + b.width * sx + 'px;height:' + b.height * sy + 'px';
    overlay.appendChild(box);
  });
}
```

Pull an amount out of a receipt. A cross-origin image that taints the canvas rejects with `SecurityError`, so serve the picture from the app's own origin:

```js
async function findTotal(img) {
  try {
    var lines = await new TextDetector().detect(img);
    var hit = lines.find(function (l) { return /total/i.test(l.rawValue); });
    var m = hit && hit.rawValue.match(/(\d+[.,]\d{2})/);
    return m ? parseFloat(m[1].replace(',', '.')) : null;
  } catch (e) {
    if (e.name === 'SecurityError') console.warn('Image is from another origin; load it from the app instead.');
    else console.warn('OCR failed:', e.name, e.message);
    return null;
  }
}
```
**Notes:** Implemented in the app shell (TextRecognitionBridge.kt).

### `__otpCancel`

```js
window.WebToApk.__otpCancel(requestId: String)
```

Abandons a pending WebOTP request (AbortSignal, or a newer request replaced it).

**Example**

Internal transport behind aborting a WebOTP request (`AbortSignal`). Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** nothing. **Needs:** nothing to switch on.

**Use the public API:**

```js
const ac = new AbortController();
const cred = await navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal });
codeInput.value = cred.code;
ac.abort();   // the pending get() rejects with AbortError
```

Stop waiting as soon as the user starts typing the code themselves:

```js
const input = document.querySelector('input[autocomplete="one-time-code"]');
const ac = new AbortController();
input.addEventListener('input', () => ac.abort(), { once: true });

navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })
  .then((cred) => { input.value = cred.code; })
  .catch((e) => { if (e.name !== 'AbortError') console.log('No code:', e.name); });
```

Cancel the wait when the user goes back to change their phone number:

```js
let otpAbort = null;

function waitForSms() {
  otpAbort = new AbortController();
  return navigator.credentials.get({ otp: { transport: ['sms'] }, signal: otpAbort.signal });
}

document.getElementById('change-number').addEventListener('click', () => {
  if (otpAbort) { otpAbort.abort(); otpAbort = null; }   // the pending get() rejects with AbortError
  showPhoneStep();
});
```

**Notes:** Implemented in the app shell (WebApiPolyfills.kt). The same page code works unchanged in Chrome.

### `__otpStart`

```js
window.WebToApk.__otpStart(requestId: String)
```

WebOTP: waits (up to five minutes) for one SMS through Google's SMS User Consent sheet — no SMS permission. The answer is `appmint:otp {requestId, message|error}`.

**Example**

Internal transport behind `navigator.credentials.get({ otp: { transport: ['sms'] } })`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** nothing; the answer arrives as the `appmint:otp` event `{requestId, message}` or `{requestId, error}` (`cancelled`, `timeout`, `unavailable`). **Needs:** nothing to switch on.

**Use the public API:**

```js
const ac = new AbortController();
const cred = await navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal });
codeInput.value = cred.code;
```

Stop waiting after one minute with a timeout signal:

```js
async function waitForCode(input) {
  if (!('OTPCredential' in window)) return;
  try {
    const cred = await navigator.credentials.get({
      otp: { transport: ['sms'] },
      signal: AbortSignal.timeout(60000)
    });
    input.value = cred.code;
  } catch (e) {
    // TimeoutError after 60 s, AbortError when the user taps Deny
    showMessage('Type the code from the SMS.');
  }
}
```

Tell the user what to do next from the error name:

```js
async function readOtp() {
  try {
    const cred = await navigator.credentials.get({ otp: { transport: ['sms'] } });
    return cred.code;
  } catch (e) {
    if (e.name === 'InvalidStateError') showResendButton();      // no SMS within five minutes
    else if (e.name === 'NotSupportedError') showManualEntry();  // no Google Play services
    return null;                                                  // AbortError: the user tapped Deny
  }
}
```

**Notes:** Implemented in the app shell (WebApiPolyfills.kt). The same page code works unchanged in Chrome.

### `__permissionState`

```js
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:**

```js
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:

```js
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:

```js
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.

### `__pipAvailable`

```js
window.WebToApk.__pipAvailable(): Boolean
```

Whether video.requestPictureInPicture() can work: PiP on in the build, Android 8+.

**Example**

Documented together with `WebToApk.__pipEnter` — the example there shows this one too.

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__pipEnter`

```js
window.WebToApk.__pipEnter(width: Int, height: Int): String
```

Opens the PiP window with a [width]×[height] video's aspect ratio: "ok", or "unsupported" / "failed". The mode change arrives as `appmint:pip`.

**Example**

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__pipExit`

```js
window.WebToApk.__pipExit()
```

document.exitPictureInPicture(): brings the app back to full screen.

**Example**

Documented together with `WebToApk.__pipEnter` — the example there shows this one too.

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__pipPrepare`

```js
window.WebToApk.__pipPrepare()
```

The page is about to put a video fullscreen for PiP: do not force landscape for it.

**Example**

Documented together with `WebToApk.__pipEnter` — the example there shows this one too.

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__printHtml`

```js
window.WebToApk.__printHtml(html: String, baseUrl: String, title: String)
```

Prints [html] — a same-origin iframe's serialised document, sent by the `window.print()` shim ([PrintShim]) — through an offscreen WebView at [baseUrl], because the main WebView's print adapter can only print the whole page. [title] names the print job ("<App name> Document" when blank).

**Example**

Internal transport behind printing an iframe: `iframe.contentWindow.print()` (and an iframe page's own `window.print()`) sends that iframe's document here, and the app prints it through Android's print screen. Pages use the standard calls; never call this directly.

**Returns:** nothing. **Needs:** nothing.

The receipt pattern this makes work — a hidden iframe filled with just the receipt:

```js
function printReceipt(order) {
  var frame = document.createElement('iframe');
  frame.style.display = 'none';
  document.body.appendChild(frame);
  var doc = frame.contentWindow.document;
  doc.open();
  doc.write('<html><head><title>Receipt ' + order.id + '</title>' +
            '<style>body{font:14px sans-serif} td{padding:2px 8px}</style></head><body>' +
            '<h2>Receipt ' + order.id + '</h2><table>' +
            order.lines.map(function (l) { return '<tr><td>' + l.name + '</td><td>' + l.price + '</td></tr>'; }).join('') +
            '</table></body></html>');
  doc.close();
  frame.contentWindow.print();   // prints the RECEIPT, not the page behind it
  setTimeout(function () { frame.remove(); }, 1000);
}
```

A ticket page of your own, loaded into a hidden iframe from the same origin — its CSS and images load, and its `<title>` names the print job:

```js
function printTicket(id) {
  var frame = document.createElement('iframe');
  frame.style.cssText = 'position:fixed;width:0;height:0;border:0';
  frame.src = 'ticket.html?id=' + encodeURIComponent(id);
  frame.onload = function () {
    frame.contentWindow.print();   // prints ticket.html's DOM as it stands now
    setTimeout(function () { frame.remove(); }, 1000);
  };
  document.body.appendChild(frame);
}
```

Adjust the iframe's document just before the snapshot with `beforeprint` on the iframe's window:

```js
function printWithTimestamp(frame) {
  var w = frame.contentWindow;
  w.addEventListener('beforeprint', function () {
    var p = w.document.createElement('p');
    p.textContent = 'Printed ' + new Date().toLocaleString();
    w.document.body.appendChild(p);
  }, { once: true });
  w.print();   // the printed copy includes the line added above
}
```

**Notes:** Arguments are `(html, baseUrl, title)`: the iframe's serialised document, the address its relative links resolve against (the page's own address for an `about:blank` iframe), and the print job name (the iframe's `<title>`, else the page's). The snapshot is taken when `print()` is called — scripts inside it do not run again — and `beforeprint` / `afterprint` fire on the iframe's window around it. A cross-origin iframe cannot be printed from the page.

### `__pullRefreshEligible`

```js
window.WebToApk.__pullRefreshEligible(eligible: Boolean)
```

See [pullPageEligible] / [pullProbeJs]. Called on every touchstart.

**Example**

Internal plumbing behind **pull-to-refresh**: the shell's own touch probe calls it on every touch to say whether a downward pull there should refresh the page. Pages never call it — they control pull-to-refresh with standard CSS and touch events.

**Returns:** nothing. **Needs:** **Enable Pull-to-Refresh** switched on when you build (without it the probe is not installed).

The public way to stop a pull from refreshing in part of your page — claim the gesture:

```html
<style>
  /* A map, drawing canvas or slider: this element owns vertical drags. */
  #map, #canvas { touch-action: none; }
  /* A horizontal carousel: pan-x also tells the shell "not a refresh". */
  .carousel { touch-action: pan-x; }
</style>
```

Or claim it from a touch handler, the standard way:

```js
document.getElementById('canvas').addEventListener('touchstart', function (e) {
  e.preventDefault();   // the page took this gesture: no pull-to-refresh
}, { passive: false });
```

Keep a pan-and-zoom surface from refreshing the page by claiming vertical drags with CSS only:

```html
<div id="board" style="touch-action: none; height: 60vh"></div>
<script>
  // Dragging on #board moves the board; a pull from the top of the rest of the page still refreshes.
  document.getElementById('board').addEventListener('pointermove', function (e) { moveBoard(e); });
</script>
```

**Notes:** A pull only refreshes when the nearest scrollable box under the finger (or the page itself) is scrolled to the top, only one finger is down, and neither of the claims above applies. A touch inside a cross-origin iframe never refreshes. Do not call this method yourself.

### `__relatedAppsInstalled`

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

navigator.getInstalledRelatedApps(): which of [idsJson] (package names from the page's manifest) are installed and visible to this app.

**Example**

Internal transport behind `navigator.getInstalledRelatedApps()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** JSON array of the given package names that are installed and visible to this app. **Needs:** the packages declared at build time (from the bundled web app manifest).

**Use the public API:**

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

Show the "Get the companion app" button only when it is not installed:

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

The packages come from the web app manifest the page links:

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

**Notes:** Implemented in the app shell (IdleAndRelatedApps.kt).

### `__setAppBadge`

```js
window.WebToApk.__setAppBadge(contents: Long): Boolean
```

navigator.setAppBadge(n): n >= 0 is a count (0 clears), -1 the spec's flag badge.

**Example**

Internal transport behind `navigator.setAppBadge(count)` / `clearAppBadge()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `true` when the count was applied. **Needs:** nothing to switch on.

**Use the public API:**

```js
if ('setAppBadge' in navigator) navigator.setAppBadge(unreadCount);
```

Keep the icon count in step with the unread count, and clear it at zero:

```js
async function syncBadge(unread) {
  if (!('setAppBadge' in navigator)) return;
  try {
    if (unread > 0) await navigator.setAppBadge(unread);
    else await navigator.clearAppBadge();
  } catch (e) {
    console.warn('badge not applied', e.name);   // NotAllowedError when the launcher refused it
  }
}
syncBadge(messages.filter(function (m) { return !m.read; }).length);
```

Put a flag badge (no number) on the icon while there is unsynced work:

```js
function markPendingSync(hasPending) {
  if (!('setAppBadge' in navigator)) return;
  const p = hasPending
    ? navigator.setAppBadge()      // no argument = flag badge; a count launcher shows 1
    : navigator.clearAppBadge();
  p.catch(function () {});
}
```

**Notes:** `-1` is the spec's flag badge (no number), which a count launcher shows as 1.

### `__srAbort`

```js
window.WebToApk.__srAbort(id: String)
```

SpeechRecognition.abort(): stop at once, discard.

**Example**

Documented together with `WebToApk.__srStart` — the example there shows this one too.

Internal transport behind `webkitSpeechRecognition` / `SpeechRecognition` (start, stop, abort, and whether a recogniser exists on the phone). Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__srStart(id, options)` returns `""` when listening starts, or a Web Speech error name (`not-allowed`, `service-not-allowed`). Everything after that arrives as `appmint:sr` events: `{id, type}` for start/audiostart/speechstart/speechend/audioend/end, `{id, type:'result', final, alternatives:[{transcript, confidence}]}`, and `{id, type:'error', error}`. **Needs:** the microphone (armed from the call shape).

**Use the public API:**

```js
const rec = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
rec.onresult = (e) => console.log(e.results[0][0].transcript);
rec.start();
```

Continuous dictation with live text and a language:

```js
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
  const rec = new SR();
  rec.lang = 'en-US';
  rec.continuous = true;
  rec.interimResults = true;
  rec.onresult = (e) => {
    const r = e.results[e.results.length - 1];
    note.value = r[0].transcript + (r.isFinal ? '\n' : '');
  };
  micBtn.onclick = () => rec.start();
  stopBtn.onclick = () => rec.stop();
}
```

Report errors such as a refused microphone:

```js
rec.onerror = (e) => {
  if (e.error === 'not-allowed' || e.error === 'service-not-allowed') showMessage('Allow the microphone to dictate.');
  else showMessage('Speech error: ' + e.error);
};
rec.onend = () => micBtn.classList.remove('live');
```

**Notes:** Implemented in the app shell (SpeechRecognitionBridge.kt) over Android's SpeechRecognizer. `__srStart` is refused for pages that do not belong to the app.

### `__srAvailable`

```js
window.WebToApk.__srAvailable(): Boolean
```

Whether a speech recognition service is installed (webkitSpeechRecognition exists only then).

**Example**

Documented together with `WebToApk.__srStart` — the example there shows this one too.

Internal transport behind `webkitSpeechRecognition` / `SpeechRecognition` (start, stop, abort, and whether a recogniser exists on the phone). Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__srStart(id, options)` returns `""` when listening starts, or a Web Speech error name (`not-allowed`, `service-not-allowed`). Everything after that arrives as `appmint:sr` events: `{id, type}` for start/audiostart/speechstart/speechend/audioend/end, `{id, type:'result', final, alternatives:[{transcript, confidence}]}`, and `{id, type:'error', error}`. **Needs:** the microphone (armed from the call shape).

**Use the public API:**

```js
const rec = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
rec.onresult = (e) => console.log(e.results[0][0].transcript);
rec.start();
```

Continuous dictation with live text and a language:

```js
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
  const rec = new SR();
  rec.lang = 'en-US';
  rec.continuous = true;
  rec.interimResults = true;
  rec.onresult = (e) => {
    const r = e.results[e.results.length - 1];
    note.value = r[0].transcript + (r.isFinal ? '\n' : '');
  };
  micBtn.onclick = () => rec.start();
  stopBtn.onclick = () => rec.stop();
}
```

Report errors such as a refused microphone:

```js
rec.onerror = (e) => {
  if (e.error === 'not-allowed' || e.error === 'service-not-allowed') showMessage('Allow the microphone to dictate.');
  else showMessage('Speech error: ' + e.error);
};
rec.onend = () => micBtn.classList.remove('live');
```

**Notes:** Implemented in the app shell (SpeechRecognitionBridge.kt) over Android's SpeechRecognizer. `__srStart` is refused for pages that do not belong to the app.

### `__srStart`

```js
window.WebToApk.__srStart(id: String, optionsJson: String): String
```

SpeechRecognition.start(): "" when starting (events arrive as `appmint:sr`), else the Web Speech error name. Origin-gated: what the user says is user data.

**Example**

Internal transport behind `webkitSpeechRecognition` / `SpeechRecognition` (start, stop, abort, and whether a recogniser exists on the phone). Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__srStart(id, options)` returns `""` when listening starts, or a Web Speech error name (`not-allowed`, `service-not-allowed`). Everything after that arrives as `appmint:sr` events: `{id, type}` for start/audiostart/speechstart/speechend/audioend/end, `{id, type:'result', final, alternatives:[{transcript, confidence}]}`, and `{id, type:'error', error}`. **Needs:** the microphone (armed from the call shape).

**Use the public API:**

```js
const rec = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
rec.onresult = (e) => console.log(e.results[0][0].transcript);
rec.start();
```

Continuous dictation with live text and a language:

```js
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
  const rec = new SR();
  rec.lang = 'en-US';
  rec.continuous = true;
  rec.interimResults = true;
  rec.onresult = (e) => {
    const r = e.results[e.results.length - 1];
    note.value = r[0].transcript + (r.isFinal ? '\n' : '');
  };
  micBtn.onclick = () => rec.start();
  stopBtn.onclick = () => rec.stop();
}
```

Report errors such as a refused microphone:

```js
rec.onerror = (e) => {
  if (e.error === 'not-allowed' || e.error === 'service-not-allowed') showMessage('Allow the microphone to dictate.');
  else showMessage('Speech error: ' + e.error);
};
rec.onend = () => micBtn.classList.remove('live');
```

**Notes:** Implemented in the app shell (SpeechRecognitionBridge.kt) over Android's SpeechRecognizer. `__srStart` is refused for pages that do not belong to the app.

### `__srStop`

```js
window.WebToApk.__srStop(id: String)
```

SpeechRecognition.stop(): stop listening, deliver what was heard.

**Example**

Documented together with `WebToApk.__srStart` — the example there shows this one too.

Internal transport behind `webkitSpeechRecognition` / `SpeechRecognition` (start, stop, abort, and whether a recogniser exists on the phone). Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__srStart(id, options)` returns `""` when listening starts, or a Web Speech error name (`not-allowed`, `service-not-allowed`). Everything after that arrives as `appmint:sr` events: `{id, type}` for start/audiostart/speechstart/speechend/audioend/end, `{id, type:'result', final, alternatives:[{transcript, confidence}]}`, and `{id, type:'error', error}`. **Needs:** the microphone (armed from the call shape).

**Use the public API:**

```js
const rec = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
rec.onresult = (e) => console.log(e.results[0][0].transcript);
rec.start();
```

Continuous dictation with live text and a language:

```js
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
  const rec = new SR();
  rec.lang = 'en-US';
  rec.continuous = true;
  rec.interimResults = true;
  rec.onresult = (e) => {
    const r = e.results[e.results.length - 1];
    note.value = r[0].transcript + (r.isFinal ? '\n' : '');
  };
  micBtn.onclick = () => rec.start();
  stopBtn.onclick = () => rec.stop();
}
```

Report errors such as a refused microphone:

```js
rec.onerror = (e) => {
  if (e.error === 'not-allowed' || e.error === 'service-not-allowed') showMessage('Allow the microphone to dictate.');
  else showMessage('Speech error: ' + e.error);
};
rec.onend = () => micBtn.classList.remove('live');
```

**Notes:** Implemented in the app shell (SpeechRecognitionBridge.kt) over Android's SpeechRecognizer. `__srStart` is refused for pages that do not belong to the app.

### `__ttsSpeak`

```js
window.WebToApk.__ttsSpeak(json: String)
```

The speechSynthesis polyfill's full speak: `{text, lang, rate, pitch, volume (0–1), voice (a getVoices() name, or ''), id}`. [ttsSpeak] stays for pages that call the bridge directly; it has no volume or voice.

**Example**

Internal transport behind `speechSynthesis.speak(utterance)`: the full utterance — text, language, rate, pitch, volume and the chosen voice — as one JSON string. Pages use the standard `speechSynthesis` API; never call this directly.

**Returns:** nothing. Progress arrives as `appmint:tts` events, which the `speechSynthesis` layer turns into the utterance's `start` / `boundary` / `end` / `error` events. **Needs:** nothing.

The standard API this carries:

```js
function readAloud(text) {
  if (!('speechSynthesis' in window)) return;
  var voice = speechSynthesis.getVoices().filter(function (v) { return v.lang === 'en-GB'; })[0];
  var u = new SpeechSynthesisUtterance(text);
  if (voice) u.voice = voice;   // that exact voice
  u.volume = 0.6;               // 0 .. 1
  u.addEventListener('boundary', function (e) { highlightWordAt(e.charIndex, e.charLength); });
  u.addEventListener('end', function () { clearHighlight(); });
  speechSynthesis.speak(u);
}
```

A voice picker the user chooses once; every later utterance carries that exact voice:

```js
var picker = document.getElementById('voice-picker');

function listVoices() {
  var saved = localStorage.getItem('voiceName') || '';
  picker.innerHTML = '';
  speechSynthesis.getVoices().forEach(function (v) {
    picker.add(new Option(v.name + ' (' + v.lang + ')', v.name, false, v.name === saved));
  });
}
if ('speechSynthesis' in window) {
  listVoices();                                   // [] until the engine is ready
  speechSynthesis.onvoiceschanged = listVoices;
}
picker.onchange = function () { localStorage.setItem('voiceName', picker.value); };

function speakWithSavedVoice(text) {
  var name = localStorage.getItem('voiceName');
  var voice = speechSynthesis.getVoices().filter(function (v) { return v.name === name; })[0];
  var u = new SpeechSynthesisUtterance(text);
  if (voice) u.voice = voice;                     // its language is used too when u.lang is empty
  speechSynthesis.speak(u);
}
```

Workout cues: quiet and quick for countdowns, full volume and higher for the switch:

```js
function cue(text, loud) {
  if (!('speechSynthesis' in window)) return;
  var u = new SpeechSynthesisUtterance(text);
  u.lang = 'en-US';
  u.rate = 1.4;                  // 0.1 .. 4
  u.pitch = loud ? 1.3 : 1;      // 0.1 .. 4
  u.volume = loud ? 1 : 0.4;     // 0 .. 1
  u.onerror = function (e) {
    if (e.error === 'synthesis-failed') console.log('Cue not spoken:', text);
  };
  speechSynthesis.speak(u);      // queues after the previous cue
}

cue('Ten seconds left', false);
cue('Switch sides!', true);
```

**Notes:** The JSON is `{text, lang, rate, pitch, volume, voice, id}` (`voice` is a `getVoices()` name or `''`). The older `ttsSpeak(text, lang, rate, pitch, id)` stays for pages that call the bridge directly; it has no volume or voice.

### `__unlockOrientation`

```js
window.WebToApk.__unlockOrientation()
```

screen.orientation.unlock(): back to the orientation the creator built with.

**Example**

Internal transport behind `screen.orientation.unlock()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** nothing; the app goes back to the orientation chosen at build time. **Needs:** nothing to switch on.

**Use the public API:**

```js
document.getElementById('play').addEventListener('click', async () => {
  try { await screen.orientation.lock('landscape'); } catch (e) { /* NotSupportedError on TV */ }
});
document.getElementById('exit').addEventListener('click', () => screen.orientation.unlock());
```

Only the game route is landscape; every other route goes back to the app's own orientation:

```js
function onRouteChange(path) {
  if (path.startsWith('/play')) {
    screen.orientation.lock('landscape').catch(() => {});
  } else if (screen.orientation && typeof screen.orientation.unlock === 'function') {
    screen.orientation.unlock();       // menus, settings and the store
  }
}

window.addEventListener('popstate', () => onRouteChange(location.pathname));
onRouteChange(location.pathname);
```

Keep the onboarding slides upright, then unlock when they are done:

```js
async function showOnboarding() {
  try { await screen.orientation.lock('portrait'); } catch (e) { /* TV: no lock */ }
  await runOnboardingSlides();         // your own flow
  screen.orientation.unlock();         // back to the orientation the app was built with
}
```

**Notes:** Implemented in the app shell (WebApiPolyfills.kt). The same page code works unchanged in Chrome.

### `__wakeLock`

```js
window.WebToApk.__wakeLock(docId: String, held: Boolean): Boolean
```

Screen Wake Lock: [held] true keeps the screen on while document [docId] holds a sentinel. Returns false when the lock could not be taken.

**Example**

Internal transport behind `navigator.wakeLock.request('screen')` and `WakeLockSentinel.release()`. Pages use the standard API; never call this directly — the `__` methods may change without notice.

**Returns:** `true` when the screen-on flag was applied for that document. **Needs:** nothing to switch on.

**Use the public API:**

```js
let lock = null;
async function keepScreenOn() {
  if (!('wakeLock' in navigator)) return;
  lock = await navigator.wakeLock.request('screen');
  lock.addEventListener('release', () => { lock = null; });
}
```

Take the lock again when the user comes back to the app, because every lock is dropped in the background:

```js
let lock = null;
let wantLock = false;

async function acquire() {
  if (!('wakeLock' in navigator)) return;
  try {
    lock = await navigator.wakeLock.request('screen');
    lock.addEventListener('release', () => { lock = null; });
  } catch (e) {
    lock = null;                       // NotAllowedError: the page was not visible
  }
}

function startRecipeMode() { wantLock = true; acquire(); }
function stopRecipeMode()  { wantLock = false; if (lock) lock.release(); }

document.addEventListener('visibilitychange', () => {
  if (wantLock && !lock && document.visibilityState === 'visible') acquire();
});
```

Keep the screen on only while a workout timer runs, and let it sleep again when the timer ends:

```js
async function runTimer(seconds) {
  const lock = ('wakeLock' in navigator)
    ? await navigator.wakeLock.request('screen').catch(() => null)
    : null;
  try {
    for (let left = seconds; left > 0; left--) {
      showRemaining(left);
      await new Promise((r) => setTimeout(r, 1000));
    }
  } finally {
    if (lock && !lock.released) await lock.release();
  }
}
```

**Notes:** The app drops every lock when it goes to the background and fires `appmint:wakelock-lost`, which releases the sentinels (their `release` event fires).

### `addContact`

```js
window.WebToApk.addContact(requestId: String, contactJson: String)
```

*(Contacts: pick and add)* The system picker (no permission needed) and writing a new contact (WRITE_CONTACTS). The read side — listing and searching — is under the Contacts group above.

**Example**

Saves a new contact straight into the phone's address book, without opening the Contacts app.

**Returns:** nothing now. The answer is the `appmint:contacts` event (and `window.onAppMintContacts`) with `detail = { requestId, ok: true, id: '123' }` on success, `{ requestId, ok: false, id: null }` when the save failed, or `{ requestId, error }`. **Needs:** turn on **Edit Contacts** in Step 4 (Access) when you build; the user is asked for permission on the first call.

**Save a customer.** The contact is passed as a JSON string.

```js
function saveCustomer(name, phone, email) {
  if (!(window.WebToApk && window.WebToApk.addContact)) return;
  var id = 'add-' + Date.now();

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:contacts', handler);

    if (e.detail.error === 'not_enabled') { showMessage('Turn on Edit Contacts when you build'); return; }
    if (e.detail.error) { showMessage('Not saved: ' + e.detail.error); return; }
    showMessage(e.detail.ok ? 'Saved to contacts' : 'Could not save the contact');
  });

  WebToApk.addContact(id, JSON.stringify({
    displayName: name,
    phones: [{ number: phone, type: 'mobile' }],   // type: mobile | home | work
    emails: [email]
  }));
}
```

**Short form.** Single values are accepted too: `{ name: 'Ali', phone: '+911234567890', email: 'ali@example.com' }`.

A "Save to contacts" button on a business card page, using the short form and a Promise:

```js
function addOne(contact) {
  return new Promise(function (resolve, reject) {
    var id = 'card-' + Date.now() + '-' + Math.random().toString(36).slice(2);
    window.addEventListener('appmint:contacts', function handler(e) {
      if (!e.detail || e.detail.requestId !== id) return;
      window.removeEventListener('appmint:contacts', handler);
      if (e.detail.error) reject(new Error(e.detail.error));
      else if (!e.detail.ok) reject(new Error('save_failed'));
      else resolve(e.detail.id);
    });
    WebToApk.addContact(id, JSON.stringify(contact));
  });
}

document.getElementById('save-card').addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.addContact)) return;
  addOne({ name: 'Priya Sharma', phone: '+919876543210', email: 'priya@example.com' })
    .then(function () { showMessage('Priya is in your contacts now'); })
    .catch(function (err) {
      showMessage(err.message === 'permission_denied' ? 'Allow Contacts to save this card.' : 'Not saved: ' + err.message);
    });
});
```

Import a team list one person at a time, with work and home numbers, and report how many were saved:

```js
async function importTeam(team) {   // [{ name, work, home, email }]
  if (!(window.WebToApk && window.WebToApk.addContact)) return;
  var saved = 0;
  for (var i = 0; i < team.length; i++) {
    var m = team[i];
    try {
      await addOne({   // addOne() from the example above
        displayName: m.name,
        phones: [{ number: m.work, type: 'work' }, { number: m.home, type: 'home' }],
        emails: m.email ? [m.email] : []
      });
      saved++;
    } catch (err) {
      if (err.message === 'not_enabled' || err.message === 'permission_denied') break;   // the rest would fail too
    }
  }
  showMessage(saved + ' of ' + team.length + ' saved');
}
```

**Notes:** Errors: `not_enabled`, `permission_denied`. The returned `id` is the new raw-contact id. A phone `type` other than `home` or `work` is saved as `mobile`. The contact is saved on the device (no account). Writing contacts is a sensitive permission: your Play Store listing must declare it in the Data safety form and privacy policy.

### `adsAppOpenEnable`

```js
window.WebToApk.adsAppOpenEnable(enabled: Boolean)
```

Lets the page pause/resume the app-open ad on return from another app (on by default when a unit is set).

**Example**

The native half of `AppMintAds.appOpen.setEnabled(on)`: it pauses or resumes the app-open ad. Pages use `AppMintAds.appOpen.setEnabled`.

**Returns:** nothing. **Needs:** **Ad Control → Developer-controlled** and an app-open ad unit ID.

```js
function setAppOpenAds(on) {
  if (window.AppMintAds) window.AppMintAds.appOpen.setEnabled(on);
}

// Pause while the user is in the middle of something, resume afterwards.
function startCheckout() { setAppOpenAds(false); showCheckout(); }
function checkoutDone()  { setAppOpenAds(true); }
```

Picking a photo leaves the app for the system picker, and coming back would count as a return. Pause the app-open ad around it:

```js
var input = document.getElementById('photo');

function appOpenAds(on) {
  if (window.AppMintAds) window.AppMintAds.appOpen.setEnabled(on);
}

input.addEventListener('click', function () { appOpenAds(false); });
function pickerDone() { setTimeout(function () { appOpenAds(true); }, 1000); }
input.addEventListener('change', pickerDone);
input.addEventListener('cancel', pickerDone);
```

Members of your own paid plan never see it. The switch is not saved, so set it early on every launch:

```js
// Runs from a <head> script: window.AppMintAds is already there at document start.
var plan = null;
try { plan = localStorage.getItem('plan'); } catch (e) {}

if (window.AppMintAds && plan === 'pro') {
  window.AppMintAds.appOpen.setEnabled(false);
}
```

**Notes:** Raw signature `adsAppOpenEnable(enabled)`. On by default when an app-open unit is set. The app-open ad shows at cold start (if it loads within 15 seconds) and when the user comes back from another app; an ad older than 4 hours is never shown.

### `adsConfig`

```js
window.WebToApk.adsConfig(): String
```

`{control, formats:{banner, mrec, interstitial, rewarded, rewarded_interstitial, app_open}}` — the ad formats this build can serve.

**Example**

Reads which ad formats this build can serve and which ad control mode it uses. Pages normally call `AppMintAds.config()`, which parses this for you.

**Returns:** a JSON string: `{"control":"automatic"|"developer","formats":{"banner":bool,"mrec":bool,"interstitial":bool,"rewarded":bool,"rewarded_interstitial":bool,"app_open":bool}}`. A format is `true` when ads are on and the build has an ad unit ID for it. **Needs:** nothing.

The public API first:

```js
if (window.AppMintAds) {
  var cfg = window.AppMintAds.config();      // already parsed; {control:'automatic', formats:{}} on error
  if (cfg.control === 'developer' && cfg.formats.rewarded) {
    document.getElementById('watch-ad').hidden = false;
  }
}
```

The raw call, if you do not use `AppMintAds`:

```js
var b = window.WebToApk;
if (b && typeof b.adsConfig === 'function') {
  try {
    var cfg = JSON.parse(b.adsConfig());
    console.log(cfg.control, cfg.formats);
  } catch (e) { console.log('could not read ad config', e); }
}
```

Reserve space only for the formats this build really has, and give the empty box back to the page otherwise:

```js
var box = document.getElementById('sidebar-ad');
var cfg = window.AppMintAds ? window.AppMintAds.config() : { control: 'automatic', formats: {} };

if (cfg.control === 'developer' && cfg.formats.mrec) {
  window.AppMintAds.banner.attach(box, { size: 'mrec' });
} else if (cfg.control === 'developer' && cfg.formats.banner) {
  window.AppMintAds.banner.attach(box, { size: 'banner' });
} else {
  box.remove();                                   // browser, Automatic mode, or no unit: no empty gap
}
```

**Notes:** In Automatic mode `formats` still tells you which units exist, but `AppMintAds.load/show` answer `code: 'automatic_mode'`; use `WebToApk.showRewardedAd()` / `showInterstitialAd()` there.

### `adsIsReady`

```js
window.WebToApk.adsIsReady(type: String): Boolean
```

True when a full-screen [type] is loaded and can be shown right now.

**Example**

Tells you whether a full-screen ad of one type is loaded and can show now (Developer-controlled ads). Pages normally call `AppMintAds.isReady(type)`.

**Returns:** `true` / `false`, synchronously. `type` is `'interstitial'`, `'rewarded'`, `'rewarded_interstitial'` or `'app_open'`; `'banner'` / `'mrec'` always answer `false`. **Needs:** **Ad Control → Developer-controlled** and an ad unit ID for that type.

The public API first:

```js
var watchBtn = document.getElementById('watch-ad');
function refresh() {
  watchBtn.disabled = !(window.AppMintAds && window.AppMintAds.isReady('rewarded'));
}
refresh();
window.addEventListener('appmint:ad', refresh);     // loaded / closed / failed all change readiness
```

The raw call:

```js
var b = window.WebToApk;
var ready = !!(b && typeof b.adsIsReady === 'function' && b.adsIsReady('interstitial'));
```

Show an interstitial at the end of a level only when one is waiting, and never hold the player back when it is not:

```js
function onLevelComplete() {
  var ads = window.AppMintAds;
  if (ads && ads.isReady('interstitial')) {
    // resolves after the ad closes, or at once with {ok:false, code} if it could not show
    ads.show('interstitial').then(startNextLevel);
  } else {
    startNextLevel();                             // not loaded yet, or inside the 60-second cooldown
  }
}
```

**Notes:** An interstitial inside its 60-second cooldown answers `false`. An app-open ad older than 4 hours answers `false` (Google says not to show it). In Automatic mode this is always `false`; use `WebToApk.isRewardedAdReady()` there.

### `adsLoad`

```js
window.WebToApk.adsLoad(type: String, callbackId: String)
```

Loads a full-screen [type]; the promise for [callbackId] resolves with the outcome.

**Example**

The native half of `AppMintAds.load(type)`: it starts loading a full-screen ad and answers the shim's callback. Pages use `AppMintAds.load(type)`, which returns a Promise; do not call this directly.

**Returns:** (through `AppMintAds.load`) a Promise of `{ok:true}`, `{ok:true, cached:true}` when one is already loaded, or `{ok:false, code, reason, message?, adCode?}`. **Needs:** **Ad Control → Developer-controlled** and an ad unit ID for the type.

```js
var watchBtn = document.getElementById('watch-ad');
watchBtn.disabled = true;

if (window.AppMintAds) {
  window.AppMintAds.load('rewarded').then(function (res) {
    if (res.ok) watchBtn.disabled = false;
    else console.log('rewarded ad not loaded:', res.code, res.message);
  });
} else {
  watchBtn.hidden = true;                       // browser: there are no ads here
}
```

Retry a load that failed for a passing reason, and stop for the reasons a retry cannot fix:

```js
function loadWithRetry(type, attempt) {
  attempt = attempt || 0;
  return window.AppMintAds.load(type).then(function (res) {
    if (res.ok) return res;
    if ((res.code === 'no_fill' || res.code === 'network') && attempt < 3) {
      return new Promise(function (wait) { setTimeout(wait, 5000 * (attempt + 1)); })
        .then(function () { return loadWithRetry(type, attempt + 1); });
    }
    return res;                                   // invalid_unit, consent, ad_free, disabled ...
  });
}

if (window.AppMintAds) {
  loadWithRetry('rewarded').then(function (res) {
    document.getElementById('watch-ad').disabled = !res.ok;
  });
}
```

Warm two formats before a game session starts and hide every ad button when the user has no ads at all:

```js
if (window.AppMintAds) {
  Promise.all([
    window.AppMintAds.load('interstitial'),
    window.AppMintAds.load('rewarded_interstitial')
  ]).then(function (results) {
    var noAds = results.some(function (r) { return r.code === 'ad_free' || r.code === 'disabled'; });
    if (noAds) hideAllAdButtons();                // paid to remove ads, or ads are off in this build
    results.forEach(function (r) {
      if (!r.ok && r.adCode !== undefined) console.log('ad load failed', r.code, r.adCode, r.message);
    });
  });
}
```

**Notes:** Failure codes: `no_fill`, `network`, `invalid_unit`, `internal` (from AdMob, with `adCode`), and `automatic_mode`, `unknown_type`, `ad_free`, `disabled`, `consent`, and `use_slot` for `banner` / `mrec` (those load with `AppMintAds.banner.attach`). The app already preloads every full-screen type that has a unit ID, and reloads after each show. The raw signature is `adsLoad(type, callbackId)`; its answer goes to an internal resolver, so a direct call gives you no result.

### `adsShow`

```js
window.WebToApk.adsShow(type: String, callbackId: String, userGesture: Boolean)
```

Shows a loaded full-screen [type]; resolves when it closes, with earned/reward for rewarded formats.

**Example**

The native half of `AppMintAds.show(type)`: it shows a loaded full-screen ad and answers when it closes. Pages use `AppMintAds.show(type)`; do not call this directly.

**Returns:** (through `AppMintAds.show`) a Promise of `{ok:true, shown:true, earned, reward?}` after the ad closes (`reward` = `{type, amount}` for rewarded formats), or `{ok:false, shown:false, code, reason, message?}`. **Needs:** **Ad Control → Developer-controlled** and an ad unit ID for the type.

Call it from the user's tap. The shim passes `navigator.userActivation.isActive` as the third argument (`userGesture`):

```js
document.getElementById('watch-ad').addEventListener('click', function () {
  if (!window.AppMintAds) return;
  window.AppMintAds.show('rewarded').then(function (res) {
    if (res.earned) addCoins(res.reward.amount);
    else if (res.reason === 'ad_free') addCoins(50);      // paid to remove ads: reward anyway
    else if (res.reason !== 'declined') showMessage('No ad available right now.');
  });
});
```

Offer a rewarded interstitial from the user's own "Double my bonus" tap, and move on whatever the answer:

```js
document.getElementById('double-bonus').addEventListener('click', function () {
  if (!window.AppMintAds) return goToNextLevel();
  window.AppMintAds.show('rewarded_interstitial').then(function (res) {
    if (res.earned) doubleLevelBonus();
    else if (!res.shown && res.code !== 'declined') console.log('no ad:', res.code);
    goToNextLevel();                              // shown or not, the player keeps going
  });
});
```

Pause your game music while any full-screen ad is on screen, using the `appmint:ad` stream:

```js
window.addEventListener('appmint:ad', function (e) {
  var d = e.detail;                               // {type, phase, code?, adCode?, message?, earned?, reward?}
  if (d.type === 'banner' || d.type === 'mrec') return;
  if (d.phase === 'opened') music.pause();
  if (d.phase === 'closed' || d.phase === 'show_failed') music.play();
});

function onChapterSaved() {
  if (window.AppMintAds) window.AppMintAds.show('interstitial');   // a natural break
}
```

**Notes:** Raw signature `adsShow(type, callbackId, userGesture)`. When `userGesture` is `false` for `rewarded` or `rewarded_interstitial`, the app first asks "Watch an ad? / No thanks" and a "no" answers `declined`. Other codes: `automatic_mode`, `unknown_type`, `ad_free`, `disabled`, `consent`, `not_full_screen`, `showing`, `cooldown` (interstitial, 60 s), `not_loaded` (a load starts for next time), `show_failed`, `internal`.

### `adsSlotAttach`

```js
window.WebToApk.adsSlotAttach(slotId: String, type: String, widthCss: Int, callbackId: String)
```

Creates a banner/mrec overlay for the page's slot [slotId], sized to [widthCss]; resolves with the ad's size once loaded.

**Example**

The native half of `AppMintAds.banner.attach(element, {size})`: it creates a banner or medium-rectangle ad over a page element. Pages use `AppMintAds.banner.attach`; do not call this directly.

**Returns:** (through `AppMintAds.banner.attach`) a Promise of `{ok:true, slot, width, height}` in CSS pixels once the ad has loaded, or `{ok:false, slot, code, message?}`. **Needs:** **Ad Control → Developer-controlled** and a banner (or medium rectangle) ad unit ID.

```html
<div id="ad-slot"></div>

<script>
if (window.AppMintAds) {
  window.AppMintAds.banner.attach('#ad-slot', { size: 'banner' }).then(function (res) {
    if (!res.ok) console.log('no banner:', res.code);   // e.g. no_fill, network, too_many_slots
  });
}
</script>
```

A medium rectangle on a results screen that gives its space back when no ad comes:

```js
var box = document.getElementById('result-ad');

if (window.AppMintAds) {
  window.AppMintAds.banner.attach(box, { size: 'mrec' }).then(function (res) {
    if (res.ok) return;                           // the box now has min-height = res.height
    window.AppMintAds.banner.detach(res.slot);    // free the slot for another banner
    box.hidden = true;
    if (res.code === 'too_many_slots') console.log('detach a banner first:', res.message);
  });
} else {
  box.hidden = true;
}
```

Count impressions and clicks per slot from the `appmint:ad` stream, keyed by the slot id `attach` returned:

```js
var slotNames = {};

if (window.AppMintAds) {
  window.AppMintAds.banner.attach('#footer-ad', { size: 'banner' }).then(function (res) {
    if (res.ok) slotNames[res.slot] = 'footer';
  });
}

window.addEventListener('appmint:ad', function (e) {
  var d = e.detail;                               // banner events carry {type, phase, slot}
  if (d.slot && (d.phase === 'impression' || d.phase === 'clicked')) {
    track('ad_' + d.phase, slotNames[d.slot] || d.slot);
  }
});
```

**Notes:** Raw signature `adsSlotAttach(slotId, type, widthCss, callbackId)`; `type` is `'banner'` or `'mrec'`. A WebView cannot hold a native view inside the page, so the ad is drawn over your element and follows it on scroll and resize (see `adsSlotRect`). At most two slots at once, and only one medium rectangle. Extra codes: `duplicate_slot`, `too_many_slots`, `detached`, `internal`, plus the load codes (`no_fill`, `network`, `invalid_unit`, `ad_free`, `disabled`, `consent`, `automatic_mode`).

### `adsSlotDetach`

```js
window.WebToApk.adsSlotDetach(slotId: String)
```

Removes a banner overlay.

**Example**

The native half of `AppMintAds.banner.detach(slotOrElement)`: it removes a banner overlay and frees its slot. Pages use `AppMintAds.banner.detach`.

**Returns:** nothing. **Needs:** **Ad Control → Developer-controlled**.

Remove the banner before a screen where an ad does not belong (a video player, a checkout):

```js
var slotEl = document.getElementById('ad-slot');

function openPlayer() {
  if (window.AppMintAds) window.AppMintAds.banner.detach(slotEl);   // the element, or the slot id from attach()
  showPlayer();
}

function closePlayer() {
  hidePlayer();
  if (window.AppMintAds) window.AppMintAds.banner.attach(slotEl, { size: 'banner' });
}
```

In a single-page app, free the banner when the route that holds it goes away. `attach` marks the element straight away, so detaching by element works even while the ad is still loading:

```js
function onRouteChange(route) {
  if (!window.AppMintAds) return renderRoute(route);
  var old = document.getElementById('article-ad');
  if (old) window.AppMintAds.banner.detach(old);
  renderRoute(route);
  if (route === '/articles') {
    window.AppMintAds.banner.attach('#article-ad', { size: 'banner' });
  }
}
```

After a "Remove Ads" purchase the overlays are gone; detach the slots too and drop the space they reserved:

```js
window.addEventListener('appmint:premium', function (e) {
  if (!e.detail.premium || !window.AppMintAds) return;
  document.querySelectorAll('[data-appmint-ad-slot]').forEach(function (el) {
    window.AppMintAds.banner.detach(el);
    el.remove();
  });
});
```

**Notes:** Raw signature `adsSlotDetach(slotId)`. `detach` takes the element or the `slot` string that `attach` returned, not a CSS selector. An `attach` Promise still waiting for its ad answers `{ok:false, code:'detached'}`. Removing the element from the DOM also detaches the slot.

### `adsSlotRect`

```js
window.WebToApk.adsSlotRect(slotId: String, x: Float, y: Float, w: Float, h: Float, visible: Boolean)
```

Internal: the slot element's current viewport rectangle (CSS px) — moves the overlay.

**Example**

Internal: moves a banner overlay to its element's current position. The `AppMintAds.banner` helper calls it for you on scroll, resize, visibility change and every 500 ms; pages use `AppMintAds.banner.attach` and never call this.

**Returns:** nothing. **Needs:** **Ad Control → Developer-controlled**, and a slot created by `AppMintAds.banner.attach`.

All a page does is give the slot element a place in the layout. The helper measures it and keeps the ad on top of it:

```html
<main>
  <article id="story">…</article>
  <div id="ad-slot" style="margin: 16px 0"></div>   <!-- the ad follows this box while scrolling -->
</main>

<script>
if (window.AppMintAds) window.AppMintAds.banner.attach('#ad-slot', { size: 'banner' });
</script>
```

A banner inside a scrolling list, not the page: the helper listens to scroll in the capture phase, so the ad follows the inner container too:

```html
<div id="feed" style="height: 100vh; overflow-y: auto">
  <div class="card">…</div>
  <div class="card">…</div>
  <div id="feed-ad" style="margin: 12px 0"></div>
  <div class="card">…</div>
</div>

<script>
if (window.AppMintAds) window.AppMintAds.banner.attach('#feed-ad', { size: 'banner' });
</script>
```

The ad is drawn above the page, so your own dialog would sit under it. Hide the slot while the dialog is open:

```js
var slotEl = document.getElementById('ad-slot');
var dialog = document.getElementById('settings-dialog');

function openSettings() {
  slotEl.style.visibility = 'hidden';             // the helper hides the overlay on its next tick
  dialog.showModal();
}

dialog.addEventListener('close', function () {
  slotEl.style.visibility = '';                   // and puts it back over the box
});
```

**Notes:** Raw signature `adsSlotRect(slotId, x, y, w, h, visible)`, in CSS pixels relative to the viewport. The overlay is hidden while the element is off screen, has zero size, has `visibility: hidden`, or the page is in the background. If the element leaves the DOM, the helper detaches the slot by itself.

### `authenticateBiometric`

```js
window.WebToApk.authenticateBiometric(callbackId: String, title: String, subtitle: String)
```

*(Fingerprint / biometric bridge)* WebToApk.isBiometricAvailable()            -> "available" | "no_hardware"                                                | "not_enrolled" | "unavailable"   WebToApk.authenticateBiometric(id, title, subtitle) Result is delivered to the same __webToApkAuth callback table the Google Sign-In bridge uses:   window.__webToApkAuth = window.__webToApkAuth || {};   window.__webToApkAuth['unlock'] = function (json) {       var r = JSON.parse(json);      // {ok:true} | {ok:false,error:"..."}       if (r.ok) showApp();   };   WebToApk.authenticateBiometric('unlock', 'Unlock', 'Use your fingerprint');

**Example**

Shows the system fingerprint prompt with a title and subtitle. The simple form of `authenticateBiometricEx` (use that one for the PIN option, a custom Cancel label or "strong" biometrics).

**Returns:** nothing. The answer arrives later: the shell calls `window.__webToApkAuth[callbackId](json)` with `'{"ok":true}'` or `'{"ok":false,"error":"<code>"}'`, then removes that entry. **Needs:** **Fingerprint** ticked when you build (without it: `error:"permission_missing"`).

Register the callback FIRST, then call:

```js
function unlockWithFingerprint() {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.authenticateBiometric !== 'function') {
      resolve({ ok: false, error: 'not_in_app' });
      return;
    }
    var id = 'bio_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometric(id, 'Unlock', 'Touch the fingerprint sensor');
  });
}

document.getElementById('unlock').addEventListener('click', async function () {
  var r = await unlockWithFingerprint();
  if (r.ok) showMyApp();
  else if (r.error === 'user_cancel') { /* the user closed it — do nothing */ }
  else if (r.error === 'none_enrolled') alert('Please add a fingerprint in phone settings.');
  else if (r.error === 'lockout') alert('Too many tries. Wait a little and try again.');
  else alert('Could not unlock: ' + r.error);
});
```

Confirm a risky action with the fingerprint first (an empty title shows the app's name):

```js
function confirmItsYou(subtitle) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.authenticateBiometric !== 'function') {
      resolve({ ok: false, error: 'not_in_app' });
      return;
    }
    var id = 'confirm_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometric(id, '', subtitle);
  });
}

document.getElementById('delete-account').addEventListener('click', async function () {
  var r = await confirmItsYou('Confirm to delete your account');
  if (r.ok) deleteMyAccount();
  else if (r.error !== 'user_cancel') alert('Not confirmed: ' + r.error);
});
```

Lock the app again when it comes back after more than a minute in the background:

```js
var hiddenAt = 0;

document.addEventListener('visibilitychange', async function () {
  if (document.visibilityState === 'hidden') {
    hiddenAt = Date.now();
    return;
  }
  if (!hiddenAt || Date.now() - hiddenAt < 60000) return;
  hiddenAt = 0;
  hideMyApp();
  var r = await unlockWithFingerprint();     // from the first example
  if (r.ok) showMyApp();
  else showLockedScreen();                   // with its own "Unlock" button
});
```

**Notes:** An empty title uses the app's name. A wrong finger does NOT end the prompt — the callback fires only on success, cancel or a real error. Error codes are the same as `authenticateBiometricEx`. This proves the phone's owner is present; it does not prove who they are to a server.

### `authenticateBiometricEx`

```js
window.WebToApk.authenticateBiometricEx(callbackId: String, optionsJson: String)
```

The full form. [optionsJson] accepts: title, subtitle, description, negativeButtonText, allowDeviceCredential (PIN/pattern/password fallback), strong (require BIOMETRIC_STRONG — use for anything payment-shaped). Errors are distinct codes, not one boolean. Before any prompt: no_hardware | none_enrolled | unavailable (the status check could not run — see isBiometricAvailable). From the Android 9+ system prompt: user_cancel | no_hardware | hw_unavailable | none_enrolled | lockout | lockout_permanent | no_device_credential | timeout | no_space | unable_to_process | vendor_error, and for a code Android adds later its own message text (or `error_<code>`). permission_missing when the build lacks the Fingerprint permission. On Android 7–8 (API 24–27, fingerprint only) an error is the phone's own message text as the OEM wrote it — not a fixed code.

**Example**

Shows the system fingerprint / face prompt with full options: PIN or pattern as a second way in, a custom Cancel label, and "strong" biometrics only.

**Returns:** nothing. The answer arrives later: the shell calls `window.__webToApkAuth[callbackId](json)` with `'{"ok":true}'` or `'{"ok":false,"error":"<code>"}'`, then removes that entry. **Needs:** **Fingerprint** ticked when you build (without it: `error:"permission_missing"`).

A small Promise wrapper you can reuse:

```js
function verifyUser(options) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.authenticateBiometricEx !== 'function') {
      resolve({ ok: false, error: 'not_in_app' });
      return;
    }
    var id = 'bio_' + Date.now() + '_' + Math.random().toString(36).slice(2);
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometricEx(id, JSON.stringify(options));
  });
}
```

Unlock the app — fingerprint, face, or the phone's PIN/pattern:

```js
async function unlockApp() {
  var r = await verifyUser({
    title: 'Unlock',
    subtitle: 'Use your fingerprint or PIN',
    allowDeviceCredential: true       // Android 11+: the system adds "Use PIN"
  });
  if (r.ok) showMyApp();
  else if (r.error === 'lockout') alert('Too many tries. Wait a little and try again.');
}
```

Confirm a payment — strong biometrics only, your own Cancel label:

```js
async function confirmPayment() {
  var r = await verifyUser({
    title: 'Confirm payment',
    description: 'Pay 12.00 to Coffee House',
    negativeButtonText: 'Not now',
    strong: true                      // BIOMETRIC_STRONG (Android 11+)
  });
  if (r.ok) sendPayment();
  else if (r.error !== 'user_cancel') alert('Could not confirm: ' + r.error);
}
```

Unlock a Keystore key made with `requireAuth`, then retry:

```js
async function openVault() {
  var r = await verifyUser({ title: 'Open vault', allowDeviceCredential: true });
  if (r.ok) readSecret();    // AppMintKeystore.decrypt works for authValiditySeconds now
}
```

**Notes:** Error codes: `user_cancel`, `none_enrolled`, `no_hardware`, `hw_unavailable`, `lockout` (wait and retry), `lockout_permanent` (the user must unlock the phone with PIN first), `no_device_credential`, `timeout`, `permission_missing`, `unavailable`, and rarely `no_space`, `unable_to_process`, `vendor_error`. On Android 7–8 the shell shows its own dialog and an error is the system's message text. `allowDeviceCredential` and `negativeButtonText` do not mix: with the PIN option the system draws its own button. A wrong finger does not end the prompt.

### `bleConnect`

```js
window.WebToApk.bleConnect(address: String)
```

*(BLE fitness sensors (heart rate / speed / cadence / power / battery))* Runtime for permBluetooth. No wizard switch sets it: the build arms it when the page's own code calls these methods (or Web Bluetooth). Before this runtime the permissions were declared while nothing in the runtime touched a sensor, the exact declared-but-dead pattern SmsBridge documents. Results stream as `appmint:ble` events / window.onAppMintBle({kind, ...}).

**Example**

Connects to one fitness sensor found by `bleStartScan()` and starts streaming its readings.

**Returns:** nothing. Progress and data arrive as `appmint:ble` events (and `window.onAppMintBle`): `{kind:'state', state:'connecting', address}`, `{kind:'state', state:'connected', address}`, `{kind:'state', state:'ready', services:[...]}`, then `{kind:'data', type, data}` for every reading. **Needs:** Bluetooth (turned on by the build when your code uses the sensor bridge) and the Nearby devices permission, asked on first use.

Pass the `address` from a `device` event, from the user's tap:

```js
function connectSensor(address) {
  if (!window.WebToApk || typeof window.WebToApk.bleConnect !== 'function') return;
  window.WebToApk.bleConnect(address);
}

window.onAppMintBle = function (d) {
  if (d.kind === 'state') {
    // 'connecting' | 'connected' | 'ready' | 'disconnected' (also 'scanning' / 'scan_stopped')
    showStatus(d.state);
  } else if (d.kind === 'data') {
    if (d.type === 'heart_rate')    show('bpm', d.data.bpm);
    if (d.type === 'speed_cadence') {                   // either key can be missing in one packet
      if (d.data.speedKmh !== undefined)   show('speed', d.data.speedKmh.toFixed(1) + ' km/h');
      if (d.data.cadenceRpm !== undefined) show('cadence', Math.round(d.data.cadenceRpm) + ' rpm');
    }
    if (d.type === 'power')   show('power', d.data.watts + ' W');
    if (d.type === 'running') show('pace', d.data.speedKmh.toFixed(1) + ' km/h, ' + d.data.cadenceSpm + ' spm');
    if (d.type === 'battery') show('battery', d.data.percent + '%');
  } else if (d.kind === 'error') {
    showStatus('Could not connect: ' + d.error);
  }
};
```

Remember the sensor that connected and reconnect to it next time without a scan:

```js
var SAVED_KEY = 'hrStrapAddress';

window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (d.kind === 'state' && d.state === 'connected') localStorage.setItem(SAVED_KEY, d.address);
  if (d.kind === 'error' && d.error === 'invalid_address') localStorage.removeItem(SAVED_KEY);
});

document.getElementById('start-workout').addEventListener('click', function () {
  if (!window.WebToApk || typeof window.WebToApk.bleConnect !== 'function') return;
  var address = localStorage.getItem(SAVED_KEY);
  if (address) window.WebToApk.bleConnect(address);
  else window.WebToApk.bleStartScan();   // first time: let the user pick a sensor
});
```

Track a heart-rate session (average, max, strap contact) from the readings:

```js
var beats = 0, sum = 0, max = 0;

window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (d.kind === 'state' && d.state === 'ready' && d.services.length === 0) {
    showStatus('This sensor has no heart rate, speed, power or battery service.');
  }
  if (d.kind !== 'data' || d.type !== 'heart_rate') return;
  if (d.data.contact === false) {          // only sent by straps that report skin contact
    showStatus('Wet the strap: no skin contact');
    return;
  }
  beats++; sum += d.data.bpm; max = Math.max(max, d.data.bpm);
  show('bpm', d.data.bpm);
  show('avg', Math.round(sum / beats));
  show('max', max);
});
```

**Notes:** One sensor at a time: connecting stops any scan and drops the previous sensor first (you get its `disconnected`). `ready.services` lists the full UUIDs of the supported services this sensor has. The first speed/cadence packet after connecting sends nothing — speed and cadence need two packets to compute. `heart_rate` also carries `rrIntervalsMs` (beat-to-beat intervals in ms, when the strap sends them), `energyKj` and `contact` when the strap reports them; `power` carries `cadenceRpm` from its second packet on when the meter sends crank data. Errors: `not_enabled`, `permission_denied`, `bluetooth_unavailable`, `invalid_address`. If the sensor walks out of range you get `{kind:'state', state:'disconnected', address}`.

### `bleDisconnect`

```js
window.WebToApk.bleDisconnect()
```

*(BLE fitness sensors (heart rate / speed / cadence / power / battery))* Runtime for permBluetooth. No wizard switch sets it: the build arms it when the page's own code calls these methods (or Web Bluetooth). Before this runtime the permissions were declared while nothing in the runtime touched a sensor, the exact declared-but-dead pattern SmsBridge documents. Results stream as `appmint:ble` events / window.onAppMintBle({kind, ...}).

**Example**

Disconnects the fitness sensor connected with `bleConnect()`.

**Returns:** nothing. If a sensor was connected, an `appmint:ble` event `{kind:'state', state:'disconnected'}` follows. **Needs:** Bluetooth (turned on by the build when your code uses the sensor bridge); without it the call does nothing.

```js
document.getElementById('end-workout').addEventListener('click', function () {
  if (window.WebToApk && typeof window.WebToApk.bleDisconnect === 'function') {
    window.WebToApk.bleDisconnect();
  }
});

window.addEventListener('appmint:ble', function (e) {
  if (e.detail.kind === 'state' && e.detail.state === 'disconnected') {
    showStatus('Sensor disconnected');
  }
});
```

Reconnect by itself when the sensor drops out, but not when the user ended the workout:

```js
var lastAddress = null, userEnded = false, retries = 0;

window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (d.kind !== 'state') return;
  if (d.state === 'connected') { lastAddress = d.address; userEnded = false; retries = 0; }
  // Only a drop caused by the sensor carries an address.
  if (d.state === 'disconnected' && d.address && !userEnded && lastAddress && retries < 3) {
    retries++;
    showStatus('Sensor lost, reconnecting (' + retries + '/3)...');
    setTimeout(function () { window.WebToApk.bleConnect(lastAddress); }, 3000);
  }
});

function endWorkout() {
  userEnded = true;
  if (window.WebToApk && typeof window.WebToApk.bleDisconnect === 'function') {
    window.WebToApk.bleDisconnect();
  }
}
```

Disconnect and forget the saved sensor from a settings screen:

```js
document.getElementById('forget-sensor').addEventListener('click', function () {
  localStorage.removeItem('hrStrapAddress');   // wherever your app saved the address
  if (window.WebToApk && typeof window.WebToApk.bleDisconnect === 'function') {
    window.WebToApk.bleDisconnect();
  }
  showStatus('Sensor forgotten. Tap Scan to pair a new one.');
});
```

**Notes:** The `disconnected` event you cause yourself has no `address`; one caused by the sensor (out of range, switched off) has it. The app also disconnects when it closes.

### `bleSetWheelCircumference`

```js
window.WebToApk.bleSetWheelCircumference(mm: Int)
```

*(BLE fitness sensors (heart rate / speed / cadence / power / battery))* Runtime for permBluetooth. No wizard switch sets it: the build arms it when the page's own code calls these methods (or Web Bluetooth). Before this runtime the permissions were declared while nothing in the runtime touched a sensor, the exact declared-but-dead pattern SmsBridge documents. Results stream as `appmint:ble` events / window.onAppMintBle({kind, ...}).

**Example**

Sets the bike wheel size (circumference in millimetres) used to turn a speed sensor's wheel turns into km/h.

**Returns:** nothing. **Needs:** nothing to call it; the speed readings themselves need a connected cycling speed sensor (see `bleConnect`).

```js
// 2096 mm = 700x23c (the default). 2105 = 700x25c, 2136 = 700x28c, 2070 = 26x2.0 MTB.
function setWheel(mm) {
  if (window.WebToApk && typeof window.WebToApk.bleSetWheelCircumference === 'function') {
    window.WebToApk.bleSetWheelCircumference(mm);
  }
}

document.getElementById('wheel').addEventListener('change', function (e) {
  setWheel(parseInt(e.target.value, 10));
});
```

Apply the rider's saved wheel size every time you connect, since the setting does not survive a restart:

```js
function connectBike(address) {
  if (!window.WebToApk || typeof window.WebToApk.bleConnect !== 'function') return;
  var mm = parseInt(localStorage.getItem('wheelMm'), 10) || 2096;
  window.WebToApk.bleSetWheelCircumference(mm);
  window.WebToApk.bleConnect(address);
}
```

Calibrate from a measured roll-out (one full wheel turn, in cm) and check the range yourself, because an out-of-range value is ignored without an error:

```js
document.getElementById('save-rollout').addEventListener('click', function () {
  var mm = Math.round(parseFloat(document.getElementById('rollout-cm').value) * 10);
  if (!(mm >= 500 && mm <= 4000)) {
    showStatus('Enter a roll-out between 50 and 400 cm.');
    return;
  }
  localStorage.setItem('wheelMm', String(mm));
  if (window.WebToApk && typeof window.WebToApk.bleSetWheelCircumference === 'function') {
    window.WebToApk.bleSetWheelCircumference(mm);   // applies to the next speed reading
  }
  showStatus('Wheel set to ' + mm + ' mm');
});
```

**Notes:** Values outside 500–4000 mm are ignored silently (the old value stays). The setting lasts until the app closes, so set it again after each launch, for example right before `bleConnect()`. It changes `speedKmh` in the `speed_cadence` data of `appmint:ble`; cadence is not affected.

### `bleStartScan`

```js
window.WebToApk.bleStartScan()
```

*(BLE fitness sensors (heart rate / speed / cadence / power / battery))* Runtime for permBluetooth. No wizard switch sets it: the build arms it when the page's own code calls these methods (or Web Bluetooth). Before this runtime the permissions were declared while nothing in the runtime touched a sensor, the exact declared-but-dead pattern SmsBridge documents. Results stream as `appmint:ble` events / window.onAppMintBle({kind, ...}).

**Example**

Scans for nearby Bluetooth fitness sensors (heart rate, cycling speed/cadence, power, running footpods) for 15 seconds.

**Returns:** nothing. Results arrive as `appmint:ble` events (and `window.onAppMintBle(detail)`): one `{kind:'device', address, name, sensors, rssi}` per sensor found, plus `{kind:'state', state:'scanning'}` at the start and `{kind:'state', state:'scan_stopped'}` at the end. **Needs:** Bluetooth. There is no switch for it: the build turns Bluetooth on by itself when your code calls `bleStartScan` / `bleConnect` or uses `navigator.bluetooth`. The first scan asks the user for the Nearby devices permission (Location on Android 11 and older).

Listen first, then scan from a button tap:

```js
var found = {};

window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (d.kind === 'device') {
    found[d.address] = d;
    // d.sensors: any of 'heart_rate', 'speed_cadence', 'power', 'running'
    addRow(d.address, (d.name || 'Unnamed sensor') + ' ' + d.sensors.join(', ') + ' (' + d.rssi + ' dBm)');
  } else if (d.kind === 'state' && d.state === 'scanning') {
    showStatus('Looking for sensors...');
  } else if (d.kind === 'state' && d.state === 'scan_stopped') {
    showStatus(Object.keys(found).length ? 'Pick a sensor' : 'No sensors found. Wake the sensor and try again.');
  } else if (d.kind === 'error') {
    showStatus('Bluetooth error: ' + d.error);
  }
});

document.getElementById('scan').addEventListener('click', function () {
  if (window.WebToApk && typeof window.WebToApk.bleStartScan === 'function') {
    found = {};
    window.WebToApk.bleStartScan();
  } else {
    showStatus('Sensors work in the installed app only.');
  }
});
```

Pair the strongest heart-rate strap automatically when the scan ends:

```js
var best = null, pairing = false;

window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (!pairing) return;
  if (d.kind === 'device' && d.sensors.indexOf('heart_rate') !== -1) {
    if (!best || d.rssi > best.rssi) best = d;   // closer strap = higher (less negative) rssi
  } else if (d.kind === 'state' && d.state === 'scan_stopped') {
    pairing = false;
    if (best) window.WebToApk.bleConnect(best.address);
    else showStatus('No heart-rate strap found. Put it on and try again.');
  }
});

document.getElementById('pair-hr').addEventListener('click', function () {
  if (!window.WebToApk || typeof window.WebToApk.bleStartScan !== 'function') return;
  best = null;
  pairing = true;
  window.WebToApk.bleStartScan();
});
```

Turn each scan error into a message the user can act on:

```js
var SCAN_HELP = {
  not_enabled: 'This app was built without Bluetooth.',
  permission_denied: 'Allow Nearby devices for this app in Settings to find sensors.',
  bluetooth_unavailable: 'This phone has no Bluetooth.',
  bluetooth_off: 'Turn Bluetooth on, then tap Scan again.',
  scan_failed: 'The scan could not start. Wait a moment and try again.'
};

window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (d.kind !== 'error') return;
  var text = SCAN_HELP[d.error] || ('Bluetooth error: ' + d.error);
  if (d.code !== undefined) text += ' (code ' + d.code + ')';
  showStatus(text);
  document.getElementById('scan').disabled = false;
});
```

**Notes:** Error codes in `{kind:'error', error}`: `not_enabled` (the build has no Bluetooth), `permission_denied`, `bluetooth_unavailable` (no adapter), `bluetooth_off`, `scan_failed` (may carry the Android `code`). The scan only reports devices that advertise one of the four fitness services, and each device only once per scan. Starting a new scan stops the old one. Use either the `appmint:ble` listener or `window.onAppMintBle`, not both — both are called for every message.

### `bleStopScan`

```js
window.WebToApk.bleStopScan()
```

*(BLE fitness sensors (heart rate / speed / cadence / power / battery))* Runtime for permBluetooth. No wizard switch sets it: the build arms it when the page's own code calls these methods (or Web Bluetooth). Before this runtime the permissions were declared while nothing in the runtime touched a sensor, the exact declared-but-dead pattern SmsBridge documents. Results stream as `appmint:ble` events / window.onAppMintBle({kind, ...}).

**Example**

Stops a sensor scan started with `bleStartScan()` before its 15 seconds are up.

**Returns:** nothing. If a scan was running, an `appmint:ble` event `{kind:'state', state:'scan_stopped'}` follows. **Needs:** Bluetooth (turned on by the build when your code uses the sensor bridge); without it the call does nothing.

```js
window.addEventListener('appmint:ble', function (e) {
  if (e.detail.kind === 'state' && e.detail.state === 'scan_stopped') {
    document.getElementById('scan').disabled = false;
  }
});

document.getElementById('stop-scan').addEventListener('click', function () {
  if (window.WebToApk && typeof window.WebToApk.bleStopScan === 'function') {
    window.WebToApk.bleStopScan();
  }
});
```

Stop early as soon as the kind of sensor you need shows up:

```js
window.addEventListener('appmint:ble', function (e) {
  var d = e.detail;
  if (d.kind === 'device' && d.sensors.indexOf('power') !== -1) {
    if (window.WebToApk && typeof window.WebToApk.bleStopScan === 'function') {
      window.WebToApk.bleStopScan();   // no need to wait the full 15 seconds
    }
    showPowerMeter(d.address, d.name || 'Power meter');
  }
});
```

Stop scanning when the user closes the pairing sheet or leaves the app:

```js
function stopLooking() {
  if (window.WebToApk && typeof window.WebToApk.bleStopScan === 'function') {
    window.WebToApk.bleStopScan();   // safe even if the scan already ended
  }
}

document.getElementById('close-pairing').addEventListener('click', function () {
  stopLooking();
  document.getElementById('pairing-sheet').hidden = true;
});

document.addEventListener('visibilitychange', function () {
  if (document.hidden) stopLooking();
});
```

**Notes:** You rarely need this: the scan stops by itself after 15 seconds, and `bleConnect()` stops it too. Calling it when no scan is running is safe and sends no event.

### `cancelAllNotifications`

```js
window.WebToApk.cancelAllNotifications(): Boolean
```

*(Notification permission, cancelling and listing)* Reading and requesting the POST_NOTIFICATIONS grant, and managing notifications the page already scheduled. Posting and scheduling are documented on their own methods.

**Example**

Cancels every reminder this app has scheduled.

**Returns:** `true` when done; `false` only on an internal error. **Needs:** nothing extra.

```js
document.getElementById('reset-reminders').addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.cancelAllNotifications)) return;
  if (!confirm('Remove all your reminders?')) return;
  const ok = WebToApk.cancelAllNotifications();
  showMessage(ok ? 'All reminders removed' : 'Could not remove reminders');
});
```

On sign-out, stop the previous user's reminders and clear the icon count:

```js
async function signOut() {
  if (window.WebToApk && window.WebToApk.cancelAllNotifications) {
    WebToApk.cancelAllNotifications();
  }
  if ('clearAppBadge' in navigator) navigator.clearAppBadge().catch(function () {});
  await auth.signOut();
  showLoginScreen();
}
```

Rebuild the whole daily schedule from the saved times (cancel all, then add each one):

```js
function applyMedicineTimes(times) {        // e.g. ['08:00', '20:00']
  const b = window.WebToApk;
  if (!(b && b.cancelAllNotifications)) return;
  b.cancelAllNotifications();
  times.forEach(function (hhmm, i) {
    const parts = hhmm.split(':');
    const at = new Date();
    at.setHours(Number(parts[0]), Number(parts[1]), 0, 0);
    if (at.getTime() <= Date.now()) at.setDate(at.getDate() + 1);   // next occurrence
    b.scheduleNotification('med-' + i, 'Medicine', 'Time for your ' + hhmm + ' dose',
      String(at.getTime()), 'daily');
  });
}
```

**Notes:** it clears the scheduled list, so nothing fires later and nothing is set again after a restart. Notifications already showing in the tray stay there.

### `cancelNotification`

```js
window.WebToApk.cancelNotification(id: String): Boolean
```

*(Notification permission, cancelling and listing)* Reading and requesting the POST_NOTIFICATIONS grant, and managing notifications the page already scheduled. Posting and scheduling are documented on their own methods.

**Example**

Cancels one scheduled reminder by its id, so it will not fire again.

**Returns:** `true` (also when no reminder had that id); `false` only on an internal error. **Needs:** nothing extra (reminders need **⏰ Reminders** at build time).

```js
function stopWaterReminder() {
  if (!(window.WebToApk && window.WebToApk.cancelNotification)) return;
  WebToApk.cancelNotification('water');   // the id you gave scheduleNotification / scheduleNotificationEx

  // Check it is gone:
  const left = JSON.parse(WebToApk.getScheduledNotifications() || '[]');
  const stillThere = left.some(function (r) { return r.id === 'water'; });
  console.log('water reminder still scheduled?', stillThere);   // false
}
```

A reminder switch in settings that schedules or cancels by the same id:

```js
document.getElementById('water-toggle').addEventListener('change', function (e) {
  const b = window.WebToApk;
  if (!(b && b.cancelNotification && b.scheduleNotification)) return;
  if (e.target.checked) {
    b.scheduleNotification('water', 'Drink water', 'Time for a glass of water',
      String(Date.now() + 60 * 60 * 1000), 'hourly');
  } else {
    b.cancelNotification('water');
  }
});
```

When a task is deleted, cancel every reminder that belongs to it:

```js
function cancelTaskReminders(taskId) {
  const b = window.WebToApk;
  if (!(b && b.cancelNotification)) return;
  let list = [];
  try { list = JSON.parse(b.getScheduledNotifications() || '[]'); } catch (e) { list = []; }
  list
    .filter(function (r) { return r.id.indexOf('task-' + taskId + '-') === 0; })
    .forEach(function (r) { b.cancelNotification(r.id); });
}
```

**Notes:** this cancels a reminder that has not fired yet (one-off or repeating). It does not remove a notification that is already showing in the tray, and it does not affect notifications posted with `notify()` / `showNotification()`.

### `cancelVibrate`

```js
window.WebToApk.cancelVibrate()
```

*(Vibration)* Simple haptics for the page (navigator.vibrate and a pattern form). Both are ignored unless the build enabled the Vibrate permission.

**Example**

Stops a vibration that is still running. Most pages use the standard `navigator.vibrate(0)`, which calls this for you.

**Returns:** nothing. **Needs:** turn on **Vibrate** in Step 4 (Access) when you build.

The standard way:

```js
if ('vibrate' in navigator) navigator.vibrate(0);   // or navigator.vibrate([])
```

The raw call, for example to stop a long alarm pattern when the user taps "Dismiss":

```js
if (window.WebToApk && window.WebToApk.vibratePattern) {
  window.WebToApk.vibratePattern(JSON.stringify([1000, 500, 1000, 500, 1000]));
}

document.getElementById('dismiss').addEventListener('click', function () {
  if (window.WebToApk && typeof window.WebToApk.cancelVibrate === 'function') {
    window.WebToApk.cancelVibrate();
  }
});
```

Stop a running vibration when the user leaves the screen, so the phone does not keep buzzing in their pocket:

```js
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) return;
  if (window.WebToApk && typeof window.WebToApk.cancelVibrate === 'function') {
    window.WebToApk.cancelVibrate();
  }
});
```

**Notes:** Safe to call when nothing is vibrating.

### `canOpenAppAtTime`

```js
window.WebToApk.canOpenAppAtTime(): String
```

Whether a scheduled notification may put the APP ITSELF on screen (`openApp`). "granted"  — a full-screen alert will show, over the lock screen. "denied"   — Android 14+ withheld it; call requestOpenAppPermission(). There is no third answer where the app silently launches itself in the background: Android has blocked background activity starts since Android 10, and a full-screen intent is the whole of what remains.

**Example**

Tells you whether a reminder with `openApp: true` may put the app itself on screen (a full-screen alert, also over the lock screen).

**Returns:** a string, synchronously: `'granted'` or `'denied'`. Below Android 14 it is always `'granted'`. **Needs:** turn on **⏰ Reminders** in the Permissions step when you build.

```js
function prepareAlarm() {
  const b = window.WebToApk;
  if (!(b && b.canOpenAppAtTime)) return;
  if (b.canOpenAppAtTime() === 'denied') {
    showMessage('To open the app at alarm time, allow "full-screen notifications" for this app.');
    b.requestOpenAppPermission();
    return;
  }
  const t = new Date();
  t.setDate(t.getDate() + 1);
  t.setHours(7, 0, 0, 0);                    // tomorrow 07:00
  b.scheduleNotificationEx('wake-up', JSON.stringify({
    at: t.getTime(), repeat: 'daily',
    title: 'Wake up', body: 'Good morning!',
    openApp: true
  }));
}

window.addEventListener('appmint:reminder', function (e) {
  // e.detail.overLockScreen is true when the app was shown as a full-screen alert
  if (e.detail.overLockScreen) showBigAlarmScreen(e.detail.id);
});
```

A settings screen that shows both alarm switches with their current state:

```js
function renderAlarmStatus() {
  const b = window.WebToApk;
  if (!(b && b.canOpenAppAtTime && b.getAlarmPrecision)) return;
  const openApp = b.canOpenAppAtTime();       // 'granted' | 'denied'
  const precision = b.getAlarmPrecision();    // 'exact' | 'inexact' | 'unavailable'
  document.getElementById('open-app-state').textContent =
    openApp === 'granted' ? 'Opens over the lock screen' : 'Notification only';
  document.getElementById('open-app-fix').hidden = openApp === 'granted';
  document.getElementById('timing-state').textContent =
    precision === 'exact' ? 'On the minute' : 'May be late';
}

renderAlarmStatus();
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) renderAlarmStatus();  // back from Settings
});
```

Warn when saved alarms were set to open the app but Android does not allow it right now:

```js
function checkSavedAlarms() {
  const b = window.WebToApk;
  if (!(b && b.canOpenAppAtTime && b.getScheduledNotifications)) return;
  if (b.canOpenAppAtTime() === 'granted') return;
  // [{ id, title, body, trigger, repeat, options }] - options is the JSON string you scheduled
  const items = JSON.parse(b.getScheduledNotifications());
  const affected = items.filter(function (r) {
    try { return JSON.parse(r.options || '{}').openApp === true; } catch (e) { return false; }
  });
  if (affected.length) {
    showMessage(affected.length + ' alarm(s) will only show a notification. Tap "Allow" to let them open the app.');
  }
}
```

**Notes:** when it is `'denied'`, an `openApp` reminder still posts a heads-up notification, but the app does not open by itself. Android does not allow any other way for an app to start itself in the background.

### `castMedia`

```js
window.WebToApk.castMedia(mediaUrl: String)
```

Offers [mediaUrl] to whatever on this phone can play it on another screen — Chromecast, a Smart TV app, a local player — through the system chooser. The type is taken from the URL ([CastMediaType]): an audio file (.mp3, .m4a, .aac, .ogg, .opus, .wav, .flac …) or an audio `data:` URL is offered to audio apps, anything else — video files, stream manifests, extension-less URLs — to video apps. This is a hand-off, not a cast session: the app does not stay in control of playback, and there is no callback. A page that needs a real session should use the Cast SDK in JavaScript instead.

**Example**

Offers a video or audio address to the apps on the phone that can play it on another screen (a Chromecast app, a Smart TV app, a video player), through the Android "Cast to..." chooser.

**Returns:** nothing, and no callback. **Needs:** nothing to switch on. The phone needs an app that accepts the link.

```js
function castToTv(url) {
  if (window.WebToApk && typeof window.WebToApk.castMedia === 'function') {
    window.WebToApk.castMedia(url);          // opens the "Cast to..." chooser
  } else {
    alert('Casting is only available in the app');
  }
}

document.getElementById('cast-btn').addEventListener('click', function () {
  var video = document.querySelector('video');
  var url = video && video.currentSrc ? video.currentSrc : 'https://example.com/movie.mp4';
  castToTv(url);
});
```

Send a podcast episode from a list to a speaker or TV app (an `.mp3` link is offered to audio apps):

```js
// <li><button class="cast" data-src="https://example.com/ep12.mp3">Cast</button> Episode 12</li>
document.getElementById('episodes').addEventListener('click', function (e) {
  var btn = e.target.closest('button.cast');
  if (!btn) return;
  var src = btn.getAttribute('data-src') || '';
  if (src.indexOf('https://') !== 0) {
    alert('This episode cannot be cast');   // files inside the app cannot be handed over
    return;
  }
  if (window.WebToApk && typeof window.WebToApk.castMedia === 'function') {
    window.WebToApk.castMedia(src);
  }
});
```

Show the Cast button only inside the app, and pause the page's own player before handing a live stream over:

```js
var castBtn = document.getElementById('cast-live');
var canCast = !!(window.WebToApk && typeof window.WebToApk.castMedia === 'function');
castBtn.hidden = !canCast;

castBtn.addEventListener('click', function () {
  var video = document.getElementById('live');
  video.pause();                                   // avoid playing twice, here and on the TV
  window.WebToApk.castMedia('https://example.com/live/stream.m3u8');   // offered to video apps
});
```

**Notes:**
- This is a hand-off, not a cast session: your page does not control playback after the user picks an app.
- The address must be a full `https://` link that other apps can open. A file inside your app ZIP, a `blob:` URL or a web page address will not play.
- The type comes from the link: an audio file (`.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.wav`, `.flac` …) or an `audio/` `data:` URL is offered to audio apps; anything else — video files, `.m3u8` streams, links without a file extension — to video apps.

### `clearFolderAccess`

```js
window.WebToApk.clearFolderAccess()
```

Releases the persisted folder grant. Refused (no-op) for a foreign page.

**Example**

Forgets the granted folder and gives the permission back to Android.

**Returns:** nothing (synchronous). `WebToApkFS.clearAccess()` does the same. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access); with the switch off it does nothing.

A "Disconnect" button:

```js
document.getElementById('disconnect').addEventListener('click', function () {
  if (window.WebToApkFS) {
    window.WebToApkFS.clearAccess();
  } else if (window.WebToApk && typeof window.WebToApk.clearFolderAccess === 'function') {
    window.WebToApk.clearFolderAccess();
  }
  document.getElementById('folder-name').textContent = 'No folder chosen';
});
```

On sign-out, forget the folder too, so the next person on this phone chooses their own:

```js
function signOut() {
  localStorage.clear();
  if (window.WebToApk && typeof window.WebToApk.clearFolderAccess === 'function') {
    window.WebToApk.clearFolderAccess();
  }
  location.href = 'login.html';
}
```

Forget a folder that was moved or deleted in the Files app, then offer to choose again (the app writes `myapp-folder.json` when a folder is first connected):

```js
async function reconnectIfMissing() {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) return;
  if (fs.stat('myapp-folder.json').ok) return;          // marker still there: the folder is fine
  if (typeof window.WebToApk.clearFolderAccess === 'function') {
    window.WebToApk.clearFolderAccess();                // drop the grant that points nowhere
  }
  if (confirm('The folder you chose earlier is gone. Choose a folder again?')) {
    var r = await fs.requestAccess();
    if (r.success) fs.writeText('myapp-folder.json', '{}');
  }
}
```

**Notes:** Files in the folder are NOT deleted; the app just cannot reach them any more. After this, `hasFolderAccess()` answers `"false"` and every file method fails until the user chooses a folder again.

### `clearMediaNotification`

```js
window.WebToApk.clearMediaNotification()
```

*(Media Notification Bridge)* Lets web content drive the Android media-style notification and receive transport control callbacks (play/pause/next/prev) from the lock screen and notification shade.  Usage from JavaScript:   window.WebToApk.setMediaMetadata('Song Title', 'Artist Name', 'https://…/art.jpg')   window.WebToApk.setPlaybackState(true, 30000, 240000)   // playing, pos ms, dur ms   window.WebToApk.clearMediaNotification()  Callback from Android → JS:   window.WebToApkOnMediaAction('play'|'pause'|'next'|'prev'|'stop')

**Example**

Removes the media notification and its lock-screen controls, for example when the user closes your player.

**Returns:** nothing. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. Without it the call does nothing.

There is no standard web call for this, so use the bridge:

```js
function closePlayer() {
  var audio = document.getElementById('player');
  audio.pause();
  audio.currentTime = 0;
  if (window.WebToApk && typeof window.WebToApk.clearMediaNotification === 'function') {
    window.WebToApk.clearMediaNotification();
  }
}

document.getElementById('close-player').addEventListener('click', closePlayer);

// The notification's own Stop button already removes it; just stop your audio:
if ('mediaSession' in navigator) {
  navigator.mediaSession.setActionHandler('stop', function () {
    var audio = document.getElementById('player');
    audio.pause();
    audio.currentTime = 0;
  });
}
```

A sleep timer that stops the audio and removes the notification after the chosen number of minutes:

```js
var sleepTimer = null;
function setSleepTimer(minutes) {
  clearTimeout(sleepTimer);
  sleepTimer = setTimeout(function () {
    var audio = document.getElementById('player');
    audio.pause();
    if (window.WebToApk && typeof window.WebToApk.clearMediaNotification === 'function') {
      window.WebToApk.clearMediaNotification();
    }
  }, minutes * 60 * 1000);
}
document.getElementById('sleep-30').addEventListener('click', function () { setSleepTimer(30); });
```

Remove the notification when the last track of a playlist has finished, instead of leaving a Play button for nothing:

```js
var audio = document.getElementById('player');
var playlist = ['https://example.com/1.mp3', 'https://example.com/2.mp3'];
var current = 0;

audio.addEventListener('ended', function () {
  current++;
  if (current < playlist.length) {
    audio.src = playlist[current];
    audio.play();
  } else if (window.WebToApk && typeof window.WebToApk.clearMediaNotification === 'function') {
    window.WebToApk.clearMediaNotification();
  }
});
```

**Notes:** Use this for "stop", not for "pause" (for pause, send `setPlaybackState(false, ...)` so the Play button stays). Setting `navigator.mediaSession.metadata = null` does not remove the notification. The next `setMediaMetadata` / `setPlaybackState` (or new `navigator.mediaSession` metadata / state) brings the notification and the lock-screen controls back.

### `clearNotificationSounds`

```js
window.WebToApk.clearNotificationSounds(): Int
```

Drops the copied sound files and returns how many were deleted. A file is copied again when a notification (or a scheduled reminder, when it fires) next uses that sound — including on a channel that already exists, which keeps pointing at the same file name (NotificationBridge.restoreBundleSound).

**Example**

Deletes the copies of your custom notification sounds that the app made from your bundle.

**Returns:** a number, synchronously: how many files were deleted. **Needs:** nothing.

**Clear, then put back the sounds you still use:**

```js
document.getElementById('reset-sounds').addEventListener('click', function () {
  const b = window.WebToApk;
  if (!(b && b.clearNotificationSounds)) return;
  const removed = b.clearNotificationSounds();

  // Sounds still in use must be copied again, or they play the default sound:
  ['/audio/alarm.mp3', '/audio/chime.mp3'].forEach(function (path) {
    if (!b.prepareNotificationSound(path)) console.warn('missing sound', path);
  });
  showMessage(removed + ' sound file(s) refreshed');
});
```

A storage screen button that frees the copied sound files and says how many were removed:

```js
document.getElementById('free-space').addEventListener('click', function () {
  const b = window.WebToApk;
  if (!(b && b.clearNotificationSounds)) return;
  const removed = b.clearNotificationSounds();
  showMessage(removed === 0 ? 'No sound files to remove.' : 'Removed ' + removed + ' sound file(s).');
});
```

On sign-out, cancel every reminder and delete the sound copies that belonged to this user:

```js
function signOutAndReset() {
  const b = window.WebToApk;
  if (b && b.cancelAllNotifications) b.cancelAllNotifications();
  if (b && b.clearNotificationSounds) b.clearNotificationSounds();
  localStorage.removeItem('alarmSound');
  goToLoginScreen();
}
```

**Notes:** you rarely need this; the copies are small. A sound is copied back the next time anything uses it — `notify()`, a reminder when it fires, `scheduleNotificationEx`, or `prepareNotificationSound(path)` — including on a notification channel that already exists, so the custom sound keeps playing.

### `clearPushInbox`

```js
window.WebToApk.clearPushInbox(): Boolean
```

Empties the push inbox (the tray and server history are untouched).

**Example**

Empties the push inbox on this phone.

**Returns:** `true` when done; `false` on an internal error. **Needs:** turn on **Appwright Push** in the Integrate step when you build.

```js
document.getElementById('clear-inbox').addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.clearPushInbox)) return;
  if (!confirm('Delete all messages? This cannot be undone.')) return;
  if (WebToApk.clearPushInbox()) renderInbox();
});
```

Empty the inbox when the user signs out, so the next person on this phone does not see their messages:

```js
async function signOut() {
  await myAuth.signOut();
  if (window.WebToApk && window.WebToApk.clearPushInbox) {
    WebToApk.clearPushInbox();
  }
  document.getElementById('bell-count').textContent = '';
  showLoginScreen();
}
```

Show a settings row with how many messages are stored, disabled when there is nothing to delete:

```js
function paintClearRow() {
  const btn = document.getElementById('clear-inbox');
  if (!(window.WebToApk && window.WebToApk.clearPushInbox)) { btn.hidden = true; return; }
  const count = JSON.parse(WebToApk.getPushInbox() || '[]').length;
  btn.textContent = count ? 'Delete ' + count + ' messages' : 'No messages';
  btn.disabled = count === 0;
}

document.getElementById('clear-inbox').addEventListener('click', function () {
  if (WebToApk.clearPushInbox()) paintClearRow();
  else alert('Could not delete the messages. Please try again.');
});

paintClearRow();
```

**Notes:** this is permanent — unlike `dismissPushMessage`, there is no undo. Tray notifications and your send history in Appwright are not touched. New messages still arrive.

### `clearSharedText`

```js
window.WebToApk.clearSharedText()
```

Clears the kept share once the page has consumed it.

**Example**

Forgets the text or link that was shared into the app, once your page has used it.

**Returns:** nothing. **Needs:** nothing (the share itself needs **Receive Shares From Other Apps** with **Text and links** ticked).

Read the kept share, use it, then clear it so it is not handled again after a reload:

```js
if (window.WebToApk && typeof window.WebToApk.getSharedText === 'function') {
  var raw = window.WebToApk.getSharedText();
  if (raw) {
    var shared = JSON.parse(raw);          // { text, subject, url, receivedAt }
    saveNote(shared.subject, shared.text);
    window.WebToApk.clearSharedText();     // getSharedText() now returns ""
  }
}

window.addEventListener('appmint:shared', function (e) {
  saveNote(e.detail.subject, e.detail.text);
  if (window.WebToApk && window.WebToApk.clearSharedText) window.WebToApk.clearSharedText();
});
```

Ask first, and clear the share whichever button the user taps:

```js
function askAboutShare(s) {
  var dialog = document.getElementById('share-dialog');
  document.getElementById('share-preview').textContent = s.text;
  dialog.hidden = false;
  function done() {
    dialog.hidden = true;
    if (window.WebToApk && typeof window.WebToApk.clearSharedText === 'function') {
      window.WebToApk.clearSharedText();
    }
  }
  document.getElementById('share-save').onclick = function () { saveNote(s.subject, s.text); done(); };
  document.getElementById('share-discard').onclick = done;
}
```

Clear it only once it is safely saved, so a failed upload can be retried:

```js
async function importShare() {
  if (!(window.WebToApk && typeof window.WebToApk.getSharedText === 'function')) return;
  var raw = window.WebToApk.getSharedText();
  if (!raw) return;
  var s = JSON.parse(raw);
  try {
    var res = await fetch('/api/bookmarks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: s.url || s.text, title: s.subject })
    });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    window.WebToApk.clearSharedText();
  } catch (e) {
    showMessage('Could not save the link — tap Retry');   // Retry calls importShare() again
  }
}
```

**Notes:** Clearing only affects `getSharedText()`. A new share replaces the kept one and fires `appmint:shared` again. Files shared into the app are released separately with `AppMint.releaseOpenedFile()`.

### `closeWindow`

```js
window.WebToApk.closeWindow()
```

*(Share, clipboard and window)* The Android share sheet, the system clipboard, and closing a window the page opened. These are the plain-web equivalents (navigator.share, navigator.clipboard, window.close) answered natively so they behave the same inside the app.

**Example**

Closes the app's main screen (the same as pressing Back on the last page). Pages normally call the standard `window.close()`, which the shell routes here.

**Returns:** nothing. **Needs:** nothing.

```js
document.getElementById('quit').onclick = function () {
  if (confirm('Close the app?')) window.close();   // in the app: the screen closes
};
```

The raw call does the same thing:

```js
if (window.WebToApk && typeof window.WebToApk.closeWindow === 'function') {
  window.WebToApk.closeWindow();
}
```

An "Exit" button that saves first, and is hidden in a browser where there is no app screen to close:

```js
var exitBtn = document.getElementById('exit-btn');

if (!(window.WebToApk && typeof window.WebToApk.closeWindow === 'function')) {
  exitBtn.hidden = true;
} else {
  exitBtn.onclick = async function () {
    exitBtn.disabled = true;
    await saveDraft();                 // your own save; closing does not wait for anything
    window.WebToApk.closeWindow();
  };
}
```

**Notes:** It finishes the app's screen at once — no exit confirmation, even if the build has one. Save anything unsaved first. In a browser `window.close()` only closes a tab the script opened itself.

### `composeSms`

```js
window.WebToApk.composeSms(phoneNumber: String, message: String): Boolean
```

Opens the user's SMS app with the message prefilled. Needs no permission and cannot be rejected by Play — the right default for anything non-automated. Returns false when the phone has no SMS app (or it could not be opened).

**Example**

Opens the user's SMS app with the number and the text already filled in. The user presses send.

**Returns:** `true` when the SMS app opened, `false` when the phone has no SMS app or it could not be opened (synchronous). **Needs:** nothing — no build switch, no permission.

**A "Text us" button.**

```js
document.getElementById('textUs').addEventListener('click', function () {
  if (window.WebToApk && window.WebToApk.composeSms) {
    var ok = WebToApk.composeSms('+911234567890', 'Hello, I need help with my order');
    if (!ok) showMessage('Could not open the SMS app.');
  } else {
    // In a normal browser the standard sms: link does the same job.
    location.href = 'sms:+911234567890?body=' + encodeURIComponent('Hello, I need help with my order');
  }
});
```

A "Remind" button on each appointment row, prefilling a reminder to that customer's number:

```js
document.getElementById('appointments').addEventListener('click', function (e) {
  var row = e.target.closest('[data-phone]');
  if (!row || !(window.WebToApk && typeof window.WebToApk.composeSms === 'function')) return;
  var when = new Date(Number(row.getAttribute('data-time')));
  var text = 'Hi ' + row.getAttribute('data-name') + ', a reminder of your appointment on ' +
             when.toLocaleDateString() + ' at ' +
             when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + '.';
  if (!WebToApk.composeSms(row.getAttribute('data-phone'), text)) {
    showMessage('This phone has no SMS app.');
  }
});
```

A "Send by SMS" form: when the phone has no SMS app (`false`), copy the text so the user can paste it elsewhere:

```js
document.getElementById('smsForm').addEventListener('submit', function (e) {
  e.preventDefault();
  var number = document.getElementById('to').value.trim();
  var text = document.getElementById('message').value;
  if (!number || !text) return;

  var opened = window.WebToApk && typeof window.WebToApk.composeSms === 'function' &&
               WebToApk.composeSms(number, text);
  if (!opened && navigator.clipboard) {
    navigator.clipboard.writeText(text).then(function () {
      showMessage('No SMS app here. The message was copied instead.');
    });
  }
});
```

**Notes:** Nothing is sent until the user taps send in their own SMS app, so this method itself needs no SMS permission. Use this for every message the user writes or approves; use `sendSms` only when the app really must send by itself. The build scans your code, and a `composeSms(` call does not switch on **SMS (read & send)** — only `sendSms` and `listSms` do — so a "Text us" button adds no Play-restricted permission.

### `copyToClipboard`

```js
window.WebToApk.copyToClipboard(text: String): Boolean
```

*(Share, clipboard and window)* The Android share sheet, the system clipboard, and closing a window the page opened. These are the plain-web equivalents (navigator.share, navigator.clipboard, window.close) answered natively so they behave the same inside the app.

**Example**

Copies plain text to the phone's clipboard. Pages normally use the standard `navigator.clipboard.writeText(text)`, which the shell routes here.

**Returns:** `true` on success, `false` on failure (synchronously). **Needs:** nothing.

The standard API first:

```js
document.getElementById('copy-btn').onclick = async function () {
  try {
    await navigator.clipboard.writeText('PROMO-2026');
    showMessage('Copied');
  } catch (e) {
    showMessage('Could not copy');
  }
};
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.copyToClipboard === 'function') {
  var ok = window.WebToApk.copyToClipboard('PROMO-2026');
  showMessage(ok ? 'Copied' : 'Could not copy');
}
```

A "Copy" button on every code block, using the synchronous answer to update the label:

```js
document.querySelectorAll('pre').forEach(function (pre) {
  var btn = document.createElement('button');
  btn.textContent = 'Copy';
  btn.onclick = function () {
    var ok = !!(window.WebToApk && typeof window.WebToApk.copyToClipboard === 'function' &&
                window.WebToApk.copyToClipboard(pre.innerText));
    btn.textContent = ok ? 'Copied' : 'Copy failed';
    setTimeout(function () { btn.textContent = 'Copy'; }, 1500);
  };
  pre.parentNode.insertBefore(btn, pre);
});
```

**Notes:** Plain text only (no images or HTML). Android 13+ shows its own small "Copied" preview, so you may not need your own message there.

### `decryptWithKey`

```js
window.WebToApk.decryptWithKey(alias: String, base64Payload: String): String
```

Reverses [encryptWithKey]. Fails with `error:"no-such-key"` once the key has been deleted — the payload is then unrecoverable by design. @return JSON `{ok, base64, error}`.

**Example**

Decrypts data made by `encryptWithKey` with the same Keystore key. Most pages use `AppMintKeystore.decrypt(alias, base64)`, which returns the parsed object.

**Returns:** a JSON string, at once: `{"ok":true,"base64":"<plain data>"}` or `{"ok":false,"error":"...","detail":"..."}`. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function fromBase64(b64) { return decodeURIComponent(escape(atob(b64))); }

function openNote() {
  if (!window.WebToApk || typeof window.WebToApk.decryptWithKey !== 'function') return null;
  var sealed = localStorage.getItem('note');
  if (!sealed) return null;
  var r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealed));
  if (r.ok) return fromBase64(r.base64);

  if (r.error === 'auth-required') {
    askUserToUnlockThenRetry();          // unlock with authenticateBiometricEx, then call again
  } else if (r.error === 'no-such-key' || r.error === 'key-invalidated') {
    alert('This note can no longer be opened.');   // the key was deleted or the screen lock changed
  } else {
    alert('Could not open the note: ' + r.error);
  }
  return null;
}
```

For a key made with `requireAuth:true`: on `auth-required`, show the unlock prompt, then decrypt again:

```js
function unlock() {
  return new Promise(function (resolve) {
    var id = 'unlock_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometricEx(id, JSON.stringify({
      title: 'Unlock your notes', strong: true, allowDeviceCredential: true
    }));
  });
}

async function openNoteWithUnlock() {
  if (!window.WebToApk || typeof window.WebToApk.decryptWithKey !== 'function') return null;
  var sealed = localStorage.getItem('note');
  if (!sealed) return null;
  var r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealed));
  if (!r.ok && r.error === 'auth-required') {
    var auth = await unlock();
    if (!auth.ok) return null;                      // cancelled or failed
    r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealed));
  }
  return r.ok ? decodeURIComponent(escape(atob(r.base64))) : null;
}
```

Show an encrypted photo: decrypt to bytes, then display it through a blob URL:

```js
function showSealedPhoto(sealedB64, img) {
  if (!window.WebToApk || typeof window.WebToApk.decryptWithKey !== 'function') return;
  var r = JSON.parse(window.WebToApk.decryptWithKey('vault', sealedB64));
  if (!r.ok) {
    alert('Could not open the photo: ' + r.error);
    return;
  }
  var bin = atob(r.base64);
  var bytes = new Uint8Array(bin.length);
  for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  var url = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' }));
  img.onload = function () { URL.revokeObjectURL(url); };
  img.src = url;
}
```

**Notes:** Once the key is deleted (`deleteKey`) the data can never be decrypted again — by design. A changed or damaged payload fails (the GCM tag does not match); the `error` is then the platform's message, not one of the codes above. Other codes: `bad base64`, `disabled`, `foreign_origin`.

### `deleteEntry`

```js
window.WebToApk.deleteEntry(relativePath: String, recursive: String): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Deletes a file or folder inside the granted folder.

**Returns:** the STRING `"true"` or `"false"`, synchronously. `WebToApkFS.delete(path, { recursive })` returns a real boolean. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access) and a granted folder.

With the helper, after asking the user:

```js
function removeEntry(path, isFolder) {
  if (!window.WebToApkFS) return false;
  if (!confirm('Delete ' + path + '? This cannot be undone.')) return false;
  return window.WebToApkFS.delete(path, { recursive: isFolder });
}

removeEntry('notes/old.txt', false);
removeEntry('notes/2025', true);      // the folder and everything in it
```

The raw call — the second argument is the STRING `'true'` or `'false'`:

```js
if (window.WebToApk && typeof window.WebToApk.deleteEntry === 'function') {
  var gone = window.WebToApk.deleteEntry('cache', 'true') === 'true';
}
```

Keep only the five newest backups and delete the rest:

```js
function pruneBackups(keep) {
  var fs = window.WebToApkFS;
  if (!fs) return 0;
  var files = fs.list('backups').filter(function (e) { return !e.isDirectory; });
  files.sort(function (a, b) { return b.lastModified - a.lastModified; });   // newest first
  var removed = 0;
  files.slice(keep).forEach(function (e) {
    if (fs.delete('backups/' + e.name)) removed++;
  });
  return removed;
}

console.log(pruneBackups(5) + ' old backups deleted');
```

**Notes:** A folder that is not empty is only deleted with recursive `'true'`. The granted folder itself (`''`) can never be deleted. Deleting is real and immediate: there is no trash.

### `deleteKey`

```js
window.WebToApk.deleteKey(alias: String): String
```

Destroys [alias] permanently. Everything encrypted with it becomes undecryptable — including by this app, and by anyone holding the device.

**Example**

Destroys a Keystore key for good. Everything encrypted with it can never be read again — by you, the user or anyone holding the phone. Most pages use `AppMintKeystore.deleteKey(alias)`, which returns a real boolean.

**Returns:** the STRING `"true"` (deleted) or `"false"` (no such key, Secure Keys off, or not the app's own page), at once. **Needs:** **Secure Keys (encryption)** turned on when you build.

This is the only real "secure delete" on a phone: overwriting a file does not erase it on flash memory, destroying the key does.

```js
function destroyVault() {
  if (!window.WebToApk || typeof window.WebToApk.deleteKey !== 'function') return;
  if (!confirm('Delete the vault? This cannot be undone.')) return;

  var gone = window.WebToApk.deleteKey('vault') === 'true';
  localStorage.removeItem('note');              // the sealed data is now useless anyway
  alert(gone ? 'Vault deleted.' : 'There was no vault to delete.');
}
```

Rotate a key: open the data with the old key, seal it with a new one, then destroy the old key:

```js
function rotateVault() {
  var W = window.WebToApk;
  if (!W || typeof W.deleteKey !== 'function') return false;
  var sealed = localStorage.getItem('note');
  if (!sealed) return false;

  var clear = JSON.parse(W.decryptWithKey('vault', sealed));
  if (!clear.ok) return false;
  var made = JSON.parse(W.generateKey('vault2', '{}'));
  if (!made.ok) return false;
  var fresh = JSON.parse(W.encryptWithKey('vault2', clear.base64));
  if (!fresh.ok) return false;

  localStorage.setItem('note', fresh.base64);
  localStorage.setItem('noteKey', 'vault2');
  return W.deleteKey('vault') === 'true';          // the old copies are dead from here on
}
```

"Forget me on this phone" at sign-out: destroy the account's key, then drop its cached data:

```js
function signOutAndForget(userId) {
  var alias = 'user_' + userId;
  if (window.WebToApk && typeof window.WebToApk.deleteKey === 'function') {
    var gone = window.WebToApk.deleteKey(alias) === 'true';
    console.log(gone ? 'Account key destroyed' : 'No key for this account');
  }
  localStorage.removeItem('messages_' + userId);   // sealed with that key, unreadable now
  signOut();
}
```

**Notes:** Check `isKeyHardwareBacked` before you promise a user that deletion is irreversible — a software-only key is weaker.

### `dismissPushMessage`

```js
window.WebToApk.dismissPushMessage(id: String): Boolean
```

Dismisses ONE message from the inbox — the per-row swipe-away an inbox screen needs (clearPushInbox is all-or-nothing). REVERSIBLE by design: the entry is hidden, not destroyed, so the UI can offer "Undo" for a few seconds after the swipe. Local only: the tray notification and the creator's history are untouched.

**Example**

Hides one message from the push inbox (a swipe-away). It can be undone with `restorePushMessage(id)`.

**Returns:** `true` if the message was hidden; `false` if the id is unknown or it was already hidden. **Needs:** turn on **Appwright Push** in the Integrate step when you build.

**Swipe away with a 5-second Undo:**

```js
function dismissRow(id, rowEl) {
  if (!(window.WebToApk && window.WebToApk.dismissPushMessage)) return;
  if (!WebToApk.dismissPushMessage(id)) return;
  rowEl.remove();

  const bar = document.getElementById('undo-bar');
  bar.hidden = false;
  const timer = setTimeout(function () { bar.hidden = true; }, 5000);
  document.getElementById('undo-btn').onclick = function () {
    clearTimeout(timer);
    bar.hidden = true;
    WebToApk.restorePushMessage(id);         // back in its old place
    renderInbox();
  };
}
```

Tidy the inbox when it opens by hiding messages that are read and older than 30 days:

```js
function hideOldReadMessages() {
  if (!(window.WebToApk && window.WebToApk.dismissPushMessage)) return 0;
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
  let hidden = 0;
  JSON.parse(WebToApk.getPushInbox() || '[]').forEach(function (m) {
    if (m.read && m.receivedAt < cutoff && WebToApk.dismissPushMessage(m.id)) hidden++;
  });
  return hidden;
}

if (hideOldReadMessages() > 0) renderInbox();
```

Dismiss a row with a real finger swipe (drag it more than 40% of its width):

```js
function makeSwipeable(rowEl, id) {
  let startX = null;
  rowEl.addEventListener('pointerdown', function (e) { startX = e.clientX; rowEl.setPointerCapture(e.pointerId); });
  rowEl.addEventListener('pointermove', function (e) {
    if (startX !== null) rowEl.style.transform = 'translateX(' + (e.clientX - startX) + 'px)';
  });
  rowEl.addEventListener('pointerup', function (e) {
    const dx = startX === null ? 0 : e.clientX - startX;
    startX = null;
    if (Math.abs(dx) > rowEl.offsetWidth * 0.4 && window.WebToApk && WebToApk.dismissPushMessage(id)) {
      rowEl.remove();
    } else {
      rowEl.style.transform = '';            // not far enough: snap back
    }
  });
}
```

**Notes:** only this phone's inbox changes. The tray notification and your send history in Appwright stay. Hidden messages are not returned by `getPushInbox()`.

### `enableNotifications`

```js
window.WebToApk.enableNotifications(callbackId: String)
```

Brings a user back to notifications: the system dialog while it can still be shown, otherwise the app's notification settings. Resolves `window.__appmintReplies[callbackId]` with {state, changed, openedSettings} (AppMint.enableNotifications() is the promise wrapper).

**Example**

Brings a user who denied or skipped notifications back: shows Android's permission dialog while it can still be shown, otherwise opens this app's notification settings and answers when the user comes back.

**Returns:** nothing directly. The answer `{state, changed, openedSettings}` is delivered to `window.__appmintReplies[callbackId]`; `state` is `'granted' | 'prompt' | 'blocked' | 'unavailable'`, `changed` is `true` when it differs from before the call, `openedSettings` is `true` when the settings screen was used. Already `'granted'` (or `'unavailable'`) answers at once. **Needs:** call it from a button the user tapped; notifications need **Notifications** (or Push / Reminders) in the build.

Prefer the promise wrapper `AppMint.enableNotifications()`. The raw call, for code that manages its own replies:

```js
function enableNotificationsRaw() {
  return new Promise(function (resolve) {
    if (!(window.WebToApk && WebToApk.enableNotifications)) return resolve({ state: 'unavailable', changed: false, openedSettings: false });
    var replies = window.__appmintReplies = window.__appmintReplies || {};
    var id = 'my_notif_' + Date.now();
    replies[id] = resolve;
    WebToApk.enableNotifications(id);
  });
}

document.getElementById('turn-on-btn').onclick = async function () {
  var r = await enableNotificationsRaw();
  if (r.state === 'granted') document.getElementById('notif-banner').hidden = true;
};
```

Every finished flow is also announced, including one started by a push button (`appmint://notifications/enable`):

```js
window.addEventListener('appmint:notifications', function (e) {
  // e.detail = { state, changed, openedSettings }
  if (e.detail.state === 'granted') document.getElementById('notif-banner').hidden = true;
});
```

**Notes:** a push or in-app message button whose link is `appmint://notifications/enable` runs this same flow; `appmint://notifications/settings` opens the settings screen. The app's push audience sees the new state at once.

### `encryptWithKey`

```js
window.WebToApk.encryptWithKey(alias: String, base64Plaintext: String): String
```

Encrypts base64 [base64Plaintext] with [alias]. Each call generates its own IV, so a large file is encrypted chunk by chunk — chunks are independent and may be decrypted in any order. @return JSON `{ok, base64, error}` where `base64` is `IV || ciphertext || tag`.

**Example**

Encrypts base64 data with a Keystore key. Most pages use `AppMintKeystore.encrypt(alias, base64)`, which returns the parsed object.

**Returns:** a JSON string, at once: `{"ok":true,"base64":"<IV + ciphertext + tag>"}` or `{"ok":false,"error":"...","detail":"..."}`. **Needs:** **Secure Keys (encryption)** turned on when you build, and a key made with `generateKey`.

The input is base64. For text, turn it into UTF-8 base64 first:

```js
function toBase64(text) { return btoa(unescape(encodeURIComponent(text))); }

function sealNote(text) {
  if (!window.WebToApk || typeof window.WebToApk.encryptWithKey !== 'function') return null;
  var r = JSON.parse(window.WebToApk.encryptWithKey('vault', toBase64(text)));
  if (r.ok) {
    localStorage.setItem('note', r.base64);   // safe to store anywhere: useless without the key
    return r.base64;
  }
  if (r.error === 'auth-required') {
    // key was made with requireAuth — unlock first (authenticateBiometricEx), then call again
    askUserToUnlockThenRetry();
  } else {
    alert('Could not encrypt: ' + r.error);
  }
  return null;
}
```

Seal a whole object (a session, some settings) as JSON before you store it:

```js
function sealJson(alias, obj) {
  if (!window.WebToApk || typeof window.WebToApk.encryptWithKey !== 'function') return null;
  var b64 = btoa(unescape(encodeURIComponent(JSON.stringify(obj))));
  var r = JSON.parse(window.WebToApk.encryptWithKey(alias, b64));
  if (!r.ok) console.warn('encrypt failed: ' + r.error);
  return r.ok ? r.base64 : null;
}

var sealed = sealJson('vault', { token: accessToken, savedAt: Date.now() });
if (sealed) localStorage.setItem('session', sealed);
```

Encrypt a photo the user picks (binary data, up to 4 MB per call):

```js
document.getElementById('id-photo').addEventListener('change', async function (e) {
  var file = e.target.files[0];
  if (!file || !window.WebToApk || typeof window.WebToApk.encryptWithKey !== 'function') return;
  var bytes = new Uint8Array(await file.arrayBuffer());
  var bin = '';
  for (var i = 0; i < bytes.length; i += 0x8000) {
    bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  }
  var r = JSON.parse(window.WebToApk.encryptWithKey('vault', btoa(bin)));
  if (r.ok) {
    saveSealedPhoto(file.name, r.base64);          // your own storage
  } else if (r.error.indexOf('chunk too large') === 0) {
    alert('That photo is too big. Pick one under 4 MB.');
  } else {
    alert('Could not encrypt the photo: ' + r.error);
  }
});
```

**Notes:** Each call uses a fresh IV, so the same text gives a different result every time. At most 4 MB per call (`error:"chunk too large ..."`); for bigger files encrypt chunk by chunk, or use `WebToApkFS.writeSealed`. Error codes: `no-such-key`, `auth-required`, `key-invalidated` (the user changed the screen lock — the key is gone for good), `bad base64`, `disabled`, `foreign_origin`.

### `enterPip`

```js
window.WebToApk.enterPip(): Boolean
```

Shrinks the whole app into a floating picture-in-picture window right now — a video or a call the user wants to keep watching while they leave. Android 8+ and only when the build enabled PiP (the manifest flag it needs); returns whether the window opened. Leaving the app with PiP enabled does this by itself; this is the explicit "minimise" button.

**Example**

Shrinks the whole app into a small floating picture-in-picture window right now — a "minimise" button for a video or a call.

**Returns:** `true` if the floating window opened, `false` otherwise (synchronously). **Needs:** **Picture-in-Picture (PiP)** switched on in Step 2 (Display) when you build. Android 8+.

```js
var pipBtn = document.getElementById('pip-btn');
pipBtn.onclick = function () {
  if (!(window.WebToApk && typeof window.WebToApk.enterPip === 'function')) return;
  var ok = window.WebToApk.enterPip();
  if (!ok) pipBtn.hidden = true;   // PiP not enabled in this build, or Android 7 and older
};
```

Make the page fit the small window: the window gets very narrow, so a `resize` listener can hide everything but the video.

```js
window.addEventListener('resize', function () {
  var small = window.innerWidth < 300;
  document.querySelector('nav').style.display = small ? 'none' : '';
});
```

Follow the floating window opening and closing with the `appmint:pip` event (`detail.inPip`), for example to hide a call's controls:

```js
window.addEventListener('appmint:pip', function (e) {
  var inPip = !!(e.detail && e.detail.inPip);
  document.body.classList.toggle('pip', inPip);   // CSS: .pip .call-controls { display: none; }
  if (!inPip) {
    document.getElementById('chat-panel').hidden = false;   // back to the full-size screen
  }
});

document.getElementById('minimise').onclick = function () {
  if (window.WebToApk && typeof window.WebToApk.enterPip === 'function') {
    document.getElementById('chat-panel').hidden = true;
    window.WebToApk.enterPip();
  }
};
```

**Notes:** With PiP enabled, pressing Home already enters the floating window by itself; this method is only for an explicit button. It returns `false` in a build without the PiP switch, because the manifest flag it needs is missing. The PiP window size is chosen by Android. AI-built apps can use `pip` from `@/lib/appmintNative`.

### `generateKey`

```js
window.WebToApk.generateKey(alias: String, optionsJson: String): String
```

Creates (or replaces) an AES-256-GCM key called [alias]. @param optionsJson `{"requireAuth":bool, "authValiditySeconds":int, "strongBox":bool}`. `requireAuth` binds the key to the device lock; `strongBox` fails outright on devices without a secure element rather than handing back a weaker key than was asked for. @return JSON `{ok, alias, hardwareBacked, error}`.

**Example**

Creates (or replaces) an AES-256-GCM key in the Android Keystore. The page gets a name for the key, never the key itself. Most pages use `AppMintKeystore.generateKey(alias, options)`, which does the JSON work for you.

**Returns:** a JSON string, at once: `{"ok":true,"alias":"vault","hardwareBacked":true}` or `{"ok":false,"error":"...","detail":"..."}`. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function createVaultKey() {
  if (!window.WebToApk || typeof window.WebToApk.generateKey !== 'function') {
    alert('Secure keys work only in the app.');
    return false;
  }
  var options = {
    requireAuth: true,          // key works only after the user unlocks (fingerprint / PIN)
    authValiditySeconds: 30,    // ...and for 30 seconds after that unlock
    strongBox: false            // true = demand the separate secure chip (fails if missing)
  };
  var r = JSON.parse(window.WebToApk.generateKey('vault', JSON.stringify(options)));
  if (!r.ok) {
    alert('Could not create the key: ' + r.error);
    return false;
  }
  if (!r.hardwareBacked) console.log('Key is kept in software on this phone');
  return true;
}
```

A plain key with no unlock rule (options may be `'{}'`):

```js
var r = JSON.parse(window.WebToApk.generateKey('notes', '{}'));
```

Create the key once, the first time it is needed — calling `generateKey` again would replace it and orphan everything already sealed:

```js
function ensureKey(alias) {
  if (!window.WebToApk || typeof window.WebToApk.generateKey !== 'function') return false;
  if (window.WebToApk.hasKey(alias) === 'true') return true;    // keep the existing key
  var r = JSON.parse(window.WebToApk.generateKey(alias, '{}'));
  if (!r.ok && r.error === 'disabled') {
    console.warn('Secure Keys was not turned on for this build');
  }
  return r.ok;
}

if (ensureKey('notes')) enableEncryptedNotes();
```

Demand the separate secure chip for a wallet key, and tell the user plainly when the phone does not have one:

```js
function createWalletKey() {
  if (!window.WebToApk || typeof window.WebToApk.generateKey !== 'function') return false;
  var r = JSON.parse(window.WebToApk.generateKey('wallet', JSON.stringify({
    requireAuth: true,
    authValiditySeconds: 10,
    strongBox: true
  })));
  if (r.ok) return true;
  if (r.error === 'strongbox-unavailable') {
    alert('The wallet needs a phone with a dedicated security chip.');
  } else if (r.error === 'bad alias') {
    alert('Key names may use letters, digits, _ - . only.');
  } else {
    alert('Could not create the wallet key: ' + r.error);
  }
  return false;
}
```

**Notes:** The alias may use only letters, digits, `_`, `-`, `.` and at most 64 characters, otherwise `error:"bad alias"`. Calling it again with the same alias REPLACES the key, and data sealed with the old key can no longer be read. Error codes: `disabled` (Secure Keys not turned on), `bad options json`, `strongbox-unavailable` (you asked for `strongBox:true` and the phone has no secure chip, or it is older than Android 9), `foreign_origin` (called from a page that is not the app's own).

### `getAlarmPrecision`

```js
window.WebToApk.getAlarmPrecision(): String
```

"exact" — reminders fire on the minute. "inexact" — the user (or the OEM) turned exact alarms off for this app, so Doze may batch them by minutes. Ask [requestExactAlarms] to fix it. "unavailable" — Android 12+ and the system alarm service could not be reached, so the precision cannot be read (a reminder scheduled now may not fire either). A page that must be on time should check this instead of assuming. @return `"exact"`, `"inexact"` or `"unavailable"`.

**Example**

Tells you whether reminders will fire on the exact minute, or may be late because the user (or the phone maker) turned exact alarms off for this app.

**Returns:** a string, synchronously: `'exact'`, `'inexact'` (may be batched by battery saving, often by minutes), or `'unavailable'` (the phone has no alarm service). **Needs:** turn on **⏰ Reminders** in the Permissions step when you build.

```js
function checkReminderTiming() {
  if (!(window.WebToApk && window.WebToApk.getAlarmPrecision)) return;
  const precision = WebToApk.getAlarmPrecision();
  const warn = document.getElementById('timing-warning');
  warn.hidden = precision === 'exact';
  if (precision === 'inexact') {
    warn.textContent = 'Reminders may be a few minutes late. Tap to allow exact alarms.';
    warn.onclick = function () { WebToApk.requestExactAlarms(); };
  }
}
checkReminderTiming();
// Re-check when the user returns from Settings:
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) checkReminderTiming();
});
```

Check before setting a wake-up alarm, and tell the user if it may be late:

```js
function setWakeUp(atMs) {
  const b = window.WebToApk;
  if (!(b && b.getAlarmPrecision && b.scheduleNotificationEx)) return;
  const precision = b.getAlarmPrecision();
  if (precision === 'unavailable') { showMessage('Alarms cannot be set on this phone right now.'); return; }
  b.scheduleNotificationEx('wake', JSON.stringify({ at: atMs, title: 'Wake up', body: 'Good morning', openApp: true }));
  if (precision === 'inexact') {
    showMessage('Alarm set, but it may ring a few minutes late. Allow exact alarms for on-time alarms.');
  }
}
```

Show the reminder timing as a line in a settings screen:

```js
function renderTimingRow() {
  if (!(window.WebToApk && window.WebToApk.getAlarmPrecision)) return;
  const labels = {
    exact: 'Reminders arrive on time',
    inexact: 'Reminders may be a few minutes late',
    unavailable: 'Reminders are not available on this phone'
  };
  document.getElementById('timing-row').textContent = labels[WebToApk.getAlarmPrecision()];
}
renderTimingRow();
```

**Notes:** Android 11 and older always answer `'exact'`. An `'inexact'` reminder still fires, just not on the minute.

### `getAppName`

```js
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.**

```js
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:**

```js
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:**

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

```js
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.**

```js
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:**

```js
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:**

```js
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`.

### `getContact`

```js
window.WebToApk.getContact(requestId: String, contactId: String)
```

*(Contacts)* All async: cursor reads can be thousands of rows, and a blocking @JavascriptInterface return would run that on the JS thread — an ANR. Results arrive as `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: pass a STRING ('pick-' + Date.now()). A raw NUMBER also works — the bridge coerces it through a double ("1.787491234567E12"), and every reply canonicalizes it back (BridgeRequestId), so `res.requestId === Date.now()` strict-compares true either way.    WebToApk.listContacts(id, limit, offset)   WebToApk.searchContacts(id, query, limit)   WebToApk.getContact(id, contactId)   WebToApk.pickContact(id)      <- NO permission needed (system picker)   WebToApk.addContact(id, json) <- needs the Contacts-write permission

**Example**

Reads one contact again by its `id` (the `id` from `listContacts` or `searchContacts`).

**Returns:** nothing now. The answer is the `appmint:contacts` event (and `window.onAppMintContacts`) with `detail = { requestId, contact: { id, displayName, photoUri, phones: [{ number, type }], emails: [...] } }`. `contact` is `null` when that contact no longer exists. On failure: `{ requestId, error }`. **Needs:** turn on **Contacts** in Step 4 (Access) when you build.

**Refresh a saved contact** using the page hook instead of an event listener.

```js
var pendingId = '';

window.onAppMintContacts = function (detail) {
  if (detail.requestId !== pendingId) return;
  if (detail.error) { showMessage('Could not read the contact: ' + detail.error); return; }
  if (!detail.contact) { showMessage('This contact was deleted.'); return; }

  var c = detail.contact;
  document.getElementById('name').textContent = c.displayName;
  document.getElementById('phones').textContent =
    c.phones.map(function (p) { return p.type + ': ' + p.number; }).join(', ');
  document.getElementById('emails').textContent = c.emails.join(', ');
};

function openContact(contactId) {
  if (!(window.WebToApk && window.WebToApk.getContact)) return;
  pendingId = 'one-' + Date.now();
  WebToApk.getContact(pendingId, String(contactId));
}
```

Refresh a saved list of favourite contact ids and drop the ones deleted from the phone since:

```js
function getOne(contactId) {
  return new Promise(function (resolve, reject) {
    var id = 'fav-' + contactId + '-' + Date.now();
    window.addEventListener('appmint:contacts', function handler(e) {
      if (!e.detail || e.detail.requestId !== id) return;
      window.removeEventListener('appmint:contacts', handler);
      if (e.detail.error) reject(new Error(e.detail.error)); else resolve(e.detail.contact);
    });
    WebToApk.getContact(id, String(contactId));
  });
}

async function loadFavourites() {
  if (!(window.WebToApk && window.WebToApk.getContact)) return;
  var ids = JSON.parse(localStorage.getItem('favIds') || '[]');
  var people = [];
  for (var i = 0; i < ids.length; i++) {
    var c = await getOne(ids[i]);
    if (c) people.push(c);   // null = deleted from the phone
  }
  localStorage.setItem('favIds', JSON.stringify(people.map(function (c) { return c.id; })));
  renderFavourites(people);
}
```

Check whether the number your app stored for a customer is still on their contact card:

```js
function checkStoredNumber(customer) {   // { contactId, phone }
  if (!(window.WebToApk && window.WebToApk.getContact)) return;
  var id = 'check-' + Date.now();

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:contacts', handler);
    if (e.detail.error) { showMessage('Could not check: ' + e.detail.error); return; }
    if (!e.detail.contact) { showMessage('This customer is no longer in your contacts.'); return; }

    var digits = function (s) { return String(s).replace(/\D/g, ''); };
    var still = e.detail.contact.phones.some(function (p) {
      return digits(p.number).slice(-10) === digits(customer.phone).slice(-10);
    });
    if (!still) showMessage('The number for ' + e.detail.contact.displayName + ' has changed.');
  });

  WebToApk.getContact(id, String(customer.contactId));
}
```

**Notes:** `window.onAppMintContacts` receives every contacts answer (list, search, pick, add), so always check `requestId`. You can use it or `addEventListener('appmint:contacts', ...)` — both fire. Errors: `not_enabled`, `permission_denied`, `foreign_origin`. The `id` from `pickContact` is a phone-row id, not a contact id, so it does not work here. Contacts is personal data: your Play Store listing must declare it in the Data safety form and privacy policy.

### `getDeviceInfo`

```js
window.WebToApk.getDeviceInfo(): String
```

*(Device / app information)* WebToApk.getDeviceInfo()    -> JSON string, NO permission required   WebToApk.getPhoneIdentity() -> carrier/SIM, needs the Phone permission   WebToApk.getInstallId()     -> stable per-install UUID

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

```js
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:**

```json
{
  "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.

```js
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()`.

### `getFolderAccessUri`

```js
window.WebToApk.getFolderAccessUri(): String
```

The granted folder's `content://` tree URI, or "" (none, disabled, or a foreign page).

**Example**

Gives the address of the folder the user granted, so you can show which folder is connected.

**Returns:** a string, synchronously: a tree URI such as `content://com.android.externalstorage.documents/tree/primary%3ADocuments%2FNotes`, or `""` when no folder is granted or the switch is off. `WebToApkFS.getAccessUri()` returns the same string. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access).

Turn the URI into a name the user recognises:

```js
function folderLabel() {
  var uri = '';
  if (window.WebToApkFS) {
    uri = window.WebToApkFS.getAccessUri();
  } else if (window.WebToApk && typeof window.WebToApk.getFolderAccessUri === 'function') {
    uri = window.WebToApk.getFolderAccessUri();
  }
  if (!uri) return '';
  var tail = uri.split('/tree/')[1] || '';
  try { tail = decodeURIComponent(tail); } catch (e) {}
  return tail.split(':').pop() || tail;          // "primary:Documents/Notes" -> "Documents/Notes"
}

var label = folderLabel();
document.getElementById('folder-name').textContent = label ? 'Saving to: ' + label : 'No folder chosen';
```

Reload the file list only when the user really picked a different folder:

```js
document.getElementById('change-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  var before = window.WebToApkFS.getAccessUri();
  var r = await window.WebToApkFS.requestAccess();
  if (!r.success) return;                        // the old folder is still connected
  if (r.folderUri !== before) {
    renderFiles(window.WebToApkFS.list(''));     // a new place: show what is in it
  }
});
```

Say whether the folder is on the phone, on an SD card, or in another storage app:

```js
function folderLocation() {
  var uri = window.WebToApkFS ? window.WebToApkFS.getAccessUri() : '';
  if (!uri) return 'none';
  var id = uri.split('/tree/')[1] || '';
  try { id = decodeURIComponent(id); } catch (e) {}
  if (uri.indexOf('com.android.externalstorage.documents') === -1) return 'other';   // e.g. a cloud drive app
  return id.indexOf('primary:') === 0 ? 'phone' : 'sd-card';                         // "1A2B-3C4D:Music" is a card
}

document.getElementById('folder-where').textContent =
  { none: '', phone: 'Phone storage', 'sd-card': 'SD card', other: 'Another storage app' }[folderLocation()];
```

**Notes:** You never pass this URI back to the file methods — they all take paths relative to this folder. Show the name next to a "Disconnect" button so the user knows where files go.

### `getInstallId`

```js
window.WebToApk.getInstallId(): String
```

*(Device / app information)* WebToApk.getDeviceInfo()    -> JSON string, NO permission required   WebToApk.getPhoneIdentity() -> carrier/SIM, needs the Phone permission   WebToApk.getInstallId()     -> stable per-install UUID

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

```js
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.

```js
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`.

### `getLaunchUrl`

```js
window.WebToApk.getLaunchUrl(): String
```

The deep link that launched or resumed this app, or "" if it was opened normally. Read it whenever your app mounts and route on it yourself: const u = window.WebToApk?.getLaunchUrl?.(); if (u) router.navigate(new URL(u).pathname); The value is KEPT, not consumed, so a late-mounting app still sees it; a later link arriving while the app runs replaces it and also fires 'appmint:deep-link'.

**Example**

The deep link that opened (or reopened) the app, as a string, or `""` when the app was opened normally.

**Returns:** a string, synchronously. **Needs:** nothing. The value is kept, not consumed, so it is safe to read whenever your app mounts.

A link can arrive in three shapes: `https://yoursite.com/path`, the shell's own `appmint-shortcut://<package>/<route>` (launcher shortcuts, the home-screen widget), or your custom scheme `myapp://segment/path`. This helper turns all three into a route:

```js
function routeOf(link) {
  var m = /^([a-z][a-z0-9+.-]*):\/\/([^\/?#]*)([^#]*)/i.exec(link || '');
  if (!m) return '/';
  var scheme = m[1].toLowerCase(), host = m[2], rest = m[3] || '';
  if (scheme === 'http' || scheme === 'https' || scheme === 'appmint-shortcut') return rest || '/';
  return '/' + host + rest;               // myapp://product/42 -> /product/42
}

// 1. On start: the link that launched the app.
if (window.WebToApk && typeof window.WebToApk.getLaunchUrl === 'function') {
  var link = window.WebToApk.getLaunchUrl();
  if (link) goTo(routeOf(link));
}

// 2. While running: a new link replaces the old one and fires 'appmint:deep-link'.
window.addEventListener('appmint:deep-link', function (e) {
  goTo(routeOf(e.detail.url));            // detail: { url }
});
```

The same announcement is also passed to a global hook, if you prefer one:

```js
window.onAppMintDeepLink = function (detail) { goTo(routeOf(detail.url)); };
```

Read a referral code from a link such as `https://yoursite.com/invite?code=AB12` or `myapp://invite?code=AB12`, once per link (it uses `routeOf` from above):

```js
function applyInvite(link) {
  if (!link) return;
  var done = '';
  try { done = sessionStorage.getItem('handledLink') || ''; } catch (e) {}
  if (link === done) return;                      // the same url is announced again after a reload
  try { sessionStorage.setItem('handledLink', link); } catch (e) {}
  var code = null;
  try { code = new URL(link).searchParams.get('code'); } catch (e) { return; }
  if (code && routeOf(link).indexOf('/invite') === 0) showInviteBanner(code);
}

if (window.WebToApk && typeof window.WebToApk.getLaunchUrl === 'function') {
  applyInvite(window.WebToApk.getLaunchUrl());
  window.addEventListener('appmint:deep-link', function (e) { applyInvite(e.detail.url); });
}
```

**Notes:** `appmint:deep-link` is also fired after every page load while a link is kept, so the same `url` can arrive more than once. Ignore a link you already handled.

```js
function applyInvite(link) {
  if (!link) return;
  var done = '';
  try { done = sessionStorage.getItem('handledLink') || ''; } catch (e) {}
  if (link === done) return;                      // the same url is announced again after a reload
  try { sessionStorage.setItem('handledLink', link); } catch (e) {}
  var code = null;
  try { code = new URL(link).searchParams.get('code'); } catch (e) { return; }
  if (code && routeOf(link).indexOf('/invite') === 0) showInviteBanner(code);
}

if (window.WebToApk && typeof window.WebToApk.getLaunchUrl === 'function') {
  applyInvite(window.WebToApk.getLaunchUrl());
  window.addEventListener('appmint:deep-link', function (e) { applyInvite(e.detail.url); });
}
```

**Notes:** `appmint:deep-link` is also fired after every page load while a link is kept, so the same `url` can arrive more than once. Ignore a link you already handled. In a Website-mode app an `https://` link is loaded by the shell itself; offline and AI-built apps get the event and route on it themselves. AI-built apps can use `launchUrl()` from `@/lib/appmintNative`.

### `getMaxIoChunkSize`

```js
window.WebToApk.getMaxIoChunkSize(): Long
```

The per-call byte cap for [readFileBase64] / [writeFileBase64], for chunk maths.

**Example**

The per-call byte limit of `readFileBase64` / `writeFileBase64` (4 MB). Pages normally use `WebToApkFS.maxChunkSize()`.

**Returns:** (sync) a number (4194304). **Needs:** nothing to call it; the read/write methods it describes need **Native Folder Access (SAF)** ticked in Step 4 (Access).

The public way:

```js
const step = window.WebToApkFS ? WebToApkFS.maxChunkSize() : 0;
```

The raw call:

```js
let step = 1048576;
if (window.WebToApk && typeof WebToApk.getMaxIoChunkSize === 'function') {
  step = Number(WebToApk.getMaxIoChunkSize()) || step;
}
console.log('read/write up to', step, 'bytes per call');
```

Work out how many bridge calls a file will take, to size a progress bar before copying it:

```js
function plannedSteps(fileUri) {
  const step = WebToApkFS.maxChunkSize();          // 0 when the helper is not available
  const info = WebToApkFS.stat(fileUri);
  if (!step || !info.ok) return 0;
  const steps = Math.max(1, Math.ceil(info.size / step));
  document.querySelector('#progress').max = steps;
  return steps;
}
```

Copy one file into another, one maximum-size chunk at a time:

```js
function copyFile(fromUri, toPath) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fromUri, offset, step);
    if (!r.ok) return r;
    const w = WebToApkFS.writeBytes(toPath, r.base64, offset, offset === 0 ? 'truncate' : 'patch');
    if (!w.ok) return w;
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, bytes: offset };
  }
}
// copyFile(pickedUri, 'photo-backup.jpg')  (a file in the granted folder)
```

**Notes:** A write larger than this is refused with `chunk too large (max N bytes)`; a larger read is cut to this size.

### `getMusicVolume`

```js
window.WebToApk.getMusicVolume(): Int
```

Current level 0..100, or -1 when this app ships no background music.

**Example**

Reads the level of the app's looping background music, 0 to 100.

**Returns:** a number, synchronously: `0`–`100`, or `-1` when this app was built without a music file. **Needs:** a file chosen under **Background Music** in Step 2 (Display) when you build.

Show a music slider only when there is music:

```js
var slider = document.getElementById('music-slider');
var level = -1;
if (window.WebToApk && typeof window.WebToApk.getMusicVolume === 'function') {
  level = window.WebToApk.getMusicVolume();
}

if (level < 0) {
  slider.parentNode.hidden = true;          // no music in this app: hide the setting
} else {
  slider.value = level;
  slider.addEventListener('input', function () {
    window.WebToApk.setMusicVolume(parseInt(slider.value, 10));
  });
}
```

A mute button that returns to the user's own level on unmute (the app remembers `0` too, so keep the old level yourself):

```js
function musicLevel() {
  return window.WebToApk && typeof window.WebToApk.getMusicVolume === 'function'
    ? window.WebToApk.getMusicVolume() : -1;
}
var muteBtn = document.getElementById('mute');

function paint() {
  var v = musicLevel();
  muteBtn.hidden = v < 0;
  muteBtn.textContent = v === 0 ? 'Unmute' : 'Mute';
}

muteBtn.addEventListener('click', function () {
  var v = musicLevel();
  if (v < 0) return;
  if (v > 0) {
    try { localStorage.setItem('musicBefore', String(v)); } catch (e) {}
    window.WebToApk.setMusicVolume(0);
  } else {
    var back = 40;
    try { back = parseInt(localStorage.getItem('musicBefore') || '40', 10); } catch (e) {}
    window.WebToApk.setMusicVolume(back);
  }
  paint();
});
paint();
```

Fade the music from its current level to a new one, for example under a dialogue scene:

```js
function fadeMusicTo(target, ms) {
  if (!window.WebToApk || typeof window.WebToApk.getMusicVolume !== 'function') return;
  var start = window.WebToApk.getMusicVolume();
  if (start < 0 || start === target) return;
  var steps = 10, i = 0;
  var t = setInterval(function () {
    i++;
    window.WebToApk.setMusicVolume(Math.round(start + (target - start) * i / steps));
    if (i === steps) clearInterval(t);
  }, ms / steps);
}

fadeMusicTo(10, 800);     // dim under the dialogue
// later: fadeMusicTo(60, 800);  the last level set is the one kept for the next launch
```

**Notes:** Returns the level set by the build slider or by your last `setMusicVolume` call. When another app or a call takes the audio, the music ducks or pauses by itself; the number you read does not change.

### `getNotificationPermission`

```js
window.WebToApk.getNotificationPermission(): String
```

The CURRENT notification permission, read live from the OS. The polyfill used to hardcode `Notification.permission = 'default'` on every page load, so the overwhelmingly common page pattern if (Notification.permission === 'granted') new Notification(...) never fired — not even for a user who had already granted it, and not on Android 12 and below where notifications need no runtime grant at all.

**Example**

Reads, right now, whether this app may post notifications.

**Returns:** a string, synchronously: `'granted'`; `'denied'` (the user refused for good, or switched this app's notifications off in Settings — a post would not show); or `'default'` (not decided yet, the app can still ask). **Needs:** nothing to call it; posting needs **Notifications** turned on in the Permissions step when you build.

**The standard web API** reads the same live value:

```js
if ('Notification' in window && Notification.permission === 'granted') {
  document.getElementById('alerts-on').hidden = false;
}
```

**The raw call**, for example after the user comes back from Settings:

```js
function refreshAlertSetting() {
  if (!(window.WebToApk && window.WebToApk.getNotificationPermission)) return;
  const perm = WebToApk.getNotificationPermission();   // 'granted' | 'denied' | 'default'
  document.getElementById('enable-alerts').hidden = perm !== 'default';   // can still ask
  document.getElementById('open-settings-hint').hidden = perm !== 'denied';
}
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) refreshAlertSetting();
});
refreshAlertSetting();
```

Decide before scheduling whether to ask, explain, or go ahead:

```js
async function ensureAlerts() {
  const b = window.WebToApk;
  if (!(b && b.getNotificationPermission)) return false;
  const perm = b.getNotificationPermission();
  if (perm === 'granted') return true;
  if (perm === 'denied') {
    showMessage('Notifications are off for this app. Turn them on in Settings.');
    return false;
  }
  // 'default': the app can still ask
  return (await Notification.requestPermission()) === 'granted';
}
```

**Notes:** it gives the same answer as `navigator.permissions.query({ name: 'notifications' })`. To ask the user, use `Notification.requestPermission()`.

### `getNotificationState`

```js
window.WebToApk.getNotificationState(): String
```

'granted' | 'prompt' (Android can still ask) | 'blocked' (refused for good or switched off in Settings — only Settings fixes it) | 'unavailable' (Android 13+ and this build does not declare the notification permission). See NotificationOptIn.

**Example**

Reads, right now, whether this app's notifications are on — and if not, whether the app can still ask or only Settings can turn them back on.

**Returns:** a string, synchronously: `'granted'` (notifications reach the phone); `'prompt'` (Android can still show its permission dialog); `'blocked'` (the user refused for good, or switched this app's notifications off in system Settings — only Settings can fix it); or `'unavailable'` (Android 13+ and the app was built without **Notifications** / **Reminders** / **Push**, so nothing can turn them on). **Needs:** nothing to call it.

**The raw call**, to decide whether to show a "Turn on notifications" banner:

```js
function refreshNotifBanner() {
  if (!(window.WebToApk && WebToApk.getNotificationState)) return;
  var state = WebToApk.getNotificationState();   // 'granted' | 'prompt' | 'blocked' | 'unavailable'
  var banner = document.getElementById('notif-banner');
  banner.hidden = !(state === 'prompt' || state === 'blocked');
  banner.querySelector('.hint').textContent = state === 'blocked'
    ? 'Notifications are off. Tap to open Settings and switch them on.'
    : 'Get order updates. Tap to turn on notifications.';
}
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) refreshNotifBanner();
});
refreshNotifBanner();
```

**Notes:** `getNotificationPermission()` keeps its own words (`'granted'` / `'default'` / `'denied'`) for older pages. To bring the user back, call `AppMint.enableNotifications()` (it picks the dialog or Settings for you).

### `getOwnedProducts`

```js
window.WebToApk.getOwnedProducts(): String
```

JSON array of every owned product ID — for gating whole screens at startup.

**Example**

Lists every Play product the user owns, to unlock whole screens at start.

**Returns:** a JSON string (array of product IDs), synchronously, for example `'["pro_unlock","level_pack_2"]'`. Parse it with `JSON.parse`. `'[]'` when nothing is owned. **Needs:** **💰 Sell In-App Products** in the build wizard.

```js
function ownedIds() {
  var b = window.WebToApk;
  if (!b || typeof b.getOwnedProducts !== 'function') return [];
  try { return JSON.parse(b.getOwnedProducts()); } catch (e) { return []; }
}

function unlockAll(ids) {
  ids.forEach(function (id) {
    var el = document.querySelector('[data-product="' + id + '"]');
    if (el) el.classList.add('unlocked');
  });
}
unlockAll(ownedIds());

// The owned list changed (launch restore, a new purchase, a refund): the full new list.
window.addEventListener('appmint:owned-changed', function (e) {
  document.querySelectorAll('.unlocked').forEach(function (el) { el.classList.remove('unlocked'); });
  unlockAll(e.detail.products);              // e.detail = {products: ['pro_unlock', ...]}
});
```

The same change also calls `window.onAppMintOwnedChanged(detail)` if you define it.

A "My purchases" list on your settings screen, with your own name for each product:

```js
var NAMES = { pro_unlock: 'Pro features', level_pack_2: 'Levels 11-20', remove_ads: 'No ads' };

function renderMyPurchases() {
  var b = window.WebToApk, ids = [];
  if (b && typeof b.getOwnedProducts === 'function') {
    try { ids = JSON.parse(b.getOwnedProducts()); } catch (e) {}
  }
  var list = document.getElementById('my-purchases');
  list.textContent = '';
  ids.forEach(function (id) {
    var li = document.createElement('li');
    li.textContent = NAMES[id] || id;
    list.appendChild(li);
  });
  document.getElementById('nothing-bought').hidden = ids.length > 0;
}
renderMyPurchases();
window.addEventListener('appmint:owned-changed', renderMyPurchases);
```

Work out the best tier the user owns when each tier is its own product:

```js
var TIERS = ['premium_plus', 'premium', 'basic'];     // best first

function currentTier() {
  var b = window.WebToApk, owned = [];
  if (b && typeof b.getOwnedProducts === 'function') {
    try { owned = JSON.parse(b.getOwnedProducts()); } catch (e) {}
  }
  for (var i = 0; i < TIERS.length; i++) if (owned.indexOf(TIERS[i]) !== -1) return TIERS[i];
  return 'free';
}

applyTier(currentTier());
window.addEventListener('appmint:purchase', function () { applyTier(currentTier()); });
window.addEventListener('appmint:owned-changed', function () { applyTier(currentTier()); });
```

**Notes:** Includes one-time products and active subscriptions, and also the Remove Ads product if the user bought it. Works offline; the list is saved on the phone at every launch restore and purchase. `appmint:owned-changed` fires only when the list really changed, so it may not fire at all on a normal launch.

### `getPhoneIdentity`

```js
window.WebToApk.getPhoneIdentity(): String
```

*(Device / app information)* WebToApk.getDeviceInfo()    -> JSON string, NO permission required   WebToApk.getPhoneIdentity() -> carrier/SIM, needs the Phone permission   WebToApk.getInstallId()     -> stable per-install UUID

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

```js
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.

```js
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`.

```js
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.

### `getProducts`

```js
window.WebToApk.getProducts(requestId: String, productIdsJson: String)
```

Asks Play for live details of the given product IDs (JSON array of strings). Result arrives as 'appmint:products' {requestId, products:[{productId, type, title, description, price, currency, period?, owned}]}. IDs Play doesn't know are absent from the result — the page hides those items.

**Example**

Asks Google Play for the live details of your products: title, description and the localized price.

**Returns:** nothing now. The answer arrives as the `appmint:products` event: `{requestId, products: [{productId, type, title, description, price, currency, period?, owned}]}`. `type` is `'inapp'` or `'subs'`; `period` is an ISO-8601 period such as `'P1M'` (subscriptions only). **Needs:** **💰 Sell In-App Products** in the build wizard. The second argument is a JSON string: use `JSON.stringify`.

```js
function loadPrices(ids) {
  return new Promise(function (resolve) {
    var b = window.WebToApk;
    if (!b || typeof b.getProducts !== 'function') { resolve([]); return; }   // browser
    var requestId = 'prices-' + Date.now();
    window.addEventListener('appmint:products', function handler(e) {
      if (String(e.detail.requestId) !== requestId) return;   // another call's answer
      window.removeEventListener('appmint:products', handler);
      resolve(e.detail.products || []);
    });
    b.getProducts(requestId, JSON.stringify(ids));
  });
}

loadPrices(['pro_unlock', 'coins_500', 'pro_monthly']).then(function (products) {
  products.forEach(function (p) {
    var btn = document.querySelector('[data-product="' + p.productId + '"]');
    if (!btn) return;
    btn.hidden = false;
    btn.textContent = p.owned ? 'Owned' : p.title + ' · ' + p.price + (p.period === 'P1M' ? ' / month' : '');
  });
});
```

The answer is also passed to `window.onAppMintProducts(detail)` if you define it.

A subscription plan picker, each card labelled with Play's price and its billing period:

```js
var PERIODS = { P1W: 'week', P1M: 'month', P3M: '3 months', P6M: '6 months', P1Y: 'year' };

window.addEventListener('appmint:products', function (e) {
  if (e.detail.requestId !== 'plans') return;
  e.detail.products.forEach(function (p) {
    var card = document.querySelector('.plan[data-product="' + p.productId + '"]');
    if (!card || p.type !== 'subs') return;
    card.hidden = false;
    card.querySelector('.price').textContent = p.price + ' / ' + (PERIODS[p.period] || p.period);
    card.classList.toggle('current', p.owned);
  });
});

var b = window.WebToApk;
if (b && typeof b.getProducts === 'function') {
  b.getProducts('plans', JSON.stringify(['pro_weekly', 'pro_monthly', 'pro_yearly']));
}
```

While testing, log the IDs Google Play did not return, through the page hook:

```js
var WANTED = ['pro_unlock', 'coins_500', 'pro_monthly'];

window.onAppMintProducts = function (detail) {       // same payload as appmint:products
  if (detail.requestId !== 'store-check') return;
  var found = detail.products.map(function (p) { return p.productId; });
  var missing = WANTED.filter(function (id) { return found.indexOf(id) === -1; });
  if (missing.length) console.warn('Not returned by Google Play: ' + missing.join(', '));
  document.getElementById('store').hidden = found.length === 0;
};

if (window.WebToApk && typeof window.WebToApk.getProducts === 'function') {
  window.WebToApk.getProducts('store-check', JSON.stringify(WANTED));
}
```

**Notes:** Show `price` exactly as Play gives it and never store it. An ID that Play does not know (not created, not active, or the app was not installed from Play) is simply missing from `products`; keep that item hidden. A numeric-looking `requestId` comes back as a number, so use a text id or compare with `String()`.

### `getPushInbox`

```js
window.WebToApk.getPushInbox(): String
```

Appwright Push inbox (creator-sent messages, newest first, max 50): JSON array of {id, title, body, imageUrl, link, receivedAt, read}. Always the full current truth — an app that mounts late loses nothing by having missed the live `appmint:push` events.

**Example**

Reads the in-app inbox of push messages you sent with Appwright Push: newest first, up to 50, without the ones the user dismissed.

**Returns:** a JSON **string**, synchronously. Parse it. Each item: `{ id, title, body, imageUrl, link, receivedAt, read }` (`receivedAt` in epoch ms), plus optional design fields when you used a template in Appwright: `template`, `accent`, `iconUrl`, `buttons: [{label, link}]`, `badge`, `inapp`, `countdownAt`, `progress`, `lines`, `collapse`. `'[]'` when empty. **Needs:** turn on **Appwright Push** in the Integrate step when you build; without it the inbox is always empty.

**Render the inbox, and refresh it live with `appmint:push`:**

```js
function loadInbox() {
  if (!(window.WebToApk && window.WebToApk.getPushInbox)) return [];
  try { return JSON.parse(WebToApk.getPushInbox() || '[]'); } catch (e) { return []; }
}

function renderInbox() {
  const inbox = loadInbox();
  const unread = inbox.filter(function (m) { return !m.read; }).length;
  document.getElementById('bell-count').textContent = unread ? String(unread) : '';
  const ul = document.getElementById('inbox');
  ul.innerHTML = '';
  inbox.forEach(function (m) {
    const li = document.createElement('li');
    li.className = m.read ? 'read' : 'unread';
    li.textContent = m.title + ' — ' + m.body;
    li.onclick = function () {
      WebToApk.markPushRead(m.id);
      if (m.link) location.href = m.link;
      renderInbox();
    };
    ul.appendChild(li);
  });
}

// While the app is open:
window.addEventListener('appmint:push', function (e) {
  // { type: 'received', message: {id, title, body, ...}, presented: true|false }
  // { type: 'revoked', id }  — you retracted the message
  // presented = the app already showed it on screen (see setPushPresentation)
  if (e.detail.type === 'received' && !e.detail.presented) showBanner(e.detail.message.title);
  renderInbox();
});

renderInbox();
```

Update the unread count on your bell whenever the user comes back to the app (messages that arrived while it was in the background are already in the inbox):

```js
function unreadCount() {
  if (!(window.WebToApk && window.WebToApk.getPushInbox)) return 0;
  try {
    return JSON.parse(WebToApk.getPushInbox() || '[]').filter(function (m) { return !m.read; }).length;
  } catch (e) { return 0; }
}

function paintBell() {
  const n = unreadCount();
  const badge = document.getElementById('bell-count');
  badge.textContent = n > 9 ? '9+' : (n ? String(n) : '');
  badge.hidden = n === 0;
}

document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'visible') paintBell();
});
paintBell();
```

Show a message's picture and its buttons (only when you designed it with them), with the time it arrived:

```js
function renderCard(m) {
  const card = document.createElement('article');
  if (m.accent) card.style.borderLeft = '4px solid ' + m.accent;
  if (m.imageUrl) {
    const img = document.createElement('img');
    img.src = m.imageUrl; img.alt = '';
    card.appendChild(img);
  }
  const h = document.createElement('h3');
  h.textContent = m.title;
  const p = document.createElement('p');
  p.textContent = m.body;
  const time = document.createElement('time');
  time.textContent = new Date(m.receivedAt).toLocaleString();
  card.append(h, p, time);
  (m.buttons || []).forEach(function (b) {
    const btn = document.createElement('button');
    btn.textContent = b.label;
    btn.onclick = function () { WebToApk.markPushRead(m.id); if (b.link) location.href = b.link; };
    card.appendChild(btn);
  });
  return card;
}

if (window.WebToApk && window.WebToApk.getPushInbox) {
  const list = document.getElementById('inbox');
  JSON.parse(WebToApk.getPushInbox() || '[]').forEach(function (m) { list.appendChild(renderCard(m)); });
}
```

**Notes:** the inbox is kept on the phone, so read it when your screen opens — events only arrive while the app is on screen. A message you retract from Appwright disappears from the inbox and the tray. Messages sent as "push only" (tray) are not added to the inbox. The same detail is also passed to `window.onAppMintPush(detail)` if you define it.

### `getPushTags`

```js
window.WebToApk.getPushTags(): String
```

This install's tags as a JSON object string, e.g. {"plan":"pro"}.

**Example**

Reads this install's Appwright Push tags.

**Returns:** a JSON **string**, synchronously — an object of text values, e.g. `'{"plan":"pro","cart":"open"}'`; `'{}'` when there are none. Parse it. **Needs:** turn on **Appwright Push** in the Integrate step when you build.

**Read one tag:**

```js
function pushTags() {
  if (!(window.WebToApk && window.WebToApk.getPushTags)) return {};
  try { return JSON.parse(WebToApk.getPushTags() || '{}'); } catch (e) { return {}; }
}

const plan = pushTags().plan || 'free';
```

**Set a tag only when it changes** — every `setPushTag` call makes the install check in with Appwright Push, so skip the ones that change nothing:

```js
function ensureTag(key, value) {
  if (!(window.WebToApk && window.WebToApk.getPushTags)) return;
  const tags = JSON.parse(WebToApk.getPushTags() || '{}');
  if ((tags[key] || '') !== value) WebToApk.setPushTag(key, value);
}

ensureTag('plan', user.isPro ? 'pro' : 'free');
```

**List them on a debug screen** while you test sending to a tag:

```js
const list = document.getElementById('push-tags');
if (window.WebToApk && window.WebToApk.getPushTags) {
  const tags = JSON.parse(WebToApk.getPushTags() || '{}');
  list.textContent = Object.keys(tags).length
    ? Object.keys(tags).map(function (k) { return k + ' = ' + tags[k]; }).join('\n')
    : 'No tags yet';
}
```

**Notes:** this is what the phone holds; the send center sees it after the install's next check-in, a few seconds after a change when the phone is online. Android only: the iOS app has no `WebToApk.getPushTags`, so the feature check skips it there.

### `getPushUser`

```js
window.WebToApk.getPushUser(): String
```

The user id last set with setPushUser ("" when signed out or never set).

**Example**

Reads the user id this install last gave Appwright Push with `setPushUser`.

**Returns:** a `string`, synchronously — the saved id, or `''` when nobody is signed in or it was never set. **Needs:** turn on **Appwright Push** in the Integrate step when you build.

**Set the id only when it changed** (on every app start):

```js
function syncPushUser(currentUserId) {
  if (!(window.WebToApk && window.WebToApk.getPushUser)) return;
  const want = currentUserId || '';
  if (WebToApk.getPushUser() !== want) WebToApk.setPushUser(want);
}

syncPushUser(localStorage.getItem('userId'));
```

**Clear a stale id** when your own session expired while the app was closed:

```js
async function checkSession() {
  const session = await myBackend.currentSession();       // null when expired
  if (!session && window.WebToApk && window.WebToApk.getPushUser && WebToApk.getPushUser() !== '') {
    WebToApk.setPushUser('');                              // stop personal messages to this phone
  }
}

checkSession();
```

**Show it on a debug screen** while you test sending to one user:

```js
const info = document.getElementById('push-debug');
if (window.WebToApk && window.WebToApk.getPushUser) {
  const id = WebToApk.getPushUser();
  info.textContent = id ? 'Push user: ' + id : 'Push user: nobody signed in';
} else {
  info.textContent = 'Not inside the app';
}
```

**Notes:** this is what the phone holds. The send center sees a change after the install's next check-in, a few seconds later when the phone is online. Android only: the iOS app has no `WebToApk.getPushUser`, so the feature check skips it there.

### `getScheduledNotifications`

```js
window.WebToApk.getScheduledNotifications(): String
```

*(Notification permission, cancelling and listing)* Reading and requesting the POST_NOTIFICATIONS grant, and managing notifications the page already scheduled. Posting and scheduling are documented on their own methods.

**Example**

Lists the reminders that are still scheduled.

**Returns:** a JSON **string**, synchronously. Parse it. Each item: `{ id, title, body, trigger, repeat, options? }` — `trigger` is the next fire time in epoch ms, `repeat` is `none|minutely|hourly|daily|weekly`, and `options` (only for `scheduleNotificationEx`) is the options as a JSON string. Live fields say how it will really be delivered, read live: `precision` (`"exact"`; `"inexact"` = "Alarms & reminders" is off for the app, so Android delivers it within a 10-minute window while the phone is awake, and may hold it longer while it sleeps), `windowMs` + `latestAt` (the window Android was given: `0` / `trigger` when exact, `600000` / `trigger + 600000` when inexact) and `notificationsEnabled` (`false` = it fires but Android shows nothing, because the app's notifications are off). `'[]'` when there are none. **Needs:** nothing extra.

```js
function renderReminders() {
  if (!(window.WebToApk && window.WebToApk.getScheduledNotifications)) return;
  let list = [];
  try { list = JSON.parse(WebToApk.getScheduledNotifications() || '[]'); } catch (e) { list = []; }

  const ul = document.getElementById('reminders');
  ul.innerHTML = '';
  list.sort(function (a, b) { return a.trigger - b.trigger; }).forEach(function (r) {
    const li = document.createElement('li');
    li.textContent = r.title + ' — ' + new Date(r.trigger).toLocaleString() +
      (r.repeat !== 'none' ? ' (' + r.repeat + ')' : '');
    ul.appendChild(li);
  });
}
```

Show whether one reminder is on, and when it fires next:

```js
function describeReminder(id) {
  if (!(window.WebToApk && window.WebToApk.getScheduledNotifications)) return 'Not available';
  let list = [];
  try { list = JSON.parse(WebToApk.getScheduledNotifications() || '[]'); } catch (e) { list = []; }
  const r = list.find(function (x) { return x.id === id; });
  return r ? 'Next: ' + new Date(r.trigger).toLocaleString() : 'Off';
}
document.getElementById('water-status').textContent = describeReminder('water');
```

Read the extra options of reminders made with `scheduleNotificationEx`:

```js
function listAlarmStyleReminders() {
  if (!(window.WebToApk && window.WebToApk.getScheduledNotifications)) return [];
  let list = [];
  try { list = JSON.parse(WebToApk.getScheduledNotifications() || '[]'); } catch (e) { list = []; }
  return list.filter(function (r) {
    if (!r.options) return false;                  // made with scheduleNotification
    let o = {};
    try { o = JSON.parse(r.options); } catch (e) { return false; }
    return o.openApp === true;                     // puts the app on screen when it fires
  }).map(function (r) { return r.id; });
}
```

**Notes:** a one-off reminder leaves the list when it fires. A repeating one stays, with `trigger` moved to the next time.

Warn when reminders will not show up on time:

```js
function reminderHealth() {
  if (!(window.WebToApk && window.WebToApk.getScheduledNotifications)) return '';
  let list = [];
  try { list = JSON.parse(WebToApk.getScheduledNotifications() || '[]'); } catch (e) { list = []; }
  if (!list.length) return '';
  if (list.some(function (r) { return r.notificationsEnabled === false; })) return 'Notifications are off for this app: reminders will not appear.';
  if (list.some(function (r) { return r.precision === 'inexact'; })) return 'Reminders may arrive late. Turn on Alarms & reminders.';
  return '';
}
```

### `getSharedText`

```js
window.WebToApk.getSharedText(): String
```

Text or a link another app shared INTO this one ("Share to <app>" — a YouTube video shared from the YouTube app arrives as its link), as JSON `{text, subject, url, receivedAt}` or "" when nothing was shared. Kept until read, so an app that mounts after the share still sees it; a share arriving while the app runs also fires `appmint:shared`. A photo or video shared with a caption delivers the file through AppMint.getOpenedFile() and the caption here. Requires the build's "Receive shares from other apps" option with "Text and links" ticked, which registers the share-sheet entry.

**Example**

Text or a link that another app shared INTO your app ("Share → your app"), as a JSON string, or `""` when nothing was shared.

**Returns:** a JSON string `{text, subject, url, receivedAt}` or `""` (synchronously). `url` is the first `http(s)://` link found in `text` (or `""`); `receivedAt` is a time in milliseconds. **Needs:** **Receive Shares From Other Apps** switched on when you build, with **Text and links** ticked — that puts your app in other apps' Share menu.

The share is kept until you clear it, so read it on start, and listen for shares that arrive while the app runs:

```js
function handleShare(s) {
  addBookmark(s.url || s.text, s.subject);
  if (window.WebToApk && window.WebToApk.clearSharedText) window.WebToApk.clearSharedText();
}

// 1. On start (the app may have been opened by the share).
if (window.WebToApk && typeof window.WebToApk.getSharedText === 'function') {
  var raw = window.WebToApk.getSharedText();
  if (raw) handleShare(JSON.parse(raw));
}

// 2. While running: detail has the same keys { text, subject, url, receivedAt }.
window.addEventListener('appmint:shared', function (e) {
  handleShare(e.detail);
});
```

A global hook receives the same object, if you prefer one:

```js
window.onAppMintShared = function (s) { handleShare(s); };
```

Open a YouTube video that was shared into the app, using the `url` field:

```js
function openSharedVideo() {
  if (!(window.WebToApk && typeof window.WebToApk.getSharedText === 'function')) return;
  var raw = window.WebToApk.getSharedText();
  if (!raw) return;
  var s = JSON.parse(raw);
  var m = /(?:youtu\.be\/|[?&]v=)([\w-]{11})/.exec(s.url || s.text);
  if (m) {
    showVideo(m[1]);
    window.WebToApk.clearSharedText();
  } else {
    showMessage('That share is not a video link');
  }
}
```

**Notes:** A YouTube video shared from the YouTube app arrives as its link. A photo or video shared WITH a caption delivers the file through `AppMint.getOpenedFile()` and the caption here. Clear the share after you use it, or you will see it again on the next start. AI-built apps can use `sharedText()` / `onShared()` from `@/lib/appmintNative`.

### `hasFolderAccess`

```js
window.WebToApk.hasFolderAccess(): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Tells you whether the user has already given the app a folder.

**Returns:** the STRING `"true"` or `"false"` (not a boolean), synchronously. `WebToApkFS.hasAccess()` gives a real boolean. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access); without it the answer is always `"false"`.

The raw method exists from the very first line of the page, so it is the right check while the page is still loading (`WebToApkFS` is added only when loading finishes):

```js
function folderConnected() {
  if (window.WebToApkFS) return window.WebToApkFS.hasAccess();
  return !!(window.WebToApk && typeof window.WebToApk.hasFolderAccess === 'function' &&
    window.WebToApk.hasFolderAccess() === 'true');
}

document.getElementById('choose-folder').hidden = folderConnected();
```

Check before a save and open the picker only when no folder is connected yet:

```js
async function saveBackup(json) {
  if (!window.WebToApkFS) return false;
  if (!window.WebToApkFS.hasAccess()) {
    var r = await window.WebToApkFS.requestAccess();   // asked once; the grant is remembered
    if (!r.success) return false;
  }
  return window.WebToApkFS.writeText('backup.json', json);
}

document.getElementById('backup').addEventListener('click', function () {
  saveBackup(JSON.stringify(appState));
});
```

An autosave timer that writes only when a folder is connected and never opens the picker itself:

```js
setInterval(function () {
  if (!window.WebToApk || typeof window.WebToApk.hasFolderAccess !== 'function') return;
  if (window.WebToApk.hasFolderAccess() !== 'true') return;   // not connected: the Save button asks
  window.WebToApk.writeTextFile('drafts/autosave.txt', document.getElementById('editor').value);
}, 30000);
```

**Notes:** Compare with `=== 'true'`; the string `"false"` is truthy. It only says the app remembers a folder. If that folder was deleted or moved in the Files app, `listFolderEntries('')` answers `[]` — ask the user to choose it again.

### `hasKey`

```js
window.WebToApk.hasKey(alias: String): String
```

True when a key called [alias] exists in this app's keystore.

**Example**

Tells you whether a Keystore key with this alias exists. Most pages use `AppMintKeystore.hasKey(alias)`, which returns a real boolean.

**Returns:** the STRING `"true"` or `"false"`, at once. **Needs:** **Secure Keys (encryption)** turned on when you build (without it the answer is always `"false"`).

```js
function vaultExists() {
  if (!window.WebToApk || typeof window.WebToApk.hasKey !== 'function') return false;
  return window.WebToApk.hasKey('vault') === 'true';   // compare the string
}

if (!vaultExists()) {
  showCreateVaultScreen();
}
```

Pick the start screen: unlock an existing vault, or set one up — and clear sealed data whose key is gone:

```js
function chooseStartScreen() {
  if (!window.WebToApk || typeof window.WebToApk.hasKey !== 'function') return showMyApp();
  var hasVault = window.WebToApk.hasKey('vault') === 'true';
  var hasData = localStorage.getItem('note') !== null;

  if (hasVault) {
    showUnlockScreen();
  } else if (hasData) {
    localStorage.removeItem('note');          // sealed with a key that no longer exists
    showCreateVaultScreen('Your old vault could not be opened, so it was cleared.');
  } else {
    showCreateVaultScreen();
  }
}
```

One key per signed-in account, made the first time that account signs in on this phone:

```js
function keyAliasFor(userId) {
  // aliases allow letters, digits, _ - . and at most 64 characters
  return 'user_' + String(userId).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 59);
}

function onSignedIn(user) {
  if (!window.WebToApk || typeof window.WebToApk.hasKey !== 'function') return openAccount(user, null);
  var alias = keyAliasFor(user.id);
  if (window.WebToApk.hasKey(alias) !== 'true') {
    var r = JSON.parse(window.WebToApk.generateKey(alias, '{}'));
    if (!r.ok) return alert('Could not set up secure storage: ' + r.error);
  }
  openAccount(user, alias);
}
```

**Notes:** The answer is a string, so `if (WebToApk.hasKey('vault'))` is always true — compare with `=== 'true'`. You only see keys this app created through the bridge; the shell's own keys are hidden.

### `isBiometricAvailable`

```js
window.WebToApk.isBiometricAvailable(): String
```

"available" | "no_hardware" | "not_enrolled" | "unavailable". Android 10+ counts face, iris and fingerprint; Android 6–9 the fingerprint sensor only. "unavailable" = the sensor is temporarily unusable, or the check could not run (e.g. the build did not enable the Fingerprint permission). See [BiometricStatus].

**Example**

Tells you whether the phone can do a biometric unlock (face, iris or fingerprint) right now. Check it before you show an unlock button.

**Returns:** a string, at once: `"available"`, `"no_hardware"` (no sensor), `"not_enrolled"` (sensor, but nothing registered, or no screen lock set) or `"unavailable"` (the sensor is temporarily unusable, or the check could not run — for example the build did not tick **Fingerprint**). **Needs:** **Fingerprint** ticked when you build — without it Android refuses to answer and the result is `"unavailable"`.

```js
function setupFingerprintButton() {
  var btn = document.getElementById('fingerprint-button');
  if (!window.WebToApk || typeof window.WebToApk.isBiometricAvailable !== 'function') {
    btn.hidden = true;                      // browser: no fingerprint bridge
    return;
  }
  var state = window.WebToApk.isBiometricAvailable();
  if (state === 'available') {
    btn.hidden = false;
  } else if (state === 'not_enrolled') {
    btn.hidden = true;
    showHint('Add a fingerprint in your phone settings to unlock with it.');
  } else {
    btn.hidden = true;                      // no_hardware / unavailable
  }
}
```

A settings switch "Unlock with fingerprint" that is greyed out with the reason when it cannot work:

```js
var BIO_REASONS = {
  no_hardware: 'This phone has no fingerprint or face sensor.',
  not_enrolled: 'Set up a fingerprint or face unlock in your phone settings first.',
  unavailable: 'The sensor cannot be used right now.'
};

function renderBioSwitch() {
  var sw = document.getElementById('bio-switch');
  var note = document.getElementById('bio-note');
  var inApp = window.WebToApk && typeof window.WebToApk.isBiometricAvailable === 'function';
  var state = inApp ? window.WebToApk.isBiometricAvailable() : 'not_in_app';
  sw.disabled = state !== 'available';
  if (sw.disabled) sw.checked = false;
  note.textContent = state === 'not_in_app' ? 'Available in the app.' : (BIO_REASONS[state] || '');
}
```

Check again when the user comes back from phone settings, in case they just added a fingerprint:

```js
document.addEventListener('visibilitychange', function () {
  if (document.visibilityState !== 'visible') return;
  if (!window.WebToApk || typeof window.WebToApk.isBiometricAvailable !== 'function') return;
  if (window.WebToApk.isBiometricAvailable() === 'available') {
    renderBioSwitch();
    showHint('Fingerprint unlock is ready. Turn it on in Settings.');
  }
});
```

**Notes:** On Android 10 and newer the check counts face, iris and fingerprint, so a face-unlock-only phone answers `"available"`. On Android 6–9 it looks at the fingerprint sensor only, so a face-only phone there answers `"no_hardware"`; with `allowDeviceCredential:true` in `authenticateBiometricEx` the user can still unlock with the phone's PIN, pattern or password (Android 11+).

### `isFolderAccessEnabled`

```js
window.WebToApk.isFolderAccessEnabled(): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Tells you whether this app was built with folder access switched on at all.

**Returns:** the STRING `"true"` or `"false"`, synchronously. **Needs:** nothing to call it; it answers `"true"` only when **Native Folder Access (SAF)** was ticked in Step 4 (Access).

Use it to decide whether to show the folder feature. `WebToApkFS.isAvailable()` answers the same question as a boolean once the page has loaded:

```js
function folderFeatureShipped() {
  if (window.WebToApkFS) return window.WebToApkFS.isAvailable();
  return !!(window.WebToApk && typeof window.WebToApk.isFolderAccessEnabled === 'function' &&
    window.WebToApk.isFolderAccessEnabled() === 'true');
}

if (!folderFeatureShipped()) {
  document.getElementById('folder-panel').hidden = true;   // in a browser, or the switch was off
}
```

Drive a settings row through its three states (not in this build, not connected, connected):

```js
function folderSettingsState() {
  var fs = window.WebToApkFS;
  if (!fs || !fs.isAvailable()) return 'unavailable';
  return fs.hasAccess() ? 'connected' : 'not-connected';
}

var row = document.getElementById('folder-row');
switch (folderSettingsState()) {
  case 'unavailable':   row.hidden = true; break;
  case 'not-connected': row.querySelector('button').textContent = 'Choose a folder'; break;
  case 'connected':     row.querySelector('button').textContent = 'Disconnect'; break;
}
```

Mark the page before it is drawn, so CSS can show folder-only controls without a flash:

```js
// In a <script> inside <head>: the raw method is already there.
if (window.WebToApk && typeof window.WebToApk.isFolderAccessEnabled === 'function' &&
    window.WebToApk.isFolderAccessEnabled() === 'true') {
  document.documentElement.classList.add('has-folders');   // .has-folders .folder-only { display: block }
}
```

**Notes:** This says nothing about whether a folder is chosen yet — use `hasFolderAccess()` for that. In a browser `window.WebToApk` does not exist, so the check above answers `false` without throwing.

### `isFullscreen`

```js
window.WebToApk.isFullscreen(): Boolean
```

*(Fullscreen bridge)* Lets the page hide the status bar (clock/battery) and navigation bar itself — e.g. a game entering its play screen:     WebToApk.setFullscreen(true)   // hide the system bars     WebToApk.setFullscreen(false)  // bring them back     WebToApk.toggleFullscreen()     WebToApk.isFullscreen() The choice is remembered across launches. It is a no-op in apps the creator already locked to fullscreen/kiosk — there is nothing to restore to there. The side-menu "Fullscreen" item is the equivalent for end users and is gated by the creator's enableFullscreenToggle option; this bridge is always available to the app's own page.

**Example**

Tells you whether the Android status and navigation bars are hidden right now.

**Returns:** `true` / `false`, synchronously. **Needs:** nothing.

```js
function barsHidden() {
  if (window.WebToApk && typeof window.WebToApk.isFullscreen === 'function') {
    return window.WebToApk.isFullscreen();
  }
  return false;   // in a browser
}

if (barsHidden()) {
  document.getElementById('fs-btn').textContent = 'Exit fullscreen';
}
```

Lay the page out for the remembered choice on start, since it survives restarts:

```js
document.addEventListener('DOMContentLoaded', function () {
  var hidden = !!(window.WebToApk && typeof window.WebToApk.isFullscreen === 'function' &&
                  window.WebToApk.isFullscreen());
  document.documentElement.classList.toggle('bars-hidden', hidden);
  // CSS: .bars-hidden header { padding-top: env(safe-area-inset-top); }
});
```

Drop a "show bars" button when the app was built fullscreen and the bars cannot come back:

```js
function showBars() {
  var W = window.WebToApk;
  if (!(W && typeof W.isFullscreen === 'function')) return;
  W.setFullscreen(false);
  setTimeout(function () {             // the change is applied on the UI thread
    if (W.isFullscreen()) {
      document.getElementById('bars-btn').hidden = true;
    }
  }, 100);
}
```

**Notes:** It returns `true` when the creator built the app with **Fullscreen** or kiosk mode, OR when the page called `setFullscreen(true)` / `toggleFullscreen()`. In the first case `setFullscreen(false)` cannot bring the bars back.

### `isInterstitialAdReady`

```js
window.WebToApk.isInterstitialAdReady(): Boolean
```

True when an interstitial is loaded AND past its cooldown (matches exactly what showInterstitialAd() will accept — no "ready" that then refuses to show).

**Example**

Tells you whether an interstitial ad is loaded AND past its 60-second cooldown, so `showInterstitialAd()` would show it now (Automatic ad control).

**Returns:** `true` / `false`, synchronously. **Needs:** Ads on and **Full Screen Ad → Interstitial Ad**.

```js
function onLevelDone() {
  var b = window.WebToApk;
  if (b && typeof b.isInterstitialAdReady === 'function' && b.isInterstitialAdReady()) {
    b.showInterstitialAd();               // wait for 'appmint:ad-closed' before continuing
  } else {
    startNextLevel();                     // nothing ready: never block the user
  }
}

window.addEventListener('appmint:ad-closed', function (e) {
  if (e.detail.adType === 'interstitial') startNextLevel();
});
```

Show an ad after every third finished quiz, and only when one would really show:

```js
var finished = 0;

function onQuizFinished() {
  finished++;
  var b = window.WebToApk;
  var ready = !!(b && typeof b.isInterstitialAdReady === 'function' && b.isInterstitialAdReady());
  if (finished % 3 === 0 && ready) b.showInterstitialAd();
  showResults();                                  // the results are there when the ad closes
}
```

Show a short "Ad in 3…" notice only when an ad is ready, then continue when it closes or cannot show:

```js
var afterAd = null;

function adBreakThen(next) {
  var b = window.WebToApk;
  if (!b || typeof b.isInterstitialAdReady !== 'function' || !b.isInterstitialAdReady()) return next();
  var note = document.getElementById('ad-break'), n = 3;
  note.hidden = false; note.textContent = 'Ad in 3…';
  var t = setInterval(function () {
    if (--n > 0) { note.textContent = 'Ad in ' + n + '…'; return; }
    clearInterval(t); note.hidden = true;
    afterAd = next;
    b.showInterstitialAd();
  }, 1000);
}

function runAfterAd(e) {
  if (e.detail.adType !== 'interstitial' || !afterAd) return;
  var next = afterAd; afterAd = null; next();
}
window.addEventListener('appmint:ad-closed', runAfterAd);
window.addEventListener('appmint:ad-unavailable', runAfterAd);
```

**Notes:** `true` here means the show will not be refused for `not_loaded` or `cooldown`. Consent or a "Remove Ads" purchase can still stop it (`appmint:ad-unavailable`). In **Developer-controlled** builds use `AppMintAds.isReady('interstitial')`.

### `isKeyHardwareBacked`

```js
window.WebToApk.isKeyHardwareBacked(alias: String): String
```

True when [alias] lives in the TEE or a secure element rather than in software. Worth checking before promising a user that destroying a key is irreversible.

**Example**

Tells you whether a key lives inside the phone's secure hardware (TEE or StrongBox chip) rather than in software. Most pages use `AppMintKeystore.isHardwareBacked(alias)`, which returns a real boolean.

**Returns:** the STRING `"true"` or `"false"`, at once. `"false"` also when the key does not exist. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function describeVault() {
  if (!window.WebToApk || typeof window.WebToApk.isKeyHardwareBacked !== 'function') return;
  var hw = window.WebToApk.isKeyHardwareBacked('vault') === 'true';
  document.getElementById('vault-info').textContent = hw
    ? 'Your key is locked in this phone\'s secure hardware.'
    : 'Your key is protected by software on this phone.';
}
```

Refuse to keep a high-value secret on a phone that can only hold the key in software:

```js
function setUpWalletKey() {
  var W = window.WebToApk;
  if (!W || typeof W.isKeyHardwareBacked !== 'function') return false;
  var r = JSON.parse(W.generateKey('wallet', JSON.stringify({ requireAuth: true })));
  if (!r.ok) return false;
  if (W.isKeyHardwareBacked('wallet') !== 'true') {
    W.deleteKey('wallet');
    alert('This phone cannot keep the wallet key in secure hardware, so the wallet is off here.');
    return false;
  }
  return true;
}
```

Send your server a small device-security summary with sign-in, so it can decide what to allow:

```js
function deviceSecurity() {
  var W = window.WebToApk;
  var inApp = !!W && typeof W.isKeyHardwareBacked === 'function';
  return {
    secureKeys: inApp && W.isKeystoreEnabled() === 'true',
    hardwareKey: inApp && W.isKeyHardwareBacked('vault') === 'true'
  };
}

fetch('/api/sign-in', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: email, password: password, device: deviceSecurity() })
});
```

**Notes:** Say "hardware" in your UI only when this is `"true"`.

### `isKeystoreEnabled`

```js
window.WebToApk.isKeystoreEnabled(): String
```

Whether the creator enabled the keystore bridge for this app.

**Example**

Tells you whether the creator turned on the Keystore bridge for this app. Most pages use `AppMintKeystore.isAvailable()`, which returns a real boolean.

**Returns:** the STRING `"true"` or `"false"`, at once. **Needs:** nothing to call it; it answers `"true"` only when **Secure Keys (encryption)** was turned on when you built.

```js
function keystoreReady() {
  return !!window.WebToApk &&
    typeof window.WebToApk.isKeystoreEnabled === 'function' &&
    window.WebToApk.isKeystoreEnabled() === 'true';
}

document.getElementById('vault-button').hidden = !keystoreReady();
```

Offer "Remember me" only when the token can be stored encrypted:

```js
var canSeal = !!window.WebToApk &&
  typeof window.WebToApk.isKeystoreEnabled === 'function' &&
  window.WebToApk.isKeystoreEnabled() === 'true';

var remember = document.getElementById('remember-me');
remember.parentElement.hidden = !canSeal;

function afterSignIn(token) {
  if (!canSeal || !remember.checked) return;      // nothing stored in plain text
  if (window.WebToApk.hasKey('session') !== 'true') window.WebToApk.generateKey('session', '{}');
  var r = JSON.parse(window.WebToApk.encryptWithKey('session', btoa(token)));
  if (r.ok) localStorage.setItem('token', r.base64);
}
```

Tell apart "opened in a browser" and "app built without Secure Keys" while you test:

```js
function keystoreStatus() {
  if (!window.WebToApk) return 'browser: no app bridge';
  if (typeof window.WebToApk.isKeystoreEnabled !== 'function') return 'app without the keystore bridge';
  return window.WebToApk.isKeystoreEnabled() === 'true'
    ? 'Secure Keys on'
    : 'Secure Keys off in this build';
}

console.log('Keystore:', keystoreStatus());
```

**Notes:** When it is `"false"`, every other key method answers `"false"`, `"[]"` or `{ok:false, error:"disabled"}`. Hide the feature instead of offering a button that cannot work.

### `isOwned`

```js
window.WebToApk.isOwned(productId: String): Boolean
```

Instant on-device ownership check (one-time product owned OR sub active).

**Example**

Checks, instantly and offline, whether the user owns one of your Play products (a one-time product, or a subscription that is still active).

**Returns:** `true` / `false`, synchronously. **Needs:** **💰 Sell In-App Products** in the build wizard. It also answers for the **Remove Ads (IAP)** product.

```js
function hasPro() {
  var b = window.WebToApk;
  return !!(b && typeof b.isOwned === 'function' && b.isOwned('pro_unlock'));
}

function paint() {
  document.getElementById('pro-features').hidden = !hasPro();
  document.getElementById('buy-pro').hidden = hasPro();
}
paint();

// Ownership changes: a purchase landed, or the launch restore / a refund changed the list.
window.addEventListener('appmint:purchase', paint);
window.addEventListener('appmint:owned-changed', paint);
```

Check at the moment of use: a Pro-only export runs when owned, and otherwise opens the purchase sheet and finishes once the purchase lands:

```js
var exportWaiting = false;

document.getElementById('export-hd').addEventListener('click', function () {
  var b = window.WebToApk;
  if (!b || typeof b.isOwned !== 'function') { exportStandard(); return; }   // browser
  if (b.isOwned('pro_unlock')) { exportHd(); return; }
  exportWaiting = true;
  b.purchase('pro_unlock');
});

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock' && exportWaiting) {
    exportWaiting = false;
    exportHd();                                  // finish what the user asked for
  }
});
```

Pro through any of several plans: a monthly, a yearly and a lifetime product all count:

```js
var PRO_PLANS = ['pro_monthly', 'pro_yearly', 'pro_lifetime'];

function isPro() {
  var b = window.WebToApk;
  if (!b || typeof b.isOwned !== 'function') return false;
  return PRO_PLANS.some(function (id) { return b.isOwned(id); });
}

function paintPlan() { document.body.classList.toggle('pro', isPro()); }
paintPlan();
window.addEventListener('appmint:purchase', paintPlan);
window.addEventListener('appmint:owned-changed', paintPlan);   // an expired subscription or a refund
```

**Notes:** The owned list is restored from the user's Google account at every launch and saved on the phone, so it works offline and after a reinstall, with no server. Always `false` in a browser. A subscription stops counting once Play no longer reports it as active.

### `isPremium`

```js
window.WebToApk.isPremium(): Boolean
```

True when the user owns "Remove Ads" (Play Billing entitlement, auto-restored on every launch). Matches the reason 'ad_free' in 'appmint:ad-unavailable'.

**Example**

Tells you whether the user owns your "Remove Ads" product, so the page can hide its "Remove ads" button and ad-related UI.

**Returns:** `true` / `false`, synchronously. **Needs:** Ads on and **Remove Ads (IAP)** with your **Play Store Product ID**. Without that it is always `false`.

```js
var removeAdsBtn = document.getElementById('remove-ads');

function paintPremium(premium) {
  removeAdsBtn.hidden = premium;
  document.body.classList.toggle('premium', premium);
}

var b = window.WebToApk;
paintPremium(!!(b && typeof b.isPremium === 'function' && b.isPremium()));

// Changes later: purchase finished, restore at launch, refund or expired subscription.
window.addEventListener('appmint:premium', function (e) {
  paintPremium(e.detail.premium);            // e.detail = {premium: true | false}
});
```

The same change also calls a page hook, if you define it:

```js
window.onAppMintPremiumChanged = function (info) { paintPremium(info.premium); };   // {premium}
```

Show your own "Remove ads" offer on the game-over screen only to players who still see ads:

```js
function showGameOver(score) {
  document.getElementById('final-score').textContent = score;
  var b = window.WebToApk;
  var premium = !!(b && typeof b.isPremium === 'function' && b.isPremium());
  // Ad-free already, or a plain browser (no ads there): no offer.
  document.getElementById('remove-ads-offer').hidden = premium || !b;
  document.getElementById('game-over').hidden = false;
}
```

**Notes:** Ownership is restored from the user's Google account at every launch, so read it once at start and then follow `appmint:premium`. The Product ID field accepts several IDs separated by commas (for example a monthly plan and a lifetime one); owning any of them counts. While premium, ads are removed and ad calls answer `ad_free`.

### `isRewardedAdReady`

```js
window.WebToApk.isRewardedAdReady(): Boolean
```

True when a rewarded ad is loaded and can be shown right now. Let the page enable/disable its "Watch ad" button off this (poll or call before showing).

**Example**

Tells you whether a rewarded ad is loaded and can be shown right now (Automatic ad control).

**Returns:** `true` / `false`, synchronously. **Needs:** Ads on and **Full Screen Ad → Rewarded Ad**.

Enable the "Watch an ad" button only when an ad is waiting:

```js
var btn = document.getElementById('earn');

function refreshWatchButton() {
  var b = window.WebToApk;
  var ready = !!(b && typeof b.isRewardedAdReady === 'function' && b.isRewardedAdReady());
  btn.disabled = !ready;
  btn.textContent = ready ? 'Watch an ad for 10 coins' : 'Loading ad…';
}
refreshWatchButton();
setInterval(refreshWatchButton, 2000);                 // an ad can finish loading at any time
window.addEventListener('appmint:ad-closed', refreshWatchButton);
```

On game over, offer "Continue" only when an ad is waiting, so the button never leads nowhere:

```js
var continueBtn = document.getElementById('continue-with-ad');

function onGameOver() {
  var b = window.WebToApk;
  continueBtn.hidden = !(b && typeof b.isRewardedAdReady === 'function' && b.isRewardedAdReady());
  showGameOverPanel();                            // "Restart" is always there
}

continueBtn.addEventListener('click', function () {   // the user's own tap
  continueBtn.hidden = true;
  window.WebToApk.showRewardedAd();
});

window.addEventListener('appmint:reward', function () { revivePlayer(); });
```

Check at the moment of the tap and tell the user what is happening instead of firing a show that will be refused:

```js
document.getElementById('hint').addEventListener('click', function () {
  var b = window.WebToApk;
  if (!b || typeof b.isRewardedAdReady !== 'function') return showMessage('Hints need the app.');
  if (typeof b.isPremium === 'function' && b.isPremium()) return revealHint();   // paid to remove ads
  if (b.isRewardedAdReady()) b.showRewardedAd();
  else showMessage('The ad is still loading. Try again in a few seconds.');
});

window.addEventListener('appmint:reward', function () { revealHint(); });
```

**Notes:** Returns `false` in a browser, when ads were removed by purchase, and before the first ad has loaded. After an ad is shown the next one loads by itself. In **Developer-controlled** builds use `AppMintAds.isReady('rewarded')`.

### `isSecureScreen`

```js
window.WebToApk.isSecureScreen(): Boolean
```

*(Screen-capture protection)* WebToApk.setSecureScreen(true)   // block screenshots on THIS page   WebToApk.setSecureScreen(false)  // release it early   WebToApk.isSecureScreen()        // -> boolean  Protection belongs to the page that asked for it. It is released the moment the app navigates anywhere else — a new document, a pushState route, a #hash route — so a protected screen cannot leave the rest of the app locked down, and a page that wants protection back after a reload simply asks again.  What it does: screenshots, screen recording, casting and the recents-screen thumbnail all stop while it is on. What it cannot do: stop a camera pointed at the screen. Nothing on Android can, and no bridge here will pretend otherwise.

**Example**

Tells you whether screenshot protection (`setSecureScreen`) is on right now.

**Returns:** a boolean, at once. **Needs:** nothing.

```js
function updateLockIcon() {
  var on = !!window.WebToApk &&
    typeof window.WebToApk.isSecureScreen === 'function' &&
    window.WebToApk.isSecureScreen();
  document.getElementById('shield').hidden = !on;
}
```

Check before showing a card number, and turn protection on first if this page has not asked for it yet:

```js
function revealCardNumber(full) {
  var W = window.WebToApk;
  if (W && typeof W.isSecureScreen === 'function' && !W.isSecureScreen()) {
    W.setSecureScreen(true);   // the new state is committed when this returns
  }
  document.getElementById('card-number').textContent = full;
}
```

A "Hide from screenshots" switch that starts from the real state and flips it:

```js
var toggle = document.getElementById('privacy-toggle');
var W = window.WebToApk;

if (!W || typeof W.isSecureScreen !== 'function') {
  toggle.hidden = true;
} else {
  toggle.checked = W.isSecureScreen();
  toggle.addEventListener('change', function () {
    toggle.checked = W.setSecureScreen(!W.isSecureScreen());
  });
}
```

**Notes:** It goes back to `false` by itself when the app navigates to another page or route, because protection belongs to the page that asked for it.

### `listCallLog`

```js
window.WebToApk.listCallLog(requestId: String, limit: Int, offset: Int, sinceMillis: String)
```

*(Call log)* WebToApk.listCallLog(id, limit, offset, sinceMillis) Delivered as `appmint:calllog` / window.onAppMintCallLog.

**Example**

Reads the phone's call history, newest call first.

**Returns:** nothing now. The answer is the `appmint:calllog` event (and `window.onAppMintCallLog`) with `detail = { requestId, calls: [{ number, name, type, date, duration }] }` or `{ requestId, error }`. `date` is milliseconds since 1970; `duration` is seconds. **Needs:** turn on **Call log (read)** in Step 4 (Access) when you build; the user is asked for permission on the first call.

**Show the last 50 calls.**

```js
function loadCalls() {
  if (!(window.WebToApk && window.WebToApk.listCallLog)) return;
  var id = 'calls-' + Date.now();

  window.addEventListener('appmint:calllog', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:calllog', handler);
    if (e.detail.error) { showMessage('Call log: ' + e.detail.error); return; }

    e.detail.calls.forEach(function (c) {
      // c.type: incoming | outgoing | missed | voicemail | rejected | blocked | other
      addRow((c.name || c.number) + ' - ' + c.type + ' - ' +
             new Date(c.date).toLocaleString() + ' - ' + c.duration + 's');
    });
  });

  WebToApk.listCallLog(id, 50, 0, '0');   // limit, offset, since (string)
}
```

**Only calls from the last 24 hours**, using the page hook. `sinceMillis` is a STRING because the number is too big for the bridge's int.

```js
window.onAppMintCallLog = function (detail) {
  if (detail.requestId !== 'today') return;
  if (detail.error) return;
  document.getElementById('count').textContent = detail.calls.length + ' calls today';
};

if (window.WebToApk && window.WebToApk.listCallLog) {
  WebToApk.listCallLog('today', 500, 0, String(Date.now() - 24 * 60 * 60 * 1000));
}
```

A call-history screen with a "Load more" button, fetching 25 calls at a time with `offset`:

```js
var PAGE = 25, loaded = 0;

function loadMoreCalls() {
  if (!(window.WebToApk && typeof window.WebToApk.listCallLog === 'function')) return;
  var id = 'page-' + loaded;

  window.addEventListener('appmint:calllog', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:calllog', handler);
    if (e.detail.error) { showMessage('Call log: ' + e.detail.error); return; }

    e.detail.calls.forEach(function (c) { addRow(c.name || c.number, c.type, c.date); });
    loaded += e.detail.calls.length;
    document.getElementById('more').hidden = e.detail.calls.length < PAGE;   // last page
  });

  WebToApk.listCallLog(id, PAGE, loaded, '0');   // skip the calls already shown
}

document.getElementById('more').addEventListener('click', loadMoreCalls);
loadMoreCalls();
```

**Notes:** `limit` is clamped to 1–2000. Errors: `not_enabled`, `permission_denied`, `foreign_origin`. Call log is a Google Play restricted permission: you must file an approved Permissions Declaration in Play Console, or the app is rejected.

### `listContacts`

```js
window.WebToApk.listContacts(requestId: String, limit: Int, offset: Int)
```

*(Contacts)* All async: cursor reads can be thousands of rows, and a blocking @JavascriptInterface return would run that on the JS thread — an ANR. Results arrive as `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: pass a STRING ('pick-' + Date.now()). A raw NUMBER also works — the bridge coerces it through a double ("1.787491234567E12"), and every reply canonicalizes it back (BridgeRequestId), so `res.requestId === Date.now()` strict-compares true either way.    WebToApk.listContacts(id, limit, offset)   WebToApk.searchContacts(id, query, limit)   WebToApk.getContact(id, contactId)   WebToApk.pickContact(id)      <- NO permission needed (system picker)   WebToApk.addContact(id, json) <- needs the Contacts-write permission

**Example**

Reads the phone's address book, page by page, sorted A to Z by name.

**Returns:** nothing now. The answer comes later as the `appmint:contacts` event (and `window.onAppMintContacts`) with `detail = { requestId, contacts: [{ id, displayName, photoUri, phones: [{ number, type }], emails: ['a@b.com'] }] }`, or `{ requestId, error }`. **Needs:** turn on **Contacts** in Step 4 (Access) when you build; the user is asked for permission on the first call.

**Load the first 100 contacts.** Match the answer on your own `requestId`.

```js
function loadContacts() {
  if (!(window.WebToApk && window.WebToApk.listContacts)) {
    showMessage('Contacts work only inside the app.');
    return;
  }
  var id = 'contacts-' + Date.now();

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;   // another call's answer
    window.removeEventListener('appmint:contacts', handler);

    if (e.detail.error) { showMessage('Could not read contacts: ' + e.detail.error); return; }

    var list = document.getElementById('list');
    list.innerHTML = '';
    e.detail.contacts.forEach(function (c) {
      var li = document.createElement('li');
      var num = c.phones.length ? c.phones[0].number + ' (' + c.phones[0].type + ')' : '';
      li.textContent = c.displayName + ' ' + num;
      list.appendChild(li);
    });
  });

  WebToApk.listContacts(id, 100, 0);   // limit 100, start at 0
}
```

**Next page.** Pass the number you already have as `offset`: `WebToApk.listContacts(id, 100, 100)`.

Load the whole address book in pages of 500 until a short page says you reached the end:

```js
function listPage(limit, offset) {
  return new Promise(function (resolve, reject) {
    var id = 'page-' + offset + '-' + Date.now();
    window.addEventListener('appmint:contacts', function handler(e) {
      if (!e.detail || e.detail.requestId !== id) return;
      window.removeEventListener('appmint:contacts', handler);
      if (e.detail.error) reject(new Error(e.detail.error));
      else resolve(e.detail.contacts);
    });
    WebToApk.listContacts(id, limit, offset);
  });
}

async function loadEveryContact() {
  if (!(window.WebToApk && window.WebToApk.listContacts)) return [];
  var all = [];
  while (true) {
    var page = await listPage(500, all.length);
    all = all.concat(page);
    if (page.length < 500) return all;   // last page
  }
}
```

Build an "Invite friends" list from mobile numbers only, and tell the user what to do when reading is refused:

```js
function showInviteList() {
  if (!(window.WebToApk && window.WebToApk.listContacts)) return;
  var id = 'invite-' + Date.now();

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:contacts', handler);

    if (e.detail.error === 'permission_denied') { showMessage('Allow Contacts to invite friends.'); return; }
    if (e.detail.error) { showMessage('Contacts are not available: ' + e.detail.error); return; }

    var mobiles = [];
    e.detail.contacts.forEach(function (c) {
      c.phones.forEach(function (p) {
        if (p.type === 'mobile') mobiles.push({ name: c.displayName, number: p.number });
      });
    });
    renderInviteList(mobiles);   // your own list UI
  });

  WebToApk.listContacts(id, 500, 0);
}
```

**Notes:** `limit` is clamped to 1–2000. Errors: `not_enabled` (Contacts was off at build time), `permission_denied` (the user said No), `foreign_origin` (called from a page that is not your app's own). Phone `type` is `home`, `mobile`, `work`, `main`, `work_fax`, `home_fax` or `other`. `photoUri` is a `content://` address, not an image URL a page can load. To let the user choose just one person, use `pickContact` instead — it needs no permission. Contacts is personal data: your Play Store listing must declare it in the Data safety form and privacy policy.

### `listFolderEntries`

```js
window.WebToApk.listFolderEntries(relativePath: String): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Lists the files and sub-folders inside a folder of the granted folder.

**Returns:** a JSON STRING, synchronously: an array of `{ name, uri, isDirectory, length, lastModified }` (`length` in bytes, `lastModified` in ms since 1970). `WebToApkFS.list(path)` parses it for you. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access) and a granted folder.

With the helper (`''` is the granted folder itself):

```js
function showFolder(path) {
  if (!window.WebToApkFS) return;
  var entries = window.WebToApkFS.list(path);          // already an array
  entries.forEach(function (e) {
    console.log(e.isDirectory ? '[dir] ' : '      ', e.name, e.length + ' bytes');
  });
}
showFolder('');          // the root
showFolder('notes');     // a sub-folder
```

The raw call, which returns text you must parse:

```js
if (window.WebToApk && typeof window.WebToApk.listFolderEntries === 'function') {
  var entries = JSON.parse(window.WebToApk.listFolderEntries('notes'));
  console.log(entries.length + ' entries');
}
```

Find the newest backup file and show when it was made:

```js
function latestBackup() {
  if (!window.WebToApkFS) return null;
  var backups = window.WebToApkFS.list('backups').filter(function (e) {
    return !e.isDirectory && /\.json$/i.test(e.name);
  });
  backups.sort(function (a, b) { return b.lastModified - a.lastModified; });   // newest first
  return backups[0] || null;
}

var last = latestBackup();
document.getElementById('last-backup').textContent =
  last ? 'Last backup: ' + new Date(last.lastModified).toLocaleString() : 'No backups yet';
```

**Notes:** An empty folder, a missing folder, a path that is a file, a path with `..`, no granted folder, and a page from a foreign website all answer `[]` — call `hasFolderAccess()` first if you need to tell "no folder" apart. Paths are relative, use `/`, and never go above the granted folder.

### `listKeys`

```js
window.WebToApk.listKeys(): String
```

JSON array of every key alias this page has created.

**Example**

Lists the aliases of every Keystore key this app's pages created. Most pages use `AppMintKeystore.listKeys()`, which returns the parsed array.

**Returns:** a JSON array string, sorted by name, at once: `'["notes","vault"]'`. **Needs:** **Secure Keys (encryption)** turned on when you build (without it: `"[]"`).

```js
function showKeys() {
  if (!window.WebToApk || typeof window.WebToApk.listKeys !== 'function') return;
  var aliases = JSON.parse(window.WebToApk.listKeys());
  document.getElementById('keys').textContent =
    aliases.length ? aliases.join(', ') : 'No keys yet';
}
```

A "Security keys" settings screen that lists each key, where it lives, and a Delete button:

```js
function renderKeyManager() {
  if (!window.WebToApk || typeof window.WebToApk.listKeys !== 'function') return;
  var list = document.getElementById('key-list');
  list.innerHTML = '';
  JSON.parse(window.WebToApk.listKeys()).forEach(function (alias) {
    var hw = window.WebToApk.isKeyHardwareBacked(alias) === 'true';
    var li = document.createElement('li');
    li.textContent = alias + (hw ? ' (secure hardware) ' : ' (software) ');
    var del = document.createElement('button');
    del.textContent = 'Delete';
    del.onclick = function () {
      if (!confirm('Delete "' + alias + '"? Data sealed with it is lost for good.')) return;
      window.WebToApk.deleteKey(alias);
      renderKeyManager();
    };
    li.appendChild(del);
    list.appendChild(li);
  });
}
```

On sign-out, destroy every per-account key (named `user_...`) so nothing cached on the phone can be read again:

```js
function forgetAllAccounts() {
  if (!window.WebToApk || typeof window.WebToApk.listKeys !== 'function') return 0;
  var removed = 0;
  JSON.parse(window.WebToApk.listKeys()).forEach(function (alias) {
    if (alias.indexOf('user_') === 0 && window.WebToApk.deleteKey(alias) === 'true') removed++;
  });
  return removed;
}

document.getElementById('sign-out').addEventListener('click', function () {
  var n = forgetAllAccounts();
  console.log('Destroyed ' + n + ' account key(s)');
  signOut();
});
```

**Notes:** Also answers `"[]"` when it is called from a page that is not the app's own (another site loaded in the app).

### `listSms`

```js
window.WebToApk.listSms(requestId: String, box: String, limit: Int, offset: Int)
```

*(SMS)* WebToApk.composeSms(number, message)   <- NO permission, opens the SMS app   WebToApk.sendSms(id, number, message, wantDelivery)   WebToApk.listSms(id, "inbox"|"sent"|"draft", limit, offset) Send results: `appmint:sms` / window.onAppMintSms   {requestId, status:"sent"|"delivered"|"failed", ok, reason?} Inbound messages: `appmint:sms-received` / window.onAppMintSmsReceived.

**Example**

Reads SMS messages from the phone's inbox, sent box or drafts, newest first. This file also shows how to receive new messages as they arrive.

**Returns:** nothing now. The answer is the `appmint:sms` event (and `window.onAppMintSms`) with `detail = { requestId, messages: [{ id, address, body, date, read, box }] }`. On failure: `{ requestId, status: 'failed', ok: false, reason }`. **Needs:** turn on **SMS (read & send)** in Step 4 (Access) when you build; the user is asked for permission on the first call.

**Read the latest 30 inbox messages.**

```js
function loadInbox() {
  if (!(window.WebToApk && window.WebToApk.listSms)) return;
  var id = 'inbox-' + Date.now();

  window.addEventListener('appmint:sms', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:sms', handler);
    if (e.detail.status === 'failed') { showMessage('Cannot read SMS: ' + e.detail.reason); return; }

    e.detail.messages.forEach(function (m) {
      addRow(m.address + ': ' + m.body + (m.read ? '' : ' (new)') +
             ' - ' + new Date(m.date).toLocaleString());
    });
  });

  WebToApk.listSms(id, 'inbox', 30, 0);   // box: 'inbox' | 'sent' | 'draft'
}
```

**New messages as they arrive** (`appmint:sms-received`, also `window.onAppMintSmsReceived`). Messages that arrive while the app is closed are kept (the last 50) and given to your page when the app opens again.

```js
window.addEventListener('appmint:sms-received', function (e) {
  // e.detail = { address: '+91...', body: 'Your OTP is 4821', date: 1737000000000 }
  var code = (e.detail.body.match(/\b\d{4,6}\b/) || [])[0];
  if (code) document.getElementById('otp').value = code;
});

// or, as a page hook:
window.onAppMintSmsReceived = function (sms) {
  console.log('SMS from ' + sms.address);
};
```

An expense tracker that scans the last 200 inbox messages for bank debits and adds up the amounts:

```js
function scanSpending() {
  if (!(window.WebToApk && typeof window.WebToApk.listSms === 'function')) return;
  var id = 'spend-' + Date.now();

  window.addEventListener('appmint:sms', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:sms', handler);
    if (e.detail.status === 'failed') { showMessage('Cannot read SMS: ' + e.detail.reason); return; }

    var total = 0;
    e.detail.messages.forEach(function (m) {
      if (!/debited/i.test(m.body)) return;
      var amount = m.body.match(/(?:Rs\.?|INR)\s*([\d,]+(?:\.\d{1,2})?)/i);
      if (amount) total += parseFloat(amount[1].replace(/,/g, ''));
    });
    document.getElementById('spent').textContent = 'Spent: Rs ' + total.toFixed(2);
  });

  WebToApk.listSms(id, 'inbox', 200, 0);
}
```

**Notes:** `limit` is clamped to 1–500. Any unknown box name reads the inbox. `reason` values: `not_enabled`, `permission_denied`, `foreign_origin`. Received messages still reach the user's normal SMS app too. SMS is a Google Play restricted permission: you must file an approved Permissions Declaration in Play Console (usually only default SMS apps qualify), or the app is rejected.

### `makePhoneCall`

```js
window.WebToApk.makePhoneCall(phoneNumber: String)
```

Calls [phoneNumber]. - Phone permission ON in the build: places the call directly. The first time, Android asks the user for the Phone permission; if they refuse, the dialer opens with the number filled in so they can still place it themselves. - Phone permission OFF: opens the dialer with the number filled in (the dialer needs no permission) and the user taps Call. - A page that is not the app's own (an external site open in the app) always gets the dialer, never a direct call. A plain `tel:` link in your HTML also opens the dialer and is often enough.

**Example**

Starts a phone call to a number, or opens the dialer with the number filled in.

**Returns:** nothing. **Needs:** nothing to open the dialer. Turn on **Phone** in Step 4 (Access) when you build if the call should start directly.

**A "Call the shop" button.**

```js
document.getElementById('callShop').addEventListener('click', function () {
  if (window.WebToApk && window.WebToApk.makePhoneCall) {
    WebToApk.makePhoneCall('+911234567890');
  }
});
```

**No switch needed: a plain `tel:` link** opens the dialer with the number, in the app and in a browser. This is often all you need.

```html
<a href="tel:+911234567890">Call the shop</a>
```

One handler for a whole staff directory, where each row carries its number in a `data-phone` attribute:

```js
document.getElementById('staffList').addEventListener('click', function (e) {
  var btn = e.target.closest('[data-phone]');
  if (!btn) return;
  var number = btn.getAttribute('data-phone').replace(/[^\d+]/g, '');   // keep digits and +
  if (!number) return;
  if (window.WebToApk && typeof window.WebToApk.makePhoneCall === 'function') {
    WebToApk.makePhoneCall(number);
  } else {
    location.href = 'tel:' + number;   // a normal browser
  }
});
```

**Notes:** With **Phone** off, the dialer opens with the number and the user presses call. With **Phone** on, the call starts directly; the first time, Android asks the user for the phone-call permission, and if they refuse, the dialer opens instead so they can still place the call. A page that is not the app's own (an external site open inside the app) always gets the dialer. Pass the number as a plain string. Nothing is reported back — you cannot know if the call was placed. The Phone switch adds the CALL_PHONE permission to your Play listing; declare it in the Data safety form and only turn it on if you need it.

### `markPushRead`

```js
window.WebToApk.markPushRead(id: String): Boolean
```

Marks one push message read; pass "" to mark every message read.

**Example**

Marks one push inbox message as read, or all of them when you pass `''`.

**Returns:** `true` if a message changed; `false` if nothing changed (unknown id, already read, or Appwright Push not enabled). **Needs:** turn on **Appwright Push** in the Integrate step when you build.

```js
function openMessage(id) {
  if (!(window.WebToApk && window.WebToApk.markPushRead)) return;
  WebToApk.markPushRead(id);                 // one message
  updateBellCount();
}

document.getElementById('mark-all-read').addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.markPushRead)) return;
  WebToApk.markPushRead('');                 // every message
  updateBellCount();
});

function updateBellCount() {
  const inbox = JSON.parse(WebToApk.getPushInbox() || '[]');
  const unread = inbox.filter(function (m) { return !m.read; }).length;
  document.getElementById('bell-count').textContent = unread ? String(unread) : '';
}
```

Mark a message read once the user has actually seen it on screen, not just when they tap it:

```js
if (window.WebToApk && window.WebToApk.markPushRead) {
  const seen = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;
      const id = entry.target.dataset.msgId;
      seen.unobserve(entry.target);
      if (WebToApk.markPushRead(id)) {       // true only when it was unread
        entry.target.classList.replace('unread', 'read');
        updateBellCount();
      }
    });
  }, { threshold: 0.6 });

  document.querySelectorAll('#inbox li.unread').forEach(function (li) { seen.observe(li); });
}
```

Clear the badge when the inbox screen opens, and only redraw when something was actually unread:

```js
function openInboxScreen() {
  showScreen('inbox');
  if (!(window.WebToApk && window.WebToApk.markPushRead)) return;
  const changed = WebToApk.markPushRead('');  // false: nothing was unread
  if (changed) {
    document.getElementById('bell-count').textContent = '';
    renderInbox();
  }
}
```

**Notes:** read state is stored only on this phone. It does not remove the tray notification.

### `mkdir`

```js
window.WebToApk.mkdir(relativePath: String): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Creates a folder inside the granted folder, including any missing parent folders.

**Returns:** the STRING `"true"` or `"false"`, synchronously. `WebToApkFS.mkdir(path)` returns a real boolean. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access) and a granted folder.

With the helper:

```js
if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  var ok = window.WebToApkFS.mkdir('photos/2026/june');   // creates photos, 2026 and june
  if (!ok) console.log('Could not create the folder');
}
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.mkdir === 'function') {
  var made = window.WebToApk.mkdir('backups') === 'true';
}
```

Make a dated folder for one export and write every item into it:

```js
function exportAll(items) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var dir = 'exports/' + new Date().toISOString().slice(0, 10);   // e.g. exports/2026-09-25
  if (!window.WebToApkFS.mkdir(dir)) {
    alert('Could not create ' + dir + ' (is a file with that name in the way?)');
    return false;
  }
  items.forEach(function (item) {
    window.WebToApkFS.writeText(dir + '/' + item.id + '.json', JSON.stringify(item));
  });
  return true;
}
```

**Notes:** It answers `"true"` when the folder already exists, so it is safe to call before every save. It answers `"false"` when a FILE with that name is in the way, the path contains `..`, or no folder is granted.

### `navSettled`

```js
window.WebToApk.navSettled()
```

Companion to [navStarted]: the swapped-in view has painted.

**Example**

Tells the app that the new screen has painted, so it can end the top loading bar that `navStarted()` began.

**Returns:** nothing. **Needs:** nothing.

```js
function withLoadingBar(work) {
  var b = window.WebToApk;
  var bar = !!(b && typeof b.navStarted === 'function' && typeof b.navSettled === 'function');
  if (bar) b.navStarted();
  function end() { if (bar) b.navSettled(); }   // always, also when work() failed
  return Promise.resolve()
    .then(work)
    .then(function (v) { end(); return v; }, function (err) { end(); throw err; });
}

withLoadingBar(function () {
  return fetch('/api/feed').then(function (r) { return r.json(); }).then(renderFeed);
}).catch(function () { showError('Could not load the feed.'); });
```

End the bar only once the new screen's pictures are decoded, so the user never sees an empty gallery after the bar is gone:

```js
async function openAlbum(id) {
  var b = window.WebToApk;
  var bar = !!(b && typeof b.navStarted === 'function' && typeof b.navSettled === 'function');
  if (bar) b.navStarted();
  try {
    var photos = await fetch('/api/albums/' + id).then(function (r) { return r.json(); });
    var imgs = renderAlbum(photos);                   // your own code, returns the <img> elements
    await Promise.all(imgs.map(function (img) { return img.decode().catch(function () {}); }));
  } finally {
    if (bar) b.navSettled();
  }
}
```

A screen whose code is loaded on demand with a dynamic `import()`:

```js
async function openSettings() {
  var b = window.WebToApk;
  var bar = !!(b && typeof b.navStarted === 'function' && typeof b.navSettled === 'function');
  if (bar) b.navStarted();
  try {
    var mod = await import('./settings-screen.js');   // your own module
    mod.mount(document.getElementById('app'));
  } catch (e) {
    showError('Could not open settings.');
  } finally {
    if (bar) b.navSettled();
  }
}
```

**Notes:** Only ends a bar that `navStarted()` (or the app's own DOM observer) began; it never ends the bar of a real page load. The app's observer already calls both methods when your page swaps DOM, so you need them only for waits it cannot see.

### `navStarted`

```js
window.WebToApk.navStarted()
```

Internal (called by the injected navigation observer, not creator code): the page is changing view without changing its URL. This is the ONLY signal for it — a single-HTML app that swaps DOM nodes fires no onPageStarted, no onPageFinished, no doUpdateVisitedHistory, and leaves onProgressChanged pinned at 100, so from native the tap and the new screen are completely invisible. Ignored while a real document load is running: that load owns the bar, and its own callbacks decide when it ends.

**Example**

Tells the app that a screen change has started, so it can show its thin loading bar at the top. The app already calls this for you when your page swaps its DOM; call it yourself only for a change where nothing in the page moves until data arrives.

**Returns:** nothing. **Needs:** nothing.

Always pair it with `navSettled()`, in a `try/finally`:

```js
async function openOrder(id) {
  var b = window.WebToApk;
  var bar = !!(b && typeof b.navStarted === 'function' && typeof b.navSettled === 'function');
  if (bar) b.navStarted();
  try {
    var res = await fetch('/api/orders/' + id);
    renderOrder(await res.json());         // your own screen
  } catch (e) {
    showError('Could not load the order.');
  } finally {
    if (bar) b.navSettled();               // the new view has painted: end the bar
  }
}
```

A tab bar whose tabs need several requests before anything can be drawn:

```js
document.querySelectorAll('.tab').forEach(function (tab) {
  tab.addEventListener('click', async function () {
    var b = window.WebToApk;
    var bar = !!(b && typeof b.navStarted === 'function' && typeof b.navSettled === 'function');
    if (bar) b.navStarted();
    try {
      var urls = ['/api/stats?tab=' + tab.dataset.tab, '/api/items?tab=' + tab.dataset.tab];
      var data = await Promise.all(urls.map(function (u) { return fetch(u).then(function (r) { return r.json(); }); }));
      renderTab(tab.dataset.tab, data[0], data[1]);     // your own screen
    } catch (e) {
      showError('Could not load this tab.');
    } finally {
      if (bar) b.navSettled();
    }
  });
});
```

A search form that stays on the same page and swaps in the results:

```js
document.getElementById('search-form').addEventListener('submit', function (e) {
  e.preventDefault();
  var q = document.getElementById('search-input').value.trim();
  if (!q) return;
  var b = window.WebToApk;
  var bar = !!(b && typeof b.navStarted === 'function' && typeof b.navSettled === 'function');
  if (bar) b.navStarted();
  fetch('/api/search?q=' + encodeURIComponent(q))
    .then(function (r) { return r.json(); })
    .then(function (results) { renderResults(results); })
    .catch(function () { showError('Search failed. Try again.'); })
    .finally(function () { if (bar) b.navSettled(); });
});
```

**Notes:** Nothing is drawn unless the change takes longer than about 200 ms, so fast changes never flash the bar. A bar you forget to settle ends by itself after 2.5 seconds. The call is ignored while a real page load is running (that load owns the bar). Keep showing your own spinner or skeleton as usual; the bar is extra.

### `notify`

```js
window.WebToApk.notify(optionsJson: String): Boolean
```

Rich notification. See NotificationBridge.show for the full option list: channel (urgent|default|quiet|ongoing), image, largeIcon, bigText, ongoing, actions, progress, sound, silent, group, color. Image fetching and decoding happen off the main thread, so a remote image can never block the page.

**Example**

Posts a notification right now, with any mix of options: picture, big text, buttons, progress bar, sticky (ongoing), channel, sound, colour and tag.

**Returns:** `true` when the options were valid JSON and the notification was handed to the system (it is built on a background thread, so a bad image URL does not block the page); `false` for invalid JSON. **Needs:** turn on **Notifications** in the Permissions step when you build, and the user's permission on Android 13+ (ask with `Notification.requestPermission()`).

**Simple notification.** `tag` is an ID: posting again with the same tag replaces the old one.

```js
async function notifyOrderReady() {
  if (!(window.WebToApk && window.WebToApk.notify)) return;
  if (window.Notification && Notification.permission !== 'granted') {
    const p = await Notification.requestPermission();
    if (p !== 'granted') { showMessage('Notifications are off for this app.'); return; }
  }
  WebToApk.notify(JSON.stringify({
    title: 'Order ready',
    body: 'Your order is ready to collect',
    channel: 'default',            // urgent | default | quiet | ongoing
    tag: 'order-42'
  }));
}
```

**Picture, large icon and long text.** `image` wins over `bigText` when both are given.

```js
WebToApk.notify(JSON.stringify({
  title: 'New photo',
  body: 'Tap to see it',
  image: 'https://example.com/photo.jpg',     // expanded big picture
  largeIcon: 'https://example.com/avatar.png', // round icon on the right
  color: '#00C6FF',
  tag: 'photo-7'
}));

WebToApk.notify(JSON.stringify({
  title: 'Weekly summary',
  body: '3 tasks done, 2 left',
  bigText: 'Done: pay rent, call bank, water plants. Left: tax form, dentist.',
  channel: 'quiet',                            // no sound, low priority
  group: 'summaries'
}));
```

**Buttons and inline reply** (at most 3 buttons). The tap comes back as the `appmint:notification-action` event.

```js
WebToApk.notify(JSON.stringify({
  title: 'Ali sent you a message',
  body: 'Are we still on for 6pm?',
  channel: 'urgent',                           // heads-up banner
  tag: 'chat-ali',
  actions: [
    { id: 'reply', label: 'Reply', input: 'Type a reply' }, // input = reply box
    { id: 'done',  label: 'Mark read' }
  ]
}));

window.addEventListener('appmint:notification-action', function (e) {
  // e.detail = { actionId: 'reply', tag: 'chat-ali', text: 'Yes!' }
  if (e.detail.actionId === 'reply') sendReply(e.detail.tag, e.detail.text);
  if (e.detail.actionId === 'done') markRead(e.detail.tag);
});
```

**Sticky progress (ongoing).** Re-post with the same tag to update it. When the job ends, post the final state with `ongoing: false` so the user can swipe it away.

```js
function showUpload(percent) {
  WebToApk.notify(JSON.stringify({
    title: 'Uploading', body: percent + '%',
    ongoing: true,                             // cannot be swiped away
    progress: { current: percent, max: 100 },  // or { indeterminate: true }
    silent: true,
    tag: 'upload'
  }));
}
showUpload(40);
// finished:
WebToApk.notify(JSON.stringify({ title: 'Upload complete', body: '3 photos saved', tag: 'upload' }));
```

**Other options:** `sound` (a file in your bundle like `'/audio/ding.mp3'`, a `data:audio/...` URL, or `'silent'`), `smallIcon` (a drawable name), `when` (epoch ms shown as the time), `openApp: true` (full-screen alert; see `scheduleNotificationEx`).

**Notes:** `image` and `largeIcon` take an `https://` or `data:` URL, or an image packaged with your app named as your page names it (`'img/logo.png'`, `'/img/logo.png'`, or its full `https://appassets.androidplatform.net/assets/…` address). A bad image is skipped, the notification still shows. `ongoing: true` always uses the quiet "Ongoing" channel. `notify()` has no public "remove" partner: `cancelNotification` only cancels scheduled reminders. A notification you want to remove later from the page should be posted with `new Notification(...)` and removed with its `close()`; a sticky progress card with `Capacitor.AppwrightLiveProgress` (`end`). Android fixes a channel's sound when the channel is created, so a new `sound` value makes a new channel.

### `notifyUserAction`

```js
window.WebToApk.notifyUserAction()
```

Internal (called by the injected tap counter, not creator code): one meaningful user interaction happened in the page. Counts toward the "interstitial every N actions" cadence ONLY while no URL-based navigation has ever been observed — single-HTML apps that switch screens via DOM show/hide never change the URL, so without this their navigation counter stayed at 0 forever and configured ads never showed (TicTrek complaint class, 2026-07-26). URL/SPA apps are unaffected: their first real navigation permanently switches counting back to URL signals.

**Example**

Counts one meaningful user action toward the "full-screen ad every N actions" setting. The app calls this itself from its own tap counter; pages normally never call it.

**Returns:** nothing. **Needs:** Ads on, a **Full Screen Ad** with **Trigger Mode → After Navigations**, and Automatic ad control.

**How it works:** a page that switches screens by hiding and showing DOM elements never changes its URL, so the app cannot see a "navigation". The app therefore injects a tap counter (at most one count per 2 seconds) that calls this method. As soon as the app sees one real URL change (a page load, `pushState` or a `#hash`), it counts URL changes instead and this method stops counting.

Only if your screen changes do not come from a tap (for example a keyboard or gamepad move), report them yourself:

```js
function goToScreen(name) {
  showScreen(name);                                   // your own DOM show/hide
  var b = window.WebToApk;
  if (b && typeof b.notifyUserAction === 'function') b.notifyUserAction();
}

document.addEventListener('keydown', function (e) {
  if (e.key === 'Enter') goToScreen('next');
});
```

A card-style app where the user swipes to the next screen (a swipe is not a tap on a button, so the tap counter never sees it):

```js
var startX = null;

document.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; }, { passive: true });

document.addEventListener('touchend', function (e) {
  if (startX === null) return;
  var dx = e.changedTouches[0].clientX - startX;
  startX = null;
  if (Math.abs(dx) < 80) return;                       // not a swipe
  showScreen(dx < 0 ? 'next' : 'previous');            // your own DOM show/hide
  var b = window.WebToApk;
  if (b && typeof b.notifyUserAction === 'function') b.notifyUserAction();
});
```

An HTML5 game played with a gamepad, counting one action per level the player starts with the A button:

```js
var aWasDown = false;

function pollPad() {
  var pad = navigator.getGamepads ? navigator.getGamepads()[0] : null;
  var aDown = !!(pad && pad.buttons[0] && pad.buttons[0].pressed);
  if (aDown && !aWasDown && isOnLevelSelect()) {       // your own game state
    startSelectedLevel();
    var b = window.WebToApk;
    if (b && typeof b.notifyUserAction === 'function') b.notifyUserAction();
  }
  aWasDown = aDown;
  requestAnimationFrame(pollPad);
}

window.addEventListener('gamepadconnected', function () { requestAnimationFrame(pollPad); }, { once: true });
```

**Notes:** It does nothing when ads are off, removed by purchase, or when the build uses **Ad Control → Developer-controlled** (there you call `AppMintAds.show()` yourself). A rewarded ad reached this way is always offered with a "Watch an ad? / No thanks" question, never forced. Do not call it on every tap; that would show ads more often than you configured.

### `openedFileChunk`

```js
window.WebToApk.openedFileChunk(token: String, offset: String, len: Int): String
```

*(Files shared to, or opened with, the app)* Internal to AppMint.getOpenedFile() / getOpenedFiles() / releaseOpenedFile(). The page pulls, rather than being pushed at: a file opened on cold start is parked until it is asked for, so an SPA that mounts late still gets it. Each file's metadata carries `kind` (image | video | audio | document | data) and `url` — https://appassets.androidplatform.net/appmint-shared/<token>/<name>, served from the cache copy with byte ranges, so a shared video streams into <video src> and seeks. Data files and documents are also pulled in MAX_IO_CHUNK slices into a File; media and anything over 64 MB stay url-only (getFile() fetches on demand), so a 300 MB clip never sits in a JS array.

**Example**

Internal plumbing behind `AppMint.getOpenedFile()`: returns one slice of a shared or opened file's bytes, which the helper joins into a `File`. Pages should use the public API — `f.file`, `f.getFile()` or `f.url`.

**Returns:** (sync) a base64 **string** of up to `len` bytes starting at `offset` (the offset is passed as a string, so files over 2 GB still work); `""` at the end of the file, for an unknown or released token, or on a read error. **Needs:** turn on **Receive Shares From Other Apps** when you build.

The public API (use this):

```js
if (window.AppMint && typeof AppMint.getOpenedFile === 'function') {
  AppMint.getOpenedFile().then(function (f) {
    if (!f) return;
    return f.getFile().then(function (file) { console.log('got', file.size, 'bytes'); });
  });
}
```

What the helper does with it, shown only to explain the arguments:

```js
if (window.WebToApk && typeof WebToApk.openedFileChunk === 'function') {
  const meta = JSON.parse(WebToApk.openedFilesPending() || '[]')[0];
  if (meta) {
    const b64 = WebToApk.openedFileChunk(meta.token, '0', WebToApk.openedFileMaxChunk());
    console.log('first slice is', atob(b64).length, 'bytes of', meta.size);
  }
}
```

Reading a very large shared file without slices — stream `f.url` and show progress (here counting CSV rows):

```js
async function countRows() {
  if (!window.AppMint || typeof AppMint.getOpenedFile !== 'function') return;
  const f = await AppMint.getOpenedFile();
  if (!f) return;
  const res = await fetch(f.url);                // served from disk, no base64 round trips
  if (!res.ok) throw new Error('shared file unavailable (' + res.status + ')');
  const reader = res.body.getReader();
  let seen = 0, rows = 0;
  for (;;) {
    const step = await reader.read();
    if (step.done) break;
    seen += step.value.length;
    for (let i = 0; i < step.value.length; i++) if (step.value[i] === 10) rows++;
    showProgress(seen / f.size);
  }
  console.log(rows + ' rows in ' + f.name);
}
```

**Notes:** `len` is clamped to 1 byte … 4 MB. For media files, streaming `f.url` is far cheaper than pulling slices.

### `openedFileCollected`

```js
window.WebToApk.openedFileCollected(token: String)
```

*(Files shared to, or opened with, the app)* Internal to AppMint.getOpenedFile() / getOpenedFiles() / releaseOpenedFile(). The page pulls, rather than being pushed at: a file opened on cold start is parked until it is asked for, so an SPA that mounts late still gets it. Each file's metadata carries `kind` (image | video | audio | document | data) and `url` — https://appassets.androidplatform.net/appmint-shared/<token>/<name>, served from the cache copy with byte ranges, so a shared video streams into <video src> and seeks. Data files and documents are also pulled in MAX_IO_CHUNK slices into a File; media and anything over 64 MB stay url-only (getFile() fetches on demand), so a 300 MB clip never sits in a JS array.

**Example**

Internal plumbing behind `AppMint.getOpenedFile()`: marks a shared or opened file as collected, so it is no longer offered as "new" but its cached copy is kept. Pages should use the public API; there is no reason to call this directly.

**Returns:** nothing. **Needs:** turn on **Receive Shares From Other Apps** when you build.

The public API (use this) — each file is collected once and handed to your page:

```js
if (window.AppMint && typeof AppMint.getOpenedFiles === 'function') {
  AppMint.getOpenedFiles().then(function (files) {
    files.forEach(function (f) { console.log(f.name, f.kind); });
  });
}
window.addEventListener('appmint:fileopen', function (e) { console.log('new file', e.detail.name); });
```

A file collected at startup is both in the list and announced by the event, so key your own handling by `token` to process each file once:

```js
const handled = new Set();
function handleOnce(f) {
  if (handled.has(f.token)) return;
  handled.add(f.token);
  addToInbox(f);
}
window.addEventListener('appmint:fileopen', function (e) { handleOnce(e.detail); });
if (window.AppMint && typeof AppMint.getOpenedFiles === 'function') {
  AppMint.getOpenedFiles().then(function (files) { files.forEach(handleOnce); });
}
```

A file that could not be read is still collected; it is reported once through `appmint:fileopenerror` instead of `appmint:fileopen`:

```js
window.addEventListener('appmint:fileopenerror', function (e) {
  const d = e.detail;             // {name, error}
  showBanner('Could not open "' + d.name + '". Try sharing it again.');
  console.warn('opened file failed:', d.error);
});
```

**Notes:** Calling it yourself would hide a file from `AppMint.getOpenedFile()` and the `appmint:fileopen` event without giving it to anyone. To free the cached copy, use `AppMint.releaseOpenedFile(f)`.

### `openedFileMaxChunk`

```js
window.WebToApk.openedFileMaxChunk(): Int
```

*(Files shared to, or opened with, the app)* Internal to AppMint.getOpenedFile() / getOpenedFiles() / releaseOpenedFile(). The page pulls, rather than being pushed at: a file opened on cold start is parked until it is asked for, so an SPA that mounts late still gets it. Each file's metadata carries `kind` (image | video | audio | document | data) and `url` — https://appassets.androidplatform.net/appmint-shared/<token>/<name>, served from the cache copy with byte ranges, so a shared video streams into <video src> and seeks. Data files and documents are also pulled in MAX_IO_CHUNK slices into a File; media and anything over 64 MB stay url-only (getFile() fetches on demand), so a 300 MB clip never sits in a JS array.

**Example**

Internal plumbing behind `AppMint.getOpenedFile()`: the largest slice `openedFileChunk` returns in one call (4 MB). Pages should use the public API, which already reads in slices of this size.

**Returns:** (sync) a number of bytes (4194304). **Needs:** turn on **Receive Shares From Other Apps** when you build.

The public API (use this):

```js
if (window.AppMint && typeof AppMint.getOpenedFile === 'function') {
  AppMint.getOpenedFile().then(function (f) {
    if (f && f.file) console.log('pulled in 4 MB slices:', f.file.size, 'bytes');
  });
}
```

The raw call, if you only want to know the number:

```js
if (window.WebToApk && typeof WebToApk.openedFileMaxChunk === 'function') {
  console.log('slice size', WebToApk.openedFileMaxChunk());
}
```

Estimating how many bridge calls the helper will make for each waiting file, when profiling a slow import:

```js
if (window.WebToApk && typeof WebToApk.openedFileMaxChunk === 'function') {
  const slice = WebToApk.openedFileMaxChunk();
  JSON.parse(WebToApk.openedFilesPending() || '[]').forEach(function (m) {
    console.log(m.name, (m.size / 1048576).toFixed(1) + ' MB =', Math.ceil(m.size / slice), 'slice(s)');
  });
}
```

**Notes:** Same cap as `getMaxIoChunkSize()` for the file-access bridge.

### `openedFileRelease`

```js
window.WebToApk.openedFileRelease(token: String)
```

*(Files shared to, or opened with, the app)* Internal to AppMint.getOpenedFile() / getOpenedFiles() / releaseOpenedFile(). The page pulls, rather than being pushed at: a file opened on cold start is parked until it is asked for, so an SPA that mounts late still gets it. Each file's metadata carries `kind` (image | video | audio | document | data) and `url` — https://appassets.androidplatform.net/appmint-shared/<token>/<name>, served from the cache copy with byte ranges, so a shared video streams into <video src> and seeks. Data files and documents are also pulled in MAX_IO_CHUNK slices into a File; media and anything over 64 MB stay url-only (getFile() fetches on demand), so a 300 MB clip never sits in a JS array.

**Example**

Internal plumbing behind `AppMint.releaseOpenedFile(f)`: deletes the cached copy of a shared or opened file, by token. Pages should use the public API.

**Returns:** nothing. **Needs:** turn on **Receive Shares From Other Apps** when you build.

The public API (use this):

```js
async function useAndRelease() {
  if (!window.AppMint || typeof AppMint.getOpenedFile !== 'function') return;
  const f = await AppMint.getOpenedFile();
  if (!f) return;
  const file = await f.getFile();       // keep your own copy first
  await store(file);
  AppMint.releaseOpenedFile(f);         // calls WebToApk.openedFileRelease(f.token)
}
```

The user dismisses a shared file without importing it — free the cached copy straight away:

```js
async function offerImport() {
  if (!window.AppMint || typeof AppMint.getOpenedFile !== 'function') return;
  const f = await AppMint.getOpenedFile();
  if (!f) return;
  const box = showImportPrompt(f.name, f.size);   // your own "Import / Discard" UI
  box.querySelector('.discard').addEventListener('click', function () {
    AppMint.releaseOpenedFile(f);
    box.remove();
  });
  box.querySelector('.import').addEventListener('click', async function () {
    await store(await f.getFile());
    AppMint.releaseOpenedFile(f);
    box.remove();
  });
}
```

A file the app cannot use is released as soon as it is refused:

```js
window.addEventListener('appmint:fileopen', function (e) {
  const f = e.detail;
  if (!/\.(gpx|tcx)$/i.test(f.name)) {
    alert(f.name + ' is not a GPX or TCX activity file.');
    AppMint.releaseOpenedFile(f);
    return;
  }
  importActivity(f.text);                // gpx/tcx arrive as text
});
```

**Notes:** After release, `f.url` returns an error and `openedFileChunk` returns `""` for that token. A `File` object you already have is not affected.

### `openedFilesPending`

```js
window.WebToApk.openedFilesPending(): String
```

A foreign page gets nothing: the files stay parked for the app's own page.

**Example**

Internal plumbing behind `AppMint.getOpenedFile()` / `AppMint.getOpenedFiles()`: lists the shared or opened files the page has not collected yet (metadata only, no bytes). Pages should use the public API.

**Returns:** (sync) a JSON **string**: an array of `{token, name, mimeType, size, isText, kind, url}`. `[]` when nothing is waiting. **Needs:** turn on **Receive Shares From Other Apps** when you build (without it the list is always empty and the `AppMint` helpers are not installed).

The public API (use this):

```js
if (window.AppMint && typeof AppMint.getOpenedFile === 'function') {
  AppMint.getOpenedFile().then(function (f) {
    if (f) console.log('opened with the app:', f.name, f.kind);
  });
}
window.addEventListener('appmint:fileopen', function (e) { console.log('arrived:', e.detail.name); });
```

Only for debugging — peek at what is waiting without collecting it:

```js
if (window.WebToApk && typeof WebToApk.openedFilesPending === 'function') {
  const waiting = JSON.parse(WebToApk.openedFilesPending() || '[]');
  console.log(waiting.length + ' file(s) waiting', waiting.map(function (m) { return m.name; }));
}
```

Debugging a file that never reached the page — see what is still waiting and whether it will arrive as `f.file` or only as `f.url`:

```js
if (window.WebToApk && typeof WebToApk.openedFilesPending === 'function') {
  const STREAM_LIMIT = 64 * 1024 * 1024;   // the helper's url-only threshold
  JSON.parse(WebToApk.openedFilesPending() || '[]').forEach(function (m) {
    const urlOnly = m.kind === 'image' || m.kind === 'video' || m.kind === 'audio' || m.size > STREAM_LIMIT;
    console.log(m.name, m.mimeType, m.size + ' bytes',
      urlOnly ? 'will come as url only' : 'will be pulled into f.file',
      m.isText ? '(text)' : '');
  });
}
```

**Notes:** The helper collects these automatically after `load` and whenever a new file arrives, so a page rarely sees anything here. Once collected, a file leaves this list but stays readable until released.

### `openNotificationSettings`

```js
window.WebToApk.openNotificationSettings(): Boolean
```

Opens this app's notification settings; true when the screen could be opened.

**Example**

Opens this app's own notification settings screen, where the user can switch notifications (and each channel) on or off.

**Returns:** `true` when the screen was opened, synchronously; `false` when it cannot be (Android 13+ and the build has no notifications). On Android 8+ it is the app's notification page; on older Android, the App info screen. **Needs:** nothing to switch on.

```js
document.getElementById('notif-settings-link').onclick = function () {
  if (!(window.WebToApk && WebToApk.openNotificationSettings && WebToApk.openNotificationSettings())) {
    showMessage('Open Settings > Apps > this app > Notifications.');
  }
};
```

**Notes:** to bring a user back in one tap, use `AppMint.enableNotifications()` instead: it shows the permission dialog when Android still allows it and opens this screen only when it does not.

### `passkeyAssetLinks`

```js
window.WebToApk.passkeyAssetLinks(): String
```

The assetlinks.json array (as text) that authorises this installed app for your domain.

**Example**

Builds the `assetlinks.json` content that tells Android your domain trusts this app (package name + the SHA-256 of the certificate the installed app is signed with). Pages normally use the public API `AppMint.passkey.assetLinks()`, which wraps this call and returns a parsed array.

**Returns:** a JSON array as TEXT, at once (`"[]"` if the certificate cannot be read). **Needs:** nothing.

Run once in the app installed FROM Google Play, then save the output as `https://your-domain.com/.well-known/assetlinks.json`:

```js
if (window.AppMint && window.AppMint.passkey) {
  console.log(JSON.stringify(window.AppMint.passkey.assetLinks(), null, 2));
}
```

The raw call returns the text already formatted:

```js
if (window.WebToApk && typeof window.WebToApk.passkeyAssetLinks === 'function') {
  var text = window.WebToApk.passkeyAssetLinks();
  console.log(text);
}
```

Read the package name and SHA-256 fingerprint out of the statement, for a console that asks for them one by one:

```js
if (window.AppMint && window.AppMint.passkey) {
  var statements = window.AppMint.passkey.assetLinks();
  if (statements.length === 0) {
    console.log('The signing certificate could not be read.');
  } else {
    var target = statements[0].target;
    console.log('Package: ' + target.package_name);
    console.log('SHA-256: ' + target.sha256_cert_fingerprints[0]);   // AB:CD:... upper-case, colon-separated
  }
}
```

**Notes:** The domain must be your server's rpID exactly (`www.` is a different domain), served over https with no redirect. If the file is wrong, passkey calls fail with `SecurityError` (reason `not_linked`).

### `passkeyIsAvailable`

```js
window.WebToApk.passkeyIsAvailable(): Boolean
```

True on Android 9+ with Google Play services — the phones that can hold a passkey.

**Example**

Tells you whether this phone can use passkeys. Pages normally use the public API `AppMint.passkey.isAvailable()` (or `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()`), which wraps this call.

**Returns:** a boolean, at once — `true` on Android 9+ with Google Play services and a WebView new enough for the passkey channel. **Needs:** nothing.

The public API:

```js
var canPasskey = !!(window.AppMint && window.AppMint.passkey && window.AppMint.passkey.isAvailable());
document.getElementById('passkey-button').hidden = !canPasskey;
```

The raw call, if you need it:

```js
var canPasskey = !!window.WebToApk &&
  typeof window.WebToApk.passkeyIsAvailable === 'function' &&
  window.WebToApk.passkeyIsAvailable() === true;
```

Offer "Add a passkey" once after a password sign-in, only on phones that can hold one (the standard check, same code in Chrome):

```js
async function offerPasskeyAfterLogin() {
  var ok = !!window.PublicKeyCredential &&
    await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  if (!ok) return;                                   // Android below 9 or no Google Play services
  if (localStorage.getItem('passkey-offered')) return;
  localStorage.setItem('passkey-offered', '1');
  document.getElementById('add-passkey-banner').hidden = false;
}
```

**Notes:** When this is `false`, `AppMint.passkey.create/get` reject with `NotSupportedError`. Hide the passkey button and offer your other sign-in.

### `passkeyOrigin`

```js
window.WebToApk.passkeyOrigin(): String
```

The origin a passkey server sees from this app: `android:apk-key-hash:<base64url SHA-256>`.

**Example**

Gives the origin your passkey server sees from this app: `android:apk-key-hash:<base64url SHA-256 of the signing certificate>`. Pages normally use the public API `AppMint.passkey.origin()`, which wraps this call.

**Returns:** a string, at once (`""` if the certificate cannot be read). **Needs:** nothing.

Run this once in the app installed FROM Google Play, and add the value to your server's allowed origins:

```js
if (window.AppMint && window.AppMint.passkey) {
  console.log(window.AppMint.passkey.origin());   // android:apk-key-hash:Lir5oIjf552K...
}
```

The raw call gives the same string:

```js
if (window.WebToApk && typeof window.WebToApk.passkeyOrigin === 'function') {
  console.log(window.WebToApk.passkeyOrigin());
}
```

Show the origin on a hidden settings screen, so you can read it off the Play install without a debugger:

```js
var out = document.getElementById('passkey-origin');
var origin = (window.AppMint && window.AppMint.passkey) ? window.AppMint.passkey.origin() : '';

if (!origin) {
  out.textContent = 'Open this screen in the installed app.';
} else {
  out.textContent = origin;               // android:apk-key-hash:...
  out.style.userSelect = 'all';           // one long-press selects the whole value
}
```

**Notes:** Google Play re-signs your app, so a test APK gives a different value than the Play install. SimpleWebAuthn example: `expectedOrigin: ['https://your-domain.com', 'android:apk-key-hash:...']`.

### `pickContact`

```js
window.WebToApk.pickContact(requestId: String)
```

System contact picker — needs NO permission, and is the right choice whenever the page just wants the user to choose one person. Picks from the PHONE table, not the contact table: the picker grants read access only to the row it returns, so picking a phone row is what makes the number readable without READ_CONTACTS. Picking a bare contact would return a row with a name and no number, and reading the number from it would need the permission this method exists to avoid.

**Example**

Opens Android's own contact picker so the user chooses one phone number. No permission is needed.

**Returns:** nothing now. The answer is the `appmint:contacts` event (and `window.onAppMintContacts`) with `detail = { requestId, contact: { id, displayName, photoUri, phones: [{ number, type: 'picked' }], emails: [] } }`. If the user closes the picker: `{ requestId, error: 'cancelled' }`. **Needs:** nothing — no build switch, no permission prompt.

**Fill a form with the chosen person.**

```js
function chooseContact() {
  if (!(window.WebToApk && window.WebToApk.pickContact)) {
    showMessage('Open this page in the app to pick a contact.');
    return;
  }
  var id = 'pick-' + Date.now();

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:contacts', handler);

    if (e.detail.error === 'cancelled') return;              // user closed the picker
    if (e.detail.error) { showMessage('Could not open contacts'); return; }
    if (!e.detail.contact) { showMessage('Could not read that contact'); return; }

    var c = e.detail.contact;
    document.getElementById('name').value = c.displayName;
    document.getElementById('phone').value = c.phones.length ? c.phones[0].number : '';
  });

  WebToApk.pickContact(id);
}
```

Keep a short list of emergency contacts. Save the name and number, not the `id`, so the list does not depend on the address book:

```js
function pickOne() {
  return new Promise(function (resolve, reject) {
    var id = 'sos-' + Date.now();
    window.addEventListener('appmint:contacts', function handler(e) {
      if (!e.detail || e.detail.requestId !== id) return;
      window.removeEventListener('appmint:contacts', handler);
      if (e.detail.error === 'cancelled') resolve(null);
      else if (e.detail.error) reject(new Error(e.detail.error));
      else resolve(e.detail.contact);
    });
    WebToApk.pickContact(id);
  });
}

document.getElementById('add-sos').addEventListener('click', async function () {
  if (!(window.WebToApk && window.WebToApk.pickContact)) return;
  var c = await pickOne();
  if (!c || !c.phones.length) return;   // closed, or nothing readable
  var list = JSON.parse(localStorage.getItem('sos') || '[]');
  list.push({ name: c.displayName, number: c.phones[0].number });
  localStorage.setItem('sos', JSON.stringify(list));
  renderSosList(list);
});
```

Disable the button while the picker is open, and show the typing fields when the phone has no contacts app:

```js
var pickBtn = document.getElementById('pick');

pickBtn.addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.pickContact)) { showManualEntry(); return; }
  var id = 'recipient-' + Date.now();
  pickBtn.disabled = true;   // one picker at a time

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:contacts', handler);
    pickBtn.disabled = false;

    if (e.detail.error === 'no_picker') { showManualEntry(); return; }
    if (e.detail.error) return;   // 'cancelled'
    if (e.detail.contact && e.detail.contact.phones.length) {
      document.getElementById('to').value = e.detail.contact.phones[0].number;
    }
  });

  WebToApk.pickContact(id);
});
```

**Notes:** You get the name and the ONE number the user tapped — never emails, never the rest of the book. `emails` is always empty. Errors: `cancelled`, `no_picker` (no contacts app on the phone). If the read fails after a pick, `contact` is `null`. Because nothing is read without the user's choice, this is the Play-friendly way to get a phone number; prefer it over `listContacts` when you only need one person. The build scans your code, and a `pickContact(` call does not switch on **Contacts** — only `listContacts`, `searchContacts` and `getContact` do — so the picker adds no contacts-read permission.

### `pickFile`

```js
window.WebToApk.pickFile(requestId: String, mimeFilter: String)
```

Opens the system single-file picker (ACTION_OPEN_DOCUMENT). Unlike [requestFolderAccess] this grants access to exactly one file, which is what an editor or a shredder should ask for; the read/write grant is persisted so the URI keeps working after the process is killed. The answer arrives on `WebToApkOnFilePickResult(success, fileJson, requestId)`; a failure's `error` is "cancelled", "disabled", "no-picker", "foreign_origin", or "busy" (a picker is already open for an earlier request, which keeps its own answer). @param mimeFilter e.g. "image/\*", "application/pdf", or "" for any file. (The star is backslash-escaped only because Kotlin block comments nest.)

**Example**

Opens the system picker for ONE file. Pages normally use the Promise helper `WebToApkFS.pickFile(mimeFilter)`; this raw method answers through a callback.

**Returns:** nothing now. Later the shell calls `window.WebToApkOnFilePickResult(success, fileJson, requestId)`, where `fileJson` is a JSON **string** `{ok, uri, name, size, mime}` on success or `{ok:false, uri:'', error}` with `error` = `'cancelled'`, `'disabled'`, `'no-picker'`, `'busy'` or `'foreign_origin'`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way (recommended):

```js
const f = await WebToApkFS.pickFile('image/*');
if (f.ok) console.log('picked', f.name, f.uri);
```

The raw call, matching on your own `requestId`:

```js
function pickRaw(mime) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof WebToApk.pickFile !== 'function') { resolve(null); return; }
    const myId = 'pick_' + Date.now();
    window.WebToApkOnFilePickResult = function (success, fileJson, requestId) {
      if (requestId !== myId) return;
      const r = JSON.parse(fileJson);           // fileJson is a string
      resolve(success && r.ok ? r : null);      // null = cancelled or not allowed
    };
    WebToApk.pickFile(myId, mime || '');        // '' = any file
  });
}
```

Attach a PDF and show its name and size, telling a cancel apart from a real problem:

```js
document.querySelector('#attach').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('application/pdf');
  if (!f.ok) {
    if (f.error === 'cancelled' || f.error === 'busy') return;   // user closed it / one already open
    alert('Could not open the file picker: ' + f.error);         // 'disabled', 'no-picker', ...
    return;
  }
  document.querySelector('#attachment').textContent =
    f.name + ' (' + Math.round(f.size / 1024) + ' KB)';
  localStorage.setItem('attachmentUri', f.uri);   // the grant is kept across restarts
});
```

Open a text file and load its contents into an editor:

```js
async function openTextFile() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/*');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);            // {ok, base64, size} or {ok:false, error}
  if (!r.ok) { alert('Could not read ' + f.name + ': ' + r.error); return; }
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  document.querySelector('#editor').value = new TextDecoder('utf-8').decode(bytes);
  document.querySelector('#editor').dataset.uri = f.uri;   // save back to the same file later
}
```

**Notes:** Only one pick can be open at a time: a call made while one is open is answered at once with `error:'busy'`, and the open one keeps its own answer. The grant is kept (read and write) until you call `releaseFileAccess(uri)`. The file is not read here — use `readFileBase64` / `WebToApkFS.readBytes` with the `uri`.

### `playClick`

```js
window.WebToApk.playClick()
```

Plays the app's tap sound on demand, so a page can fire it on its OWN semantic events (a button press) instead of on every stray tap — which is what a native app actually does, and the reason clickSoundMode can be "off" while this still works. A no-op only when the app was built WITHOUT tap sound (enableClickSound off). In clickSoundMode "off" — no automatic every-tap sound — an explicit call still plays: that mode exists for apps that sound on their own events. Otherwise it plays the creator's configured feedback.

**Example**

Plays the app's tap sound once, so your own buttons can click like a native app.

**Returns:** nothing. **Needs:** **Tap sound** switched on in Step 2 (Display) when you build. With the switch off, this does nothing.

```js
function clickSound() {
  if (window.WebToApk && typeof window.WebToApk.playClick === 'function') {
    window.WebToApk.playClick();
  }
}

document.querySelectorAll('button, .tab').forEach(function (el) {
  el.addEventListener('click', clickSound);
});
```

One listener for a whole game board, clicking only on a tap that does something:

```js
document.getElementById('board').addEventListener('click', function (e) {
  var cell = e.target.closest('.cell');
  if (!cell || cell.classList.contains('taken')) return;   // a dead tap stays silent
  if (window.WebToApk && typeof window.WebToApk.playClick === 'function') {
    window.WebToApk.playClick();
  }
  placeMark(cell);
});
```

Give your users their own "Sounds" switch on top of the build setting:

```js
var toggle = document.getElementById('sounds-toggle');
var soundsOn = true;
try { soundsOn = localStorage.getItem('sounds') !== 'off'; } catch (e) {}
toggle.checked = soundsOn;

toggle.addEventListener('change', function () {
  soundsOn = toggle.checked;
  try { localStorage.setItem('sounds', soundsOn ? 'on' : 'off'); } catch (e) {}
});

function tapSound() {
  if (soundsOn && window.WebToApk && typeof window.WebToApk.playClick === 'function') {
    window.WebToApk.playClick();
  }
}
```

**Notes:** What you hear depends on the **Tap sound** choice:
- **Follow phone setting** (recommended): the system click. It is silent when the user turned off "Touch sounds" in the phone's settings. That is on purpose.
- **Always play on every tap**: the app's own click, always audible. Every tap on the page clicks too, so you usually do not need `playClick()` in this mode.

Call it from a real button press, not from a timer.

### `prepareNotificationSound`

```js
window.WebToApk.prepareNotificationSound(spec: String): Boolean
```

Copies a bundled audio file into place ahead of time and reports whether it is usable as a notification sound. Optional — `notify()` and `scheduleNotificationEx()` do it themselves — but it lets a settings screen verify a sound the moment the user picks it.

**Example**

Checks that an audio file in your app can be used as a notification sound, and copies it into place now. Optional: `notify()` and `scheduleNotificationEx()` do the same by themselves; this lets a settings screen check a sound the moment the user picks it.

**Returns:** `true` if the sound is usable; `false` if the file does not exist or is not a supported audio type. **Needs:** nothing.

```js
function chooseAlarmSound(path) {             // e.g. '/audio/chime.mp3'
  if (!(window.WebToApk && window.WebToApk.prepareNotificationSound)) return;
  if (!WebToApk.prepareNotificationSound(path)) {
    showMessage('This sound file is missing from the app: ' + path);
    return;
  }
  localStorage.setItem('alarmSound', path);
  // Use it:
  WebToApk.notify(JSON.stringify({ title: 'Sound test', body: 'This is your alarm sound', sound: path, tag: 'sound-test' }));
}
```

Fill a sound picker with only the sounds that are really usable:

```js
const SOUNDS = [
  { label: 'Chime', path: '/audio/chime.mp3' },
  { label: 'Bell',  path: '/audio/bell.ogg' },
  { label: 'Beep',  path: '/audio/beep.wav' }
];

function renderSoundPicker(select) {
  const b = window.WebToApk;
  if (!(b && b.prepareNotificationSound)) return;
  select.innerHTML = '';
  SOUNDS.forEach(function (s) {
    if (!b.prepareNotificationSound(s.path)) return;   // missing or unsupported file
    const opt = document.createElement('option');
    opt.value = s.path; opt.textContent = s.label;
    select.appendChild(opt);
  });
}
```

Let the user pick their own audio file and use it for a daily reminder, passed as a `data:` URL:

```js
document.getElementById('own-sound').addEventListener('change', function (e) {
  const file = e.target.files[0];
  const b = window.WebToApk;
  if (!file || !(b && b.prepareNotificationSound)) return;
  const reader = new FileReader();
  reader.onload = function () {
    const dataUrl = reader.result;                 // 'data:audio/mpeg;base64,...'
    if (!b.prepareNotificationSound(dataUrl)) {
      showMessage('That file cannot be used as a notification sound.');
      return;
    }
    b.scheduleNotificationEx('daily-walk', JSON.stringify({
      at: nextEvening(), repeat: 'daily',
      title: 'Walk time', body: 'Time for your evening walk', sound: dataUrl
    }));
  };
  reader.readAsDataURL(file);
});
```

**Accepted values:** a path in your web bundle (`'/audio/chime.mp3'`, `'audio/chime.mp3'`) or a `data:audio/...;base64,...` URL your page made. Types: mp3, wav, ogg, oga, m4a, aac, flac, opus, mid, midi.

**Notes:** a bare name without a folder or extension (a `res/raw` sound) is not checked here and returns `false`, even though `notify({sound: 'name'})` can use it. Android keeps one sound per channel, so changing the sound creates a new channel and resets the user's settings for that channel.

### `print`

```js
window.WebToApk.print()
```

*(Printing)* Hands the page as it currently stands to Android's print dialogue, which can print on paper or save a PDF. Nothing to configure — the WebView renders the document.

**Example**

Opens Android's print screen for the page as it looks now — print on paper or "Save as PDF". Pages normally use the standard `window.print()`, which the shell routes here.

**Returns:** nothing. **Needs:** nothing.

The standard API first:

```js
document.getElementById('print-btn').onclick = function () {
  window.print();   // in the app: the Android print screen
};
```

The raw call does the same:

```js
if (window.WebToApk && typeof window.WebToApk.print === 'function') {
  window.WebToApk.print();
}
```

Print one invoice out of a list by marking it for a print stylesheet (leave the mark on — the print screen lays the page out after `print()` returns):

```js
// CSS: @media print { .invoice:not(.print-me), nav, button { display: none !important; } }
function printInvoice(id) {
  document.querySelectorAll('.invoice.print-me').forEach(function (el) {
    el.classList.remove('print-me');
  });
  document.getElementById('invoice-' + id).classList.add('print-me');
  if (window.WebToApk && typeof window.WebToApk.print === 'function') {
    window.WebToApk.print();
  } else {
    window.print();   // a normal browser
  }
}
```

**Notes:** It prints the whole WebView — the main page. A same-origin iframe's own `print()` prints just that iframe (see `window.print`). Use a `@media print` stylesheet to hide buttons and menus. The job is named "<App name> Document". Errors are only logged.

### `purchase`

```js
window.WebToApk.purchase(productId: String)
```

Opens Google's purchase sheet for [productId] (one-time product or subscription — resolved automatically). Grant ONLY on 'appmint:purchase' {productId, owned:true}; failures fire 'appmint:purchase-failed' {reason}. Already-owned re-fires the purchase event so the page can idempotently unlock.

**Example**

Opens Google Play's purchase sheet for one of your products (a one-time product or a subscription; the app finds out which).

**Returns:** nothing now. Success fires `appmint:purchase` `{productId, owned: true}`. A failure fires `appmint:purchase-failed` `{productId, reason}`. **Needs:** **💰 Sell In-App Products** in the build wizard, and the product created and active in Play Console.

Unlock ONLY in the `appmint:purchase` handler, never on the tap:

```js
var b = window.WebToApk;
var buyBtn = document.getElementById('buy-pro');

if (!b || typeof b.purchase !== 'function') {
  buyBtn.hidden = true;                              // browser: nothing to buy here
} else {
  buyBtn.addEventListener('click', function () {
    buyBtn.disabled = true;
    b.purchase('pro_unlock');
  });
}

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock') unlockPro();   // safe to run twice
  buyBtn.disabled = false;
});

window.addEventListener('appmint:purchase-failed', function (e) {
  buyBtn.disabled = false;
  if (e.detail.reason === 'pending') showMessage('Payment pending. It unlocks by itself once Google confirms.');
  else showMessage('Purchase is not available right now (' + e.detail.reason + ').');
});

// A cancel on Google's sheet sends no event: release the button when the page is visible again.
document.addEventListener('visibilitychange', function () { if (!document.hidden) buyBtn.disabled = false; });
```

The same results also call `window.onAppMintPurchase(detail)` and `window.onAppMintPurchaseFailed(detail)` if you define them.

Buy the plan the user picked in a subscription picker, and hide a plan whose ID Play does not know:

```js
var picked = 'pro_yearly';
document.querySelectorAll('.plan').forEach(function (card) {
  card.addEventListener('click', function () { picked = card.getAttribute('data-product'); });
});

document.getElementById('subscribe').addEventListener('click', function () {
  var b = window.WebToApk;
  if (b && typeof b.purchase === 'function') b.purchase(picked);
});

window.addEventListener('appmint:purchase', function (e) {
  if (/^pro_/.test(e.detail.productId)) unlockPro();        // any plan unlocks Pro
});
window.addEventListener('appmint:purchase-failed', function (e) {
  if (e.detail.reason !== 'not_found') return;
  var card = document.querySelector('.plan[data-product="' + e.detail.productId + '"]');
  if (card) card.hidden = true;
});
```

Unlock a level pack from the locked level the player tapped, then open that level:

```js
var wantedLevel = null;

function openLevel(n) {
  var b = window.WebToApk;
  var owned = !!(b && typeof b.isOwned === 'function' && b.isOwned('level_pack_2'));
  if (n <= 10 || owned) { startLevel(n); return; }
  if (!b || typeof b.purchase !== 'function') { showMessage('Levels 11-20 are in the Android app.'); return; }
  wantedLevel = n;
  b.purchase('level_pack_2');
}

window.onAppMintPurchase = function (detail) {       // {productId, owned: true}
  if (detail.productId !== 'level_pack_2' || !wantedLevel) return;
  var n = wantedLevel;
  wantedLevel = null;
  startLevel(n);
};
```

**Notes:** Reasons: `disabled` (Sell In-App Products is off), `not_found` (the ID is not in Play Console), `billing_error`, `pending` (the user paid with a slow method; the unlock arrives later through the launch restore and `appmint:owned-changed`). If the product is already owned, `appmint:purchase` fires again at once, so your unlock must be safe to repeat. The app also shows its own short message for errors.

### `rateApp`

```js
window.WebToApk.rateApp(): String
```

Opens the Play listing's rating flow. `rateApp()` already existed but only the native drawer entry could reach it, so an app that draws its own UI — every AI build, where the drawer is off — had no way to ask for a rating. It is the one store action a page genuinely cannot perform for itself: `market://` is an intent, not a navigable URL. Gated on the creator's own "Rate App Button" switch, exactly like the drawer entry and the bottom-nav Share item: turning Rate off must mean no rating surface anywhere, or the toggle looks broken.

**Example**

Opens the app's Google Play page so the user can rate it.

**Returns:** `'opened'` when the listing was opened, `'off'` when the creator switched Rate App off (then nothing happens). **Needs:** the **Rate App Button** switch on when you build (it is on by default). With it off, the call does nothing and returns `'off'`. For Google Play's in-app rating sheet use `AppMint.requestReview()`.

```js
var rateBtn = document.getElementById('rate-btn');
if (window.WebToApk && typeof window.WebToApk.rateApp === 'function') {
  rateBtn.onclick = function () { window.WebToApk.rateApp(); };
} else {
  rateBtn.hidden = true;   // in a browser
}
```

Offer the rating once, after the user has finished five workouts, from a card they tap:

```js
function maybeOfferRating(completedCount) {
  if (!window.WebToApk || typeof window.WebToApk.rateApp !== 'function') return;
  var asked = false;
  try { asked = localStorage.getItem('rateAsked') === '1'; } catch (e) {}
  if (asked || completedCount < 5) return;

  var card = document.getElementById('rate-card');   // "Enjoying the app? Rate it on Google Play"
  card.hidden = false;
  card.querySelector('.rate-yes').onclick = function () {
    window.WebToApk.rateApp();                         // opens the Play listing
    try { localStorage.setItem('rateAsked', '1'); } catch (e) {}
    card.hidden = true;
  };
  card.querySelector('.rate-later').onclick = function () { card.hidden = true; };
}
```

A "Rate us" row on your settings screen that says thank you when the user comes back from Google Play (the page's return to the foreground is the only signal that the user came back):

```js
document.getElementById('settings-rate').onclick = function () {
  if (!window.WebToApk || typeof window.WebToApk.rateApp !== 'function') return;
  window.WebToApk.rateApp();
  document.addEventListener('visibilitychange', function onBack() {
    if (document.visibilityState !== 'visible') return;
    document.removeEventListener('visibilitychange', onBack);
    showToast('Thanks for taking a look!');           // your own toast; the user may not have rated
  });
};
```

**Notes:** It opens the Play Store listing (`market://details?id=<package>`), or the Play website when the Play Store app is missing. It is not the in-app review card. A page cannot open `market://` itself — it is an Android intent, not a web address. Only ask after the user did something they liked, and from a tap.

### `readClipboard`

```js
window.WebToApk.readClipboard(): String
```

*(Share, clipboard and window)* The Android share sheet, the system clipboard, and closing a window the page opened. These are the plain-web equivalents (navigator.share, navigator.clipboard, window.close) answered natively so they behave the same inside the app.

**Example**

Reads the text that is on the phone's clipboard now. Pages normally use the standard `navigator.clipboard.readText()`, which the shell routes here.

**Returns:** the text, or `""` when the clipboard is empty or cannot be read (synchronously). **Needs:** nothing.

The standard API first — for example a "Paste code" button:

```js
document.getElementById('paste-btn').onclick = async function () {
  try {
    var text = await navigator.clipboard.readText();
    if (text) document.getElementById('code').value = text.trim();
    else showMessage('The clipboard is empty');
  } catch (e) {
    showMessage('Could not read the clipboard');
  }
};
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.readClipboard === 'function') {
  var text = window.WebToApk.readClipboard();
  if (text) document.getElementById('code').value = text;
}
```

A "Paste link" button that takes the first web address out of whatever was copied:

```js
document.getElementById('paste-link').onclick = function () {
  if (!(window.WebToApk && typeof window.WebToApk.readClipboard === 'function')) return;
  var text = window.WebToApk.readClipboard();
  var m = /https?:\/\/\S+/.exec(text);
  if (m) document.getElementById('link').value = m[0];
  else showMessage('There is no link on the clipboard');
};
```

**Notes:** Only your own app's pages can read it: a foreign website opened inside the app gets `""`. Android only lets the app read the clipboard while it is on screen, and Android 12+ shows a "pasted from clipboard" notice — read it on a user tap, not on page load.

### `readFileBase64`

```js
window.WebToApk.readFileBase64(pathOrUri: String, offset: Long, length: Long): String
```

Reads up to [length] raw bytes starting at [offset] and returns them base64. @param pathOrUri a `content://` URI from [pickFile], or a path relative to the folder granted by [requestFolderAccess]. @param length bytes to read; clamped to 4 MB per call. 0 or negative means "as much as one call allows". @return JSON `{ok, base64, bytesRead, offset, size, eof, error}`. `eof` tells a chunking loop when to stop without having to compare against `size` itself.

**Example**

Reads raw bytes from a file at a byte offset and returns them as base64. Pages normally use `WebToApkFS.readBytes(pathOrUri, offset, length)` (same result, already parsed) or `WebToApkFS.readAll(pathOrUri)`.

**Returns:** (sync) a JSON **string** `{ok:true, base64, bytesRead, offset, size, eof}` or `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way:

```js
const r = WebToApkFS.readBytes(fileUri, 0, 65536);
if (r.ok) console.log(r.bytesRead, 'bytes read, end of file:', r.eof);
```

The raw call — note the result is a string you must `JSON.parse`, not bare base64:

```js
if (window.WebToApk && typeof WebToApk.readFileBase64 === 'function') {
  const r = JSON.parse(WebToApk.readFileBase64(fileUri, 0, 0));   // length 0 = up to 4 MB
  if (r.ok) {
    const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
    console.log('first byte', bytes[0], 'of', r.size);
  } else {
    console.log('read failed:', r.error);
  }
}
```

Check a picked file really is a PNG by reading only its first 8 bytes:

```js
function isPng(fileUri) {
  const r = WebToApkFS.readBytes(fileUri, 0, 8);
  if (!r.ok || r.bytesRead < 8) return false;
  const head = atob(r.base64);
  const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
  return sig.every(function (b, i) { return head.charCodeAt(i) === b; });
}
```

Read a large file chunk by chunk with a progress bar, never holding it all in memory:

```js
function scanInChunks(fileUri, onChunk) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fileUri, offset, step);
    if (!r.ok) throw new Error('read failed: ' + r.error);
    onChunk(r.base64);                                    // process this piece, then drop it
    offset += r.bytesRead;
    document.querySelector('#progress').value = r.size ? offset / r.size : 1;
    if (r.eof || r.bytesRead <= 0) break;
  }
  return offset;                                          // total bytes read
}
```

**Notes:** `pathOrUri` is a `content://` URI from `pickFile` or a path relative to the folder from `requestFolderAccess`. At most 4 MB per call (`getMaxIoChunkSize()`); loop on `bytesRead` until `eof`. Errors: `not-found`, `disabled`, `cannot-open`, `offset must be >= 0`, `offset past end of file (N bytes)`, `foreign_origin` (a page from a site other than the app's own).

### `readTextFile`

```js
window.WebToApk.readTextFile(relativePath: String): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Reads a text file from the granted folder as UTF-8.

**Returns:** a string, synchronously: the file's text, or `""`. `WebToApkFS.readText(path)` returns the same. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access) and a granted folder.

`""` means "empty file" OR "no such file", so check with `WebToApkFS.stat()` when the difference matters:

```js
function loadNote(path) {
  if (!window.WebToApkFS) return null;
  var info = window.WebToApkFS.stat(path);    // { ok: true, name, size, ... } or { ok: false, error: 'not-found' }
  if (!info.ok) return null;                  // the file does not exist
  return window.WebToApkFS.readText(path);    // '' here really is an empty file
}

var text = loadNote('notes/today.txt');
document.getElementById('editor').value = text === null ? '' : text;
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.readTextFile === 'function') {
  var raw = window.WebToApk.readTextFile('notes/today.txt');
  console.log(raw.length + ' characters');
}
```

Import every `.txt` and `.md` note from a folder:

```js
function importNotes(folder) {
  if (!window.WebToApkFS) return [];
  return window.WebToApkFS.list(folder)
    .filter(function (e) { return !e.isDirectory && /\.(txt|md)$/i.test(e.name); })
    .map(function (e) {
      return {
        title: e.name.replace(/\.(txt|md)$/i, ''),
        body: window.WebToApkFS.readText(folder + '/' + e.name)
      };
    });
}

renderNotes(importNotes('notes'));
```

**Notes:** The whole file is read into memory — fine for notes and JSON, not for large files; use `WebToApkFS.readBytes` for those. Bytes that are not valid UTF-8 come back as the replacement character. A folder path, a path with `..`, no granted folder, or a page from a foreign website all answer `""`.

### `releaseFileAccess`

```js
window.WebToApk.releaseFileAccess(uriString: String): String
```

Releases a persisted single-file grant taken by [pickFile]. Android caps how many URI grants an app may persist, so an app that picks many files should release the ones it is done with.

**Example**

Releases the kept read/write grant for a file chosen with `pickFile`. Pages normally use `WebToApkFS.releaseFile(uri)`, which returns a real boolean.

**Returns:** (sync) the **string** `"true"` or `"false"`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build (always `"false"` otherwise).

The public way:

```js
if (window.WebToApkFS) WebToApkFS.releaseFile(fileUri);
```

The raw call:

```js
if (window.WebToApk && typeof WebToApk.releaseFileAccess === 'function') {
  const released = WebToApk.releaseFileAccess(fileUri) === 'true';   // a string, not a boolean
  console.log(released ? 'access released' : 'nothing to release');
}
```

Keep a "recent files" list of at most 10 and release the grant of any file that falls off it:

```js
function rememberRecent(uri) {
  let recent = JSON.parse(localStorage.getItem('recentFiles') || '[]');
  recent = [uri].concat(recent.filter(function (u) { return u !== uri; }));
  while (recent.length > 10) {
    const dropped = recent.pop();
    if (window.WebToApkFS) WebToApkFS.releaseFile(dropped);   // Android caps kept grants
  }
  localStorage.setItem('recentFiles', JSON.stringify(recent));
}
```

Close the current document: release its grant and forget the saved URI:

```js
document.querySelector('#close-doc').addEventListener('click', function () {
  const uri = localStorage.getItem('openDocUri');
  if (uri && window.WebToApkFS) {
    const released = WebToApkFS.releaseFile(uri);   // true / false, already a boolean
    console.log(released ? 'grant released' : 'no grant was held for this file');
  }
  localStorage.removeItem('openDocUri');
  document.querySelector('#editor').value = '';
});
```

**Notes:** Android limits how many file grants an app may keep, so release files you no longer need. The file itself is not touched.

### `renameEntry`

```js
window.WebToApk.renameEntry(relativePath: String, newName: String): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Renames a file or folder inside the granted folder.

**Returns:** the STRING `"true"` or `"false"`, synchronously. `WebToApkFS.rename(path, newName)` returns a real boolean. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access) and a granted folder.

`newName` is a plain name, not a path — the entry stays in the same folder:

```js
if (window.WebToApkFS) {
  var ok = window.WebToApkFS.rename('notes/today.txt', 'journal.txt');   // -> notes/journal.txt
  if (!ok) alert('Rename failed. Is there already a file called journal.txt?');
}
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.renameEntry === 'function') {
  var renamed = window.WebToApk.renameEntry('old-folder', 'new-folder') === 'true';
}
```

Keep the previous backup by renaming it before the new one is written:

```js
function rotateBackup(json) {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) return false;
  if (fs.stat('backup.json').ok) {
    var stamp = new Date().toISOString().replace(/[:.]/g, '-');   // no characters a file name cannot hold
    fs.rename('backup.json', 'backup-' + stamp + '.json');        // stays in the same folder
  }
  return fs.writeText('backup.json', json);
}
```

**Notes:** It fails when another entry already has `newName`, when `newName` is empty or contains `/` or `\`, when the path does not exist, or when no folder is granted. To move a file to another folder, write it there and delete the old one.

### `requestExactAlarms`

```js
window.WebToApk.requestExactAlarms(): Boolean
```

Opens the system screen where exact alarms are granted. Android 12+.

**Example**

Opens the Android 12+ system screen where the user allows this app to use exact alarms, so reminders fire on the minute.

**Returns:** `true` when the settings screen was opened (and on Android 11 and older, where nothing is needed); `false` if the screen could not be opened. It does not tell you the user's choice. **Needs:** turn on **⏰ Reminders** in the Permissions step when you build.

```js
document.getElementById('allow-exact').addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.requestExactAlarms)) return;
  if (WebToApk.getAlarmPrecision() === 'exact') return;   // already fine
  WebToApk.requestExactAlarms();
});

// The user comes back from Settings: read the real answer.
document.addEventListener('visibilitychange', function () {
  if (document.hidden || !window.WebToApk) return;
  const exact = WebToApk.getAlarmPrecision() === 'exact';
  document.getElementById('allow-exact').hidden = exact;
});
```

Before saving a time-critical reminder, offer the settings screen only when timing is actually off:

```js
function saveMedicineReminder(at) {
  const b = window.WebToApk;
  if (!(b && b.scheduleNotificationEx && b.getAlarmPrecision)) return;
  b.scheduleNotificationEx('medicine', JSON.stringify({
    at: at, repeat: 'daily', title: 'Medicine', body: 'Time for your tablet'
  }));
  const precision = b.getAlarmPrecision();   // 'exact' | 'inexact' | 'unavailable'
  if (precision === 'inexact') {
    showBanner('Reminders may arrive a few minutes late.', 'Fix timing', function () {
      b.requestExactAlarms();                // runs from the banner button tap
    });
  } else if (precision === 'unavailable') {
    showBanner('The phone\'s alarm service is not answering; this reminder may not fire.');
  }
}
```

When the settings screen cannot be opened (`false`), tell the user where to find the switch:

```js
document.getElementById('fix-timing').addEventListener('click', function () {
  const b = window.WebToApk;
  if (!(b && b.requestExactAlarms)) return;
  const opened = b.requestExactAlarms();
  if (!opened) {
    showMessage('Open Settings > Apps > this app > "Alarms & reminders" and turn it on.');
  }
  // When it did open, read the answer on return with getAlarmPrecision().
});
```

**Notes:** call it from a button tap and explain why first — it leaves your app. Reminders already scheduled keep working either way; only their timing changes.

### `requestFolderAccess`

```js
window.WebToApk.requestFolderAccess(requestId: String)
```

Opens the system folder picker and persists read/write access to the chosen folder. The answer arrives on `WebToApkOnFolderAccessResult`; only one picker can be open at a time, so a request made while one is open is answered at once with `error:"busy"` and the open one keeps its own answer.

**Example**

Opens the system folder picker so the user can give the app one folder. Pages normally use `WebToApkFS.requestAccess()`, which wraps this call in a Promise.

**Returns:** nothing. The answer comes back later through an internal callback that `WebToApkFS` owns; `WebToApkFS.requestAccess()` resolves with `{ success, folderUri, requestId }`, plus `error` when `success` is false. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Ask from a button tap, with the helper:

```js
document.getElementById('choose-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) {
    alert('Folder access is not available in this app.');
    return;
  }
  var r = await window.WebToApkFS.requestAccess();
  if (r.success) {
    console.log('Folder granted:', r.folderUri);   // content://.../tree/...
  } else {
    console.log('No folder chosen');                 // user cancelled, or Android refused the grant
  }
});
```

Ask only the first time, then set up the app's own sub-folder:

```js
async function ensureFolder() {
  if (!window.WebToApkFS || !window.WebToApkFS.isAvailable()) return false;
  if (window.WebToApkFS.hasAccess()) return true;          // granted on an earlier run
  var r = await window.WebToApkFS.requestAccess();
  if (!r.success) return false;
  window.WebToApkFS.mkdir('exports');                       // true even if it already exists
  return true;
}

document.getElementById('export').addEventListener('click', async function () {
  if (!(await ensureFolder())) return;
  window.WebToApkFS.writeText('exports/list.csv', buildCsv());
});
```

Tell the user exactly why no folder was connected, using the real `error` codes:

```js
var folderMessages = {
  cancelled: 'No folder was chosen.',
  not_persisted: 'Android would not keep access to that folder. Please choose another one.',
  busy: 'The folder picker is already open.',
  disabled: 'This app was built without folder access.',
  foreign_origin: 'A folder can only be connected from the app\'s own pages.',
  no_picker: 'This phone has no folder picker.'
};

document.getElementById('choose-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  var r = await window.WebToApkFS.requestAccess();
  document.getElementById('status').textContent = r.success
    ? 'Folder connected.'
    : (folderMessages[r.error] || 'Could not connect a folder.');
});
```

**Notes:** Do not call the raw method yourself: its only answer is the callback the helper listens to. `success` is `false` when the user cancels (`error:'cancelled'`), when Android will not keep the grant (`'not_persisted'`), and at once when the build did not enable the switch (`'disabled'`) or the page on screen is not the app's own (`'foreign_origin'`). The grant is kept across app restarts; choosing again replaces the folder. Only one picker can be open at a time — a request made while one is open is answered at once with `error:'busy'`, and the open one keeps its own answer.

### `requestNotificationPermission`

```js
window.WebToApk.requestNotificationPermission(): String
```

*(Notification permission, cancelling and listing)* Reading and requesting the POST_NOTIFICATIONS grant, and managing notifications the page already scheduled. Posting and scheduling are documented on their own methods.

**Example**

Shows the Android 13+ "Allow notifications?" dialog. Pages normally use `Notification.requestPermission()`, which calls this and waits for the user's answer.

**Returns:** a string, synchronously: `'granted'` if already allowed (or on Android 12 and older), otherwise `'default'` while the dialog opens. It cannot return the user's answer. **Needs:** turn on **Notifications** in the Permissions step when you build.

**Use the Promise API** — it resolves with the real answer (`'granted'` or `'denied'`):

```js
document.getElementById('enable-alerts').addEventListener('click', async function () {
  if (!('Notification' in window)) return;
  const perm = await Notification.requestPermission();
  if (perm === 'granted') {
    showMessage('Alerts are on');
  } else {
    showMessage('Alerts are off. You can turn them on in Settings.');
  }
});
```

**The raw call**, only to open the dialog without waiting:

```js
if (window.WebToApk && window.WebToApk.requestNotificationPermission) {
  const now = WebToApk.requestNotificationPermission();  // 'granted' or 'default'
  if (now === 'granted') enableAlertFeatures();
  // otherwise re-check later with WebToApk.getNotificationPermission()
  // ('granted' | 'denied' | 'default')
}
```

Older code that passes a callback to `Notification.requestPermission` gets the same final answer:

```js
document.getElementById('turn-on-reminders').addEventListener('click', function () {
  if (!('Notification' in window)) return;
  if (Notification.permission !== 'default') { updateReminderUi(Notification.permission); return; }
  Notification.requestPermission(function (perm) {
    updateReminderUi(perm);   // 'granted' or 'denied', once the user answers
  });
});
```

**Notes:** ask from a button tap with a clear reason. After the user refuses twice, Android stops showing the dialog and the answer is `'denied'` at once; the user must then allow it in the app's Settings.

### `requestOpenAppPermission`

```js
window.WebToApk.requestOpenAppPermission(): Boolean
```

Opens the system screen where the full-screen alert is granted. Android 14+.

**Example**

Opens the Android 14+ system screen where the user allows full-screen alerts for this app, so an `openApp: true` reminder can put the app on screen.

**Returns:** `true` when the settings screen was opened (and below Android 14, where nothing is needed); `false` if it could not be opened. It does not tell you the user's choice. **Needs:** turn on **⏰ Reminders** in the Permissions step when you build.

```js
document.getElementById('allow-open-app').addEventListener('click', function () {
  const b = window.WebToApk;
  if (!(b && b.requestOpenAppPermission)) return;
  if (b.canOpenAppAtTime() === 'granted') return;
  b.requestOpenAppPermission();
});

// Read the real answer when the user comes back:
document.addEventListener('visibilitychange', function () {
  if (document.hidden || !window.WebToApk) return;
  const ok = WebToApk.canOpenAppAtTime() === 'granted';
  document.getElementById('allow-open-app').hidden = ok;
});
```

Remember the alarm the user was setting, send them to Settings, and finish it when they come back:

```js
let pendingAlarm = null;

function setWakeAlarm(at) {
  const b = window.WebToApk;
  if (!(b && b.requestOpenAppPermission)) return;
  if (b.canOpenAppAtTime() === 'denied') {
    pendingAlarm = at;
    b.requestOpenAppPermission();
    return;
  }
  b.scheduleNotificationEx('wake', JSON.stringify({ at: at, title: 'Alarm', body: 'Wake up', openApp: true }));
}

document.addEventListener('visibilitychange', function () {
  if (document.hidden || pendingAlarm === null || !window.WebToApk) return;
  if (WebToApk.canOpenAppAtTime() === 'granted') setWakeAlarm(pendingAlarm);
  else showMessage('Without that setting the alarm cannot open the app by itself.');
  pendingAlarm = null;
});
```

When the settings screen cannot be opened (`false`), say where the switch is:

```js
document.getElementById('allow-fullscreen').addEventListener('click', function () {
  const b = window.WebToApk;
  if (!(b && b.requestOpenAppPermission)) return;
  if (!b.requestOpenAppPermission()) {
    showMessage('Open Settings > Apps > this app > Notifications and allow full-screen notifications.');
  }
});
```

**Notes:** call it from a button tap after explaining why — it leaves your app.

### `requestReview`

```js
window.WebToApk.requestReview(optionsJson: String, callbackId: String)
```

Asks Play for its review sheet; [optionsJson] `{openStore?}`. Resolves AppMint.requestReview [callbackId].

**Example**

Internal plumbing behind `AppMint.requestReview()` — pages use that Promise helper, which asks Google Play for its in-app review sheet and resolves with what happened.

**Returns:** nothing directly; the outcome resolves the `AppMint.requestReview()` promise. **Needs:** nothing to switch on. The sheet only ever appears for an app installed from Google Play.

The public API:

```js
async function askForRating() {
  if (!(window.AppMint && AppMint.requestReview)) return;          // a browser: no store here
  var r = await AppMint.requestReview();
  console.log(r.result);   // 'shown' | 'not_from_play' | 'quota' | 'error'
}
```

**Ask after a good moment, and ask for the store page when the sheet does not appear:**

```js
async function afterHappyMoment() {
  if (!(window.AppMint && AppMint.requestReview)) return;
  const r = await AppMint.requestReview({ openStore: true });   // {result, openedStore, message?}
  if (r.result === 'shown') thankUser();
  else if (r.openedStore) console.log('Opened the Play listing instead');
}
```

**Listen for the automatic prompt and report an error:**

```js
window.addEventListener('appmint:review', (e) => {
  if (e.detail.source === 'auto') console.log('Rate prompt result:', e.detail.result);
});

AppMint.requestReview().then((r) => {
  if (r.result === 'error') console.warn(r.message);
  if (r.result === 'not_from_play') showMessage('Rating is available once the app is installed from Google Play.');
});
```

### `restorePushMessage`

```js
window.WebToApk.restorePushMessage(id: String): Boolean
```

Undo a dismiss: puts the message back in its place in the inbox.

**Example**

Undoes `dismissPushMessage(id)`: the message comes back to the push inbox in its old place.

**Returns:** `true` if the message was restored; `false` if the id is unknown or it was not hidden. **Needs:** turn on **Appwright Push** in the Integrate step when you build.

```js
let lastDismissed = null;

function onSwipe(id) {
  if (!(window.WebToApk && window.WebToApk.dismissPushMessage)) return;
  if (WebToApk.dismissPushMessage(id)) {
    lastDismissed = id;
    showUndo();
    renderInbox();
  }
}

document.getElementById('undo-btn').addEventListener('click', function () {
  if (!lastDismissed || !window.WebToApk) return;
  if (WebToApk.restorePushMessage(lastDismissed)) renderInbox();
  lastDismissed = null;
});
```

Undo several swipes at once: remember every message hidden on this screen and bring them all back:

```js
const hiddenThisVisit = [];

function hide(id) {
  if (window.WebToApk && WebToApk.dismissPushMessage(id)) {
    hiddenThisVisit.push(id);
    document.getElementById('undo-all').hidden = false;
    renderInbox();
  }
}

document.getElementById('undo-all').addEventListener('click', function () {
  if (!(window.WebToApk && window.WebToApk.restorePushMessage)) return;
  hiddenThisVisit.splice(0).forEach(function (id) { WebToApk.restorePushMessage(id); });
  this.hidden = true;
  renderInbox();
});
```

Offer a "Recently hidden" list the user can restore from later. `getPushInbox()` does not return hidden messages, so keep their ids and titles yourself:

```js
function loadHidden() {
  try { return JSON.parse(localStorage.getItem('hiddenMsgs') || '[]'); } catch (e) { return []; }
}

function renderHidden() {
  const ul = document.getElementById('recently-hidden');
  ul.innerHTML = '';
  loadHidden().forEach(function (h) {
    const li = document.createElement('li');
    li.textContent = h.title + ' ';
    const btn = document.createElement('button');
    btn.textContent = 'Restore';
    btn.onclick = function () {
      WebToApk.restorePushMessage(h.id);     // false: retracted, cleared or already back
      const rest = loadHidden().filter(function (x) { return x.id !== h.id; });
      try { localStorage.setItem('hiddenMsgs', JSON.stringify(rest)); } catch (e) {}
      renderHidden();
      renderInbox();
    };
    li.appendChild(btn);
    ul.appendChild(li);
  });
}
```

**Notes:** a message cleared with `clearPushInbox()` or retracted by you from Appwright cannot be restored.

### `scanCode`

```js
window.WebToApk.scanCode(optionsJson: String, callbackId: String)
```

Opens Google's code scanner; [optionsJson] `{formats?, autoZoom?, manualInput?}`. Resolves the AppMint.scanCode promise [callbackId].

**Example**

Internal plumbing behind `AppMint.scanCode()` — pages use that Promise helper, which opens Google's QR/barcode scanner and resolves with the result.

**Returns:** nothing directly; the result resolves the `AppMint.scanCode()` promise. **Needs:** nothing to switch on (no camera permission). The phone needs Google Play services.

The public API:

```js
async function scanTicket() {
  if (!(window.AppMint && AppMint.scanCode)) { alert('Open this page in the app to scan'); return; }
  var r = await AppMint.scanCode({ formats: ['qr_code'] });
  if (r.ok) checkTicket(r.value);                  // r.format, r.type, r.displayValue…
  else if (r.code !== 'cancelled') alert('Scan failed (' + r.code + ')');
}
```

Join a Wi-Fi network from a printed QR code. A Wi-Fi code comes back with `type: 'wifi'` and a `wifi` object:

```js
async function scanWifi() {
  if (!(window.AppMint && AppMint.scanCode)) return;
  var r = await AppMint.scanCode({ formats: ['qr_code'] });
  if (!r.ok) { if (r.code !== 'cancelled') alert('Scan failed (' + r.code + ')'); return; }
  if (r.type !== 'wifi') { alert('That is not a Wi-Fi code.'); return; }
  showWifiCard(r.wifi.ssid, r.wifi.password, r.wifi.encryption);   // encryption: 'wpa' | 'wep' | 'open'
}
```

Look up a product by its shop barcode, letting the user type the digits when the label is damaged (`manualInput`). The first scan on a phone may download the scanner, which fails with `unavailable` when offline:

```js
async function scanProduct() {
  if (!(window.AppMint && AppMint.scanCode)) { showManualEntry(); return; }
  var r = await AppMint.scanCode({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'], manualInput: true });
  if (r.ok) return lookUpProduct(r.value, r.format);            // e.g. '4006381333931', 'ean_13'
  if (r.code === 'busy') return;                                  // a scan is already open
  if (r.code === 'unavailable') alert('The scanner is not available: ' + r.message);
  else if (r.code !== 'cancelled') alert('Scan failed (' + r.code + ')');
}
```

Open whatever a QR code points at, by its decoded `type`:

```js
async function scanAndOpen() {
  var r = await AppMint.scanCode({ formats: ['qr_code'], autoZoom: true });
  if (!r.ok) return;
  if (r.type === 'url') location.href = r.url.url;
  else if (r.type === 'phone') location.href = 'tel:' + r.phone;
  else if (r.type === 'email') location.href = 'mailto:' + r.email.address;
  else if (r.type === 'geo') showOnMap(r.geo.lat, r.geo.lng);
  else if (r.type === 'contact') saveContact(r.contact.name, r.contact.phones, r.contact.emails);
  else showText(r.displayValue);
}
```
**Notes:** The raw method takes `optionsJson` (`{formats?, autoZoom?, manualInput?}` as a JSON string) and a `callbackId`; the answer is delivered to the helper's internal reply table, so a page calling it directly gets no answer. Only one scan runs at a time — a second call answers `code: 'busy'`. See `AppMint.scanCode` for every result field and error code.

### `scheduleNotification`

```js
window.WebToApk.scheduleNotification(id: String, title: String, body: String, triggerAtMillis: String, repeat: String): Boolean
```

*(Notification permission, cancelling and listing)* Reading and requesting the POST_NOTIFICATIONS grant, and managing notifications the page already scheduled. Posting and scheduling are documented on their own methods.

**Example**

Schedules a simple reminder (title and text) for a future time. It fires even when the app is closed and is set again after the phone restarts.

**Returns:** `true` when it was scheduled; `false` when `id` is empty or the time is not a number. **Needs:** turn on **⏰ Reminders** in the Permissions step when you build (it also turns on Notifications), and the user's notification permission on Android 13+.

```js
function remindToDrinkWater() {
  if (!(window.WebToApk && window.WebToApk.scheduleNotification)) {
    showMessage('Reminders work in the installed app only.');
    return;
  }
  const ok = WebToApk.scheduleNotification(
    'water',                                  // your own id; same id replaces the old reminder
    'Drink water',                            // title
    'Time for a glass of water',              // body
    String(Date.now() + 60 * 60 * 1000),      // epoch milliseconds, AS A STRING
    'daily'                                   // none | minutely | hourly | daily | weekly
  );
  if (!ok) showMessage('Could not schedule the reminder');
}
```

**Know when a reminder opened the app** (the user tapped it):

```js
window.addEventListener('appmint:reminder', function (e) {
  // e.detail = { id: 'water', overLockScreen: false }
  if (e.detail.id === 'water') openWaterLog();
});
```

A one-off reminder at 8:00 on the day a task is due:

```js
function remindTask(task) {
  if (!(window.WebToApk && window.WebToApk.scheduleNotification)) return false;
  const at = new Date(task.dueDate);
  at.setHours(8, 0, 0, 0);
  if (at.getTime() <= Date.now()) return false;     // that morning has passed
  return WebToApk.scheduleNotification(
    'task-' + task.id,
    'Due today',
    task.title,
    String(at.getTime()),                           // epoch ms as a string
    'none'
  );
}
```

**Notes:** the reminder uses the "urgent" (heads-up) style and its tag is the id. A repeating reminder that was missed while the phone was off fires at the next step. For a picture, buttons, your own sound or opening the app over the lock screen, use `scheduleNotificationEx`. If exact alarms are off for the app, the time can drift by minutes; check `getAlarmPrecision()`.

### `scheduleNotificationEx`

```js
window.WebToApk.scheduleNotificationEx(id: String, optionsJson: String): Boolean
```

The full form of [scheduleNotification]: everything `notify()` accepts, fired at a future time with the app closed. WebToApk.scheduleNotificationEx('dose-1', JSON.stringify({ at: Date.now() + 3600000,   // epoch millis (alias: triggerAt) repeat: 'daily',            // none|minutely|hourly|daily|weekly title: 'Time for your dose', body: 'Vitamin D — 1 tablet', sound: '/audio/chime.mp3',  // a file in YOUR bundle; 'silent' for none openApp: true               // open the app itself, over the lock screen })); A bundled sound is copied into place during this call, so a wrong file name is reported now (false) rather than turning into the default sound days later.

**Example**

Schedules a reminder with every option `notify()` has (picture, buttons, sound, channel, open the app), for a future time. It fires when the app is closed and survives a restart.

**Returns:** `true` when scheduled. `false` when no time is given (`at`, `triggerAt`, `triggerAtMillis` or `inSeconds`), the id is empty, the JSON is bad, or `sound` names a file that is not in your app. **Needs:** turn on **⏰ Reminders** in the Permissions step when you build, and the user's notification permission on Android 13+.

**A daily reminder with your own sound that opens the app** (over the lock screen):

```js
function scheduleStandup(tomorrowAt9am) {
  const b = window.WebToApk;
  if (!(b && b.scheduleNotificationEx)) { showMessage('Reminders work in the installed app only.'); return; }

  // Both are user settings: check, and ask if needed.
  if (b.getAlarmPrecision() === 'inexact') b.requestExactAlarms();
  if (b.canOpenAppAtTime() === 'denied') b.requestOpenAppPermission();

  const ok = b.scheduleNotificationEx('standup', JSON.stringify({
    at: tomorrowAt9am,          // epoch ms (or triggerAt, or inSeconds: 3600)
    repeat: 'daily',            // none | minutely | hourly | daily | weekly
    title: 'Stand-up',
    body: 'Daily sync starts now',
    sound: '/audio/alarm.mp3',  // a file in YOUR app; 'silent' for none
    openApp: true               // full-screen alert that puts the app on screen
  }));
  if (!ok) showMessage('Check the sound file path — nothing was scheduled.');
}

window.addEventListener('appmint:reminder', function (e) {
  // e.detail = { id: 'standup', overLockScreen: true }
  openMeetingScreen(e.detail.id);
});
```

**A reminder with buttons, in 30 minutes:**

```js
WebToApk.scheduleNotificationEx('dose-1', JSON.stringify({
  inSeconds: 30 * 60,
  title: 'Time for your dose',
  body: 'Vitamin D — 1 tablet',
  actions: [{ id: 'taken', label: 'Taken' }, { id: 'snooze', label: 'Snooze' }]
}));

window.addEventListener('appmint:notification-action', function (e) {
  // e.detail = { actionId: 'taken', tag: 'dose-1', text: '' }
  if (e.detail.actionId === 'snooze') {
    WebToApk.scheduleNotificationEx(e.detail.tag, JSON.stringify({ inSeconds: 600, title: 'Time for your dose', body: 'Snoozed reminder' }));
  }
});
```

A one-off reminder with a picture when a sale starts, on the normal channel:

```js
function remindSale(sale) {                        // sale.startsAt = epoch ms
  if (!(window.WebToApk && window.WebToApk.scheduleNotificationEx)) return false;
  return WebToApk.scheduleNotificationEx('sale-' + sale.id, JSON.stringify({
    at: sale.startsAt,
    title: 'The sale starts now',
    body: sale.name,
    image: 'https://example.com/banners/' + sale.id + '.jpg',   // expanded picture
    channel: 'default'                             // not the heads-up style reminders use by default
  }));
}
```

An evening journal reminder the user can answer from the notification (inline reply):

```js
WebToApk.scheduleNotificationEx('journal', JSON.stringify({
  inSeconds: 12 * 60 * 60,
  repeat: 'daily',
  title: 'How was today?',
  body: 'Write one line for your journal',
  actions: [{ id: 'reply', label: 'Write', input: 'One line about today' }]
}));

window.addEventListener('appmint:notification-action', function (e) {
  // e.detail = { actionId: 'reply', tag: 'journal', text: 'Went for a long walk' }
  if (e.detail.actionId === 'reply' && e.detail.text) saveJournalLine(e.detail.text);
});
```

**Notes:** the same id replaces an older reminder. Defaults: tag = the id, channel = `urgent`, `bigText` = the body. A button tap reaches the page only while the app is running. The sound file is copied when you schedule, so a wrong path fails now and not days later; mp3, wav, ogg, m4a, aac, flac and opus work. `openApp` is the only way an app can come on screen by itself at a set time; on Android 14+ the user must allow it (`canOpenAppAtTime`).

### `searchContacts`

```js
window.WebToApk.searchContacts(requestId: String, query: String, limit: Int)
```

*(Contacts)* All async: cursor reads can be thousands of rows, and a blocking @JavascriptInterface return would run that on the JS thread — an ANR. Results arrive as `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: pass a STRING ('pick-' + Date.now()). A raw NUMBER also works — the bridge coerces it through a double ("1.787491234567E12"), and every reply canonicalizes it back (BridgeRequestId), so `res.requestId === Date.now()` strict-compares true either way.    WebToApk.listContacts(id, limit, offset)   WebToApk.searchContacts(id, query, limit)   WebToApk.getContact(id, contactId)   WebToApk.pickContact(id)      <- NO permission needed (system picker)   WebToApk.addContact(id, json) <- needs the Contacts-write permission

**Example**

Finds contacts whose name matches a search word, without loading the whole address book.

**Returns:** nothing now. The answer is the `appmint:contacts` event (and `window.onAppMintContacts`) with `detail = { requestId, contacts: [...] }` — the same contact shape as `listContacts` — or `{ requestId, error }`. **Needs:** turn on **Contacts** in Step 4 (Access) when you build; the user is asked for permission on the first call.

**Search as the user types.** Only the answer to the latest search is shown.

```js
var lastSearchId = '';

function searchPeople(word) {
  if (!(window.WebToApk && window.WebToApk.searchContacts)) return;
  lastSearchId = 'search-' + Date.now();
  WebToApk.searchContacts(lastSearchId, word, 20);   // at most 20 results
}

window.addEventListener('appmint:contacts', function (e) {
  if (!e.detail || e.detail.requestId !== lastSearchId) return;   // old or other request
  if (e.detail.error) { showMessage('Search failed: ' + e.detail.error); return; }

  var names = e.detail.contacts.map(function (c) {
    return c.displayName + (c.phones.length ? ' - ' + c.phones[0].number : '');
  });
  document.getElementById('results').textContent = names.join('\n') || 'No match';
});

document.getElementById('q').addEventListener('input', function (e) {
  searchPeople(e.target.value);
});
```

Wrap the search in a Promise and wait until the user stops typing, so fast typing does not start a search per key:

```js
function searchOnce(word, limit) {
  return new Promise(function (resolve, reject) {
    var id = 'find-' + Date.now() + '-' + Math.random().toString(36).slice(2);
    window.addEventListener('appmint:contacts', function handler(e) {
      if (!e.detail || e.detail.requestId !== id) return;
      window.removeEventListener('appmint:contacts', handler);
      if (e.detail.error) reject(new Error(e.detail.error)); else resolve(e.detail.contacts);
    });
    WebToApk.searchContacts(id, word, limit);
  });
}

var timer = null;
document.getElementById('q').addEventListener('input', function (e) {
  clearTimeout(timer);
  var word = e.target.value.trim();
  timer = setTimeout(function () {
    if (!(window.WebToApk && window.WebToApk.searchContacts) || !word) return;
    searchOnce(word, 10)
      .then(function (people) { renderSuggestions(people); })
      .catch(function (err) { showMessage('Search failed: ' + err.message); });
  }, 300);
});
```

Fill the email box from the address book when the typed name matches one person who has an email:

```js
function suggestEmail(name) {
  if (!(window.WebToApk && window.WebToApk.searchContacts) || name.length < 2) return;
  var id = 'email-' + Date.now();

  window.addEventListener('appmint:contacts', function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    window.removeEventListener('appmint:contacts', handler);
    if (e.detail.error) return;   // leave the box for the user to type

    var withEmail = e.detail.contacts.filter(function (c) { return c.emails.length > 0; });
    if (withEmail.length === 1) {
      document.getElementById('email').value = withEmail[0].emails[0];
    }
  });

  WebToApk.searchContacts(id, name, 5);
}

document.getElementById('name').addEventListener('change', function (e) {
  suggestEmail(e.target.value.trim());
});
```

**Notes:** An empty search word returns contacts from the start of the list (like `listContacts`). `limit` is clamped to 1–2000 and there is no offset. Errors: `not_enabled`, `permission_denied`, `foreign_origin`. Contacts is personal data: your Play Store listing must declare it in the Data safety form and privacy policy.

### `sendSms`

```js
window.WebToApk.sendSms(
```

*(SMS)* WebToApk.composeSms(number, message)   <- NO permission, opens the SMS app   WebToApk.sendSms(id, number, message, wantDelivery)   WebToApk.listSms(id, "inbox"|"sent"|"draft", limit, offset) Send results: `appmint:sms` / window.onAppMintSms   {requestId, status:"sent"|"delivered"|"failed", ok, reason?} Inbound messages: `appmint:sms-received` / window.onAppMintSmsReceived.

**Example**

Sends an SMS directly from the app, without opening the SMS app, and reports what really happened.

**Returns:** nothing now. The answer is the `appmint:sms` event (and `window.onAppMintSms`) with `detail = { requestId, status: 'sent' | 'delivered' | 'failed', ok, reason? }`. With a delivery report you get TWO answers on success: `sent`, then `delivered`. **Needs:** turn on **SMS (read & send)** in Step 4 (Access) when you build; the user is asked for permission on the first call.

**Send a code and follow its status.** Do not remove the listener after the first answer when you asked for a delivery report.

```js
function sendCode(phone, code) {
  if (!(window.WebToApk && window.WebToApk.sendSms)) {
    showMessage('Sending SMS works only inside the app.');
    return;
  }
  var id = 'sms-' + Date.now();

  function handler(e) {
    if (!e.detail || e.detail.requestId !== id) return;
    var d = e.detail;
    if (d.status === 'sent' && d.reason === 'no_delivery_report') {
      showStatus('Sent (the network gave no delivery report)');
      window.removeEventListener('appmint:sms', handler);
    } else if (d.status === 'sent') {
      showStatus('Sent');
    } else if (d.status === 'delivered') {
      showStatus('Delivered');
      window.removeEventListener('appmint:sms', handler);
    } else {                                   // 'failed'
      showStatus('Failed: ' + d.reason);
      window.removeEventListener('appmint:sms', handler);
    }
  }
  window.addEventListener('appmint:sms', handler);

  WebToApk.sendSms(id, phone, 'Your code is ' + code, true);   // true = delivery report
}
```

**No delivery report.** Pass `false` as the last value: you get exactly one answer, `sent` or `failed`.

```js
window.onAppMintSms = function (d) {
  if (d.requestId === 'alert-1') showStatus(d.ok ? 'Alert sent' : 'Failed: ' + d.reason);
};
if (window.WebToApk && window.WebToApk.sendSms) {
  WebToApk.sendSms('alert-1', '+911234567890', 'Gate opened at ' + new Date().toLocaleTimeString(), false);
}
```

The same notice to a short list of numbers, one after another, collecting each result (and stopping when SMS is off or refused, because every other number would fail the same way):

```js
function sendToAll(numbers, text, done) {
  if (!(window.WebToApk && typeof window.WebToApk.sendSms === 'function')) { done(null); return; }
  var results = [], i = 0;

  function next() {
    if (i >= numbers.length) { done(results); return; }
    var number = numbers[i++];
    var id = 'notice-' + Date.now() + '-' + i;

    window.addEventListener('appmint:sms', function handler(e) {
      if (!e.detail || e.detail.requestId !== id) return;
      window.removeEventListener('appmint:sms', handler);
      results.push({ number: number, ok: e.detail.ok, reason: e.detail.reason });
      var r = e.detail.reason;
      if (r === 'not_enabled' || r === 'permission_denied' || r === 'foreign_origin') done(results);
      else next();
    });
    WebToApk.sendSms(id, number, text, false);   // one answer per number
  }
  next();
}

sendToAll(['+911234567890', '+919876543210'], 'Practice moved to 6 pm today.', function (results) {
  if (!results) return showMessage('Sending SMS works only inside the app.');
  showStatus(results.filter(function (r) { return r.ok; }).length + ' of ' + results.length + ' sent');
});
```

**Notes:** Long text is split into parts and sent as one message. If no delivery report arrives within 60 seconds you get `status: 'sent'` with `reason: 'no_delivery_report'` — the message was still sent. `reason` values: `not_enabled`, `permission_denied`, `foreign_origin`, `invalid_arguments` (empty number or text), `no_service`, `radio_off`, `generic_failure`, `null_pdu`, `not_delivered`, and others. Use a string `requestId`. Each SMS may cost the user money. SMS is a Google Play restricted permission: you must file an approved Permissions Declaration in Play Console (usually only default SMS apps qualify), so prefer `composeSms` when the user can press send.

### `setAppShortcuts`

```js
window.WebToApk.setAppShortcuts(itemsJson: String)
```

Launcher shortcuts (long-press the app icon). Up to four entries: `[{id, title, route}]`, where `route` is a path in the app ('/new-note'). A tap opens the app with that route as its launch deep link (`appmint-shortcut://<package><route>`), which the page reads through `WebToApk.getLaunchUrl()` (and the `appmint:deep-link` event) and routes on exactly like any other deep link. In a Website-mode app the shell itself opens the route on the website (`/new-note` → `https://<site>/new-note`). Pass `[]` to remove them. Android 7.1+; a no-op below that.

**Example**

Sets the launcher shortcuts that appear when the user touches and holds the app icon.

**Returns:** nothing. **Needs:** nothing. Android 7.1+ (a no-op below that).

`itemsJson` is a JSON string: an array of up to four `{id, title, route}` objects. `route` is a path inside your app. A tap opens the app with `appmint-shortcut://<package><route>` as its launch link:

```js
if (window.WebToApk && typeof window.WebToApk.setAppShortcuts === 'function') {
  window.WebToApk.setAppShortcuts(JSON.stringify([
    { id: 'new-note', title: 'New note', route: '/new-note' },
    { id: 'search',   title: 'Search',   route: '/search' }
  ]));
}
```

Read the tap the same way as any other deep link, on start and while running:

```js
function shortcutRoute(link) {
  var m = /^appmint-shortcut:\/\/[^\/?#]*([^#]*)/i.exec(link || '');
  return m ? (m[1] || '/') : null;
}
if (window.WebToApk && window.WebToApk.getLaunchUrl) {
  var r = shortcutRoute(window.WebToApk.getLaunchUrl());
  if (r) goTo(r);
}
window.addEventListener('appmint:deep-link', function (e) {
  var r = shortcutRoute(e.detail.url);
  if (r) goTo(r);
});
```

Remove all shortcuts:

```js
if (window.WebToApk && window.WebToApk.setAppShortcuts) window.WebToApk.setAppShortcuts('[]');
```

**Notes:** Each call replaces the whole list. Entries without a `title` are skipped; the label is cut to 25 characters (50 for the long label). `id` defaults to `appmint-<index>`. A route without a leading `/` gets one. The icon is the app icon. A malformed list is ignored (logged), never thrown. AI-built apps can use `shortcuts` from `@/lib/appmintNative`.

### `setFullscreen`

```js
window.WebToApk.setFullscreen(enabled: Boolean)
```

*(Fullscreen bridge)* Lets the page hide the status bar (clock/battery) and navigation bar itself — e.g. a game entering its play screen:     WebToApk.setFullscreen(true)   // hide the system bars     WebToApk.setFullscreen(false)  // bring them back     WebToApk.toggleFullscreen()     WebToApk.isFullscreen() The choice is remembered across launches. It is a no-op in apps the creator already locked to fullscreen/kiosk — there is nothing to restore to there. The side-menu "Fullscreen" item is the equivalent for end users and is gated by the creator's enableFullscreenToggle option; this bridge is always available to the app's own page.

**Example**

Hides (`true`) or shows (`false`) the Android status bar and navigation bar, for example when a game enters its play screen.

**Returns:** nothing. **Needs:** nothing — it is always available to your page. The choice is remembered across launches.

```js
function enterPlayScreen() {
  if (window.WebToApk && typeof window.WebToApk.setFullscreen === 'function') {
    window.WebToApk.setFullscreen(true);    // hide clock/battery and the nav bar
  }
}
function leavePlayScreen() {
  if (window.WebToApk && window.WebToApk.setFullscreen) {
    window.WebToApk.setFullscreen(false);   // bring the bars back
  }
}
```

Keep your content out from under the camera cut-out when the bars are hidden:

```js
document.body.style.paddingTop = 'env(safe-area-inset-top)';
document.body.style.paddingBottom = 'env(safe-area-inset-bottom)';
```

Hide the bars while a video plays and bring them back when it pauses or ends:

```js
var video = document.getElementById('movie');

function hideBars(hidden) {
  if (window.WebToApk && typeof window.WebToApk.setFullscreen === 'function') {
    window.WebToApk.setFullscreen(hidden);
  }
}

video.addEventListener('play', function () { hideBars(true); });
video.addEventListener('pause', function () { hideBars(false); });
video.addEventListener('ended', function () { hideBars(false); });
```

**Notes:** It does nothing in an app built with **Fullscreen** (or kiosk mode) switched on — the creator locked that choice. It only hides the Android bars; the app's own top bar is the **Show top bar** switch. When turned on, the shell adds `viewport-fit=cover` to your viewport meta so the page lays out into the full screen. See `toggleFullscreen` and `isFullscreen`. AI-built apps can use `fullscreen` from `@/lib/appmintNative`.

### `setMediaMetadata`

```js
window.WebToApk.setMediaMetadata(title: String, artist: String, artworkUrl: String)
```

*(Media Notification Bridge)* Lets web content drive the Android media-style notification and receive transport control callbacks (play/pause/next/prev) from the lock screen and notification shade.  Usage from JavaScript:   window.WebToApk.setMediaMetadata('Song Title', 'Artist Name', 'https://…/art.jpg')   window.WebToApk.setPlaybackState(true, 30000, 240000)   // playing, pos ms, dur ms   window.WebToApk.clearMediaNotification()  Callback from Android → JS:   window.WebToApkOnMediaAction('play'|'pause'|'next'|'prev'|'stop')

**Example**

Sets the title, artist and cover picture of the media notification and lock-screen player. Pages normally set `navigator.mediaSession.metadata`, which calls this for you.

**Returns:** nothing. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. Without it the call does nothing.

The public way (use this):

```js
if ('mediaSession' in navigator && window.MediaMetadata) {
  navigator.mediaSession.metadata = new MediaMetadata({
    title: 'Episode 12', artist: 'My Podcast',
    artwork: [{ src: 'https://example.com/cover.jpg', sizes: '512x512' }]
  });
}
```

The raw call. Arguments: `title, artist, artworkUrl` (use `''` for no picture):

```js
function showNowPlaying(track) {
  if (!(window.WebToApk && typeof window.WebToApk.setMediaMetadata === 'function')) return;
  window.WebToApk.setMediaMetadata(track.title, track.artist || '', track.cover || '');
  window.WebToApk.setPlaybackState(true, 0, Math.round(track.seconds * 1000));
}

// Buttons in the notification come back through the standard API:
if ('mediaSession' in navigator) {
  navigator.mediaSession.setActionHandler('nexttrack', function () { playNext(); });
  navigator.mediaSession.setActionHandler('previoustrack', function () { playPrevious(); });
}
```

Keep the notification's title up to date for a live radio stream, where the song changes without the page loading a new file:

```js
var lastSong = '';
function refreshNowPlaying() {
  fetch('https://example.com/radio/now-playing.json')
    .then(function (r) { return r.json(); })
    .then(function (np) {            // e.g. { "title": "...", "artist": "...", "cover": "https://..." }
      var key = np.title + '|' + np.artist;
      if (key === lastSong) return;  // only update when the song really changed
      lastSong = key;
      if (window.WebToApk && typeof window.WebToApk.setMediaMetadata === 'function') {
        window.WebToApk.setMediaMetadata(np.title || 'Live radio', np.artist || '', np.cover || '');
      }
    })
    .catch(function () { /* try again on the next tick */ });
}
document.getElementById('radio').addEventListener('play', function () {
  refreshNowPlaying();
  setInterval(refreshNowPlaying, 30000);
}, { once: true });
```

**Notes:**
- The notification appears (or updates) with this call. An empty title shows the app name.
- `artworkUrl` must be a full `https://` address on the internet. The app downloads it (4-second limit); if that fails, the app icon is shown.
- Receive button presses with `navigator.mediaSession.setActionHandler` (see `navigator.mediaSession`), or with your own `window.WebToApkOnMediaAction = function (action) { ... }` (`play`, `pause`, `next`, `prev`, `stop`) — use one of the two. After `clearMediaNotification()`, the next call to this (or `setPlaybackState`) brings the notification and the lock-screen controls back.

### `setMusicVolume`

```js
window.WebToApk.setMusicVolume(percent: Int)
```

Set the background-music level from the page, 0..100. The wizard slider decides how the app STARTS; this is how an app gives its own users a music control, which any game with a settings screen is expected to have. Clamped in [BackgroundMusic.setVolume], so a page passing 500 or -1 cannot hand MediaPlayer a gain outside the range it accepts. The level is REMEMBERED: the next launch starts at it, not at the wizard's level. A no-op when the app was built without a track — there is nothing to set, and the honest answer is [getMusicVolume] returning -1 so the page can hide its slider rather than show one that does nothing.

**Example**

Changes the level of the app's looping background music, 0 to 100, for example from your game's settings screen.

**Returns:** nothing. **Needs:** a file chosen under **Background Music** in Step 2 (Display) when you build. Without music this does nothing (check `getMusicVolume()` for `-1`).

A mute button — the app remembers the level for the next launch:

```js
function hasMusic() {
  return !!(window.WebToApk && window.WebToApk.getMusicVolume) && window.WebToApk.getMusicVolume() >= 0;
}

if (!hasMusic()) document.getElementById('mute').hidden = true;

document.getElementById('mute').addEventListener('click', function () {
  if (!hasMusic()) return;
  // clamped to 0..100 by the app, and kept for the next launch
  window.WebToApk.setMusicVolume(window.WebToApk.getMusicVolume() > 0 ? 0 : 40);
});
```

Off / Low / High buttons in a settings screen:

```js
var LEVELS = { off: 0, low: 25, high: 70 };

document.querySelectorAll('[data-music]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    if (!window.WebToApk || typeof window.WebToApk.setMusicVolume !== 'function') return;
    if (window.WebToApk.getMusicVolume() < 0) return;       // built without music
    window.WebToApk.setMusicVolume(LEVELS[btn.dataset.music]);
    markSelected(btn);
  });
});
```

Turn the music down while your own video plays, then put it back (the level is remembered, so always restore it):

```js
var video = document.getElementById('intro-video');
var before = -1;

video.addEventListener('play', function () {
  if (!window.WebToApk || typeof window.WebToApk.getMusicVolume !== 'function') return;
  before = window.WebToApk.getMusicVolume();
  if (before > 0) window.WebToApk.setMusicVolume(Math.round(before / 4));
});

function restoreMusic() {
  if (before > 0) window.WebToApk.setMusicVolume(before);
  before = -1;
}
video.addEventListener('pause', restoreMusic);
video.addEventListener('ended', restoreMusic);
```

**Notes:** The level you set is saved: the next launch starts at it instead of the level from the build slider (until the user clears the app's data). `0` makes the music silent but it keeps playing.

### `setPlaybackState`

```js
window.WebToApk.setPlaybackState(isPlaying: Boolean, positionMs: Long, durationMs: Long)
```

*(Media Notification Bridge)* Lets web content drive the Android media-style notification and receive transport control callbacks (play/pause/next/prev) from the lock screen and notification shade.  Usage from JavaScript:   window.WebToApk.setMediaMetadata('Song Title', 'Artist Name', 'https://…/art.jpg')   window.WebToApk.setPlaybackState(true, 30000, 240000)   // playing, pos ms, dur ms   window.WebToApk.clearMediaNotification()  Callback from Android → JS:   window.WebToApkOnMediaAction('play'|'pause'|'next'|'prev'|'stop')

**Example**

Tells the media notification whether you are playing or paused, and where you are in the track. Pages normally set `navigator.mediaSession.playbackState` and call `setPositionState`, which call this for you.

**Returns:** nothing. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. Without it the call does nothing.

The public way (use this):

```js
var audio = document.getElementById('player');
function publish() {
  if (!('mediaSession' in navigator)) return;
  navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';
  if (audio.duration > 0 && navigator.mediaSession.setPositionState) {
    navigator.mediaSession.setPositionState({ duration: audio.duration, position: audio.currentTime });
  }
}
audio.addEventListener('play', publish);
audio.addEventListener('pause', publish);
```

The raw call. Arguments: `isPlaying` (true/false), `positionMs`, `durationMs` (whole milliseconds):

```js
function publishRaw(audio) {
  if (!(window.WebToApk && typeof window.WebToApk.setPlaybackState === 'function')) return;
  window.WebToApk.setPlaybackState(
    !audio.paused,
    Math.floor(audio.currentTime * 1000),
    Math.floor((audio.duration || 0) * 1000)
  );
}
```

Report the new position after the user seeks, and switch the notification to Play when the track ends:

```js
var audio = document.getElementById('player');
function report(playing) {
  if (!(window.WebToApk && typeof window.WebToApk.setPlaybackState === 'function')) return;
  var durMs = Math.floor((audio.duration || 0) * 1000);
  var posMs = Math.floor(audio.currentTime * 1000);
  window.WebToApk.setPlaybackState(playing, Math.min(posMs, durMs || posMs), durMs);
}
audio.addEventListener('seeked', function () { report(!audio.paused); });
audio.addEventListener('ended', function () { report(false); });   // Play button, notification can be swiped away
```

**Notes:** `true` shows the Pause button and keeps the notification from being swiped away; `false` shows Play. Call `setMediaMetadata` first, so the notification has a title. To remove the notification when the user stops, call `clearMediaNotification()`.

### `setPushPresentation`

```js
window.WebToApk.setPushPresentation(mode: String): Boolean
```

Native in-app presentation of push messages: "auto" (default) lets the shell draw the banner / modal / sheet / full-screen card the creator chose while the app is open; "none" turns it off for good (persisted) — for apps that draw their own. The `appmint:push` event and the inbox are unaffected either way; the received event's `presented` flag says whether the shell showed it.

**Example**

Turns the app's own on-screen display of Appwright Push messages on (`'auto'`, the default) or off (`'none'`) — for apps that draw their own banner from the `appmint:push` event.

**Returns:** `true` when the mode was saved; `false` for any value other than `'auto'` or `'none'`. The choice is saved on the phone and stays until changed. **Needs:** turn on **Appwright Push** in the Integrate step when you build.

**Draw your own banner** instead of the shell's:

```js
if (window.WebToApk && window.WebToApk.setPushPresentation) {
  WebToApk.setPushPresentation('none');       // 'auto' to give it back to the shell
}

window.addEventListener('appmint:push', function (e) {
  const d = e.detail;
  // { type: 'received', message: {...}, presented: false } — presented = the shell already showed it
  if (d.type === 'received' && !d.presented) showMyBanner(d.message.title, d.message.body, d.message.link);
  if (d.type === 'revoked') hideMyBanner(d.id);
});
```

Let the user switch pop-ups off in your settings screen. There is no getter, so keep your own copy of the choice to draw the switch:

```js
const toggle = document.getElementById('show-popups');
let saved = null;
try { saved = localStorage.getItem('pushPopups'); } catch (e) {}
toggle.checked = saved !== 'none';

toggle.addEventListener('change', function () {
  if (!(window.WebToApk && window.WebToApk.setPushPresentation)) return;
  const mode = toggle.checked ? 'auto' : 'none';
  if (WebToApk.setPushPresentation(mode)) {
    try { localStorage.setItem('pushPopups', mode); } catch (e) {}
  }
});
```

With `'auto'`, find out what the user did with a message the app showed on screen: `opened` when they tapped it (`action` is `-1` for the message itself, else the button index), `closed` when they dismissed it without tapping:

```js
window.addEventListener('appmint:push', function (e) {
  const d = e.detail;
  if (d.type === 'opened') {
    // the message is already marked read in the inbox
    track('push_opened', { id: d.id, button: d.action, link: d.link });
    updateBellCount();
  } else if (d.type === 'closed') {
    track('push_closed', { id: d.id });
  }
});
```

**Notes:** with `'auto'`, a message you sent with an in-app style is shown over the app while it is open (and then not also put in the tray). With `'none'` the shell never draws it; the inbox (`getPushInbox`) and the `appmint:push` event work the same either way. Setting `'none'` also closes a message the shell is showing now.

### `setPushTag`

```js
window.WebToApk.setPushTag(key: String, value: String): Boolean
```

Sets one Appwright Push tag (key: letters, digits, _ up to 32; value up to 128) — the send center can target "plan = pro", and an automation can fire a set time after a tag is set (an abandoned cart: set cart=open, clear it at checkout). An empty value removes the tag. Up to 50 tags per install.

**Example**

Sets one Appwright Push tag on this install, such as `plan = pro` or `city = pune`. The send center (Home > Notifications) can send to everyone with a tag, and a tag automation can send a message a set time after a tag is set — an abandoned cart, a finished level. An empty value removes the tag.

**Returns:** `true` when saved; `false` when the key is not 1–32 letters, digits or `_`, the value is longer than 128 characters, or the install would have more than 50 tags (nothing is saved then). Tags are kept on the phone and reported to Appwright Push a moment later (or on the next launch when offline). **Needs:** turn on **Appwright Push** in the Integrate step when you build. Tag targeting and automations reach phones running an app built with Appwright from 30 Sep 2026 on.

**Tag what the user bought or chose**, so you can send to "plan = pro" only:

```js
function pushTag(key, value) {
  if (!(window.WebToApk && window.WebToApk.setPushTag)) return false;
  return WebToApk.setPushTag(key, value);
}

pushTag('plan', user.isPro ? 'pro' : 'free');
pushTag('city', user.city.toLowerCase());
```

**Abandoned cart:** set a tag when items are in the cart and clear it at checkout. In the send center, add a tag automation on `cart` with a delay (say 60 minutes); a phone gets the reminder only if `cart` is still set when the time is up:

```js
function onCartChanged(items) {
  if (!(window.WebToApk && window.WebToApk.setPushTag)) return;
  // Setting 'open' again while it is already 'open' does not restart the automation's clock.
  WebToApk.setPushTag('cart', items.length > 0 ? 'open' : '');
}

function onCheckoutComplete() {
  if (window.WebToApk && window.WebToApk.setPushTag) WebToApk.setPushTag('cart', '');   // bought: no reminder
}
```

**Clear user-specific tags on sign-out**, together with the user id:

```js
function signOutOfPush() {
  if (!(window.WebToApk && window.WebToApk.setPushUser)) return;
  WebToApk.setPushUser('');
  WebToApk.setPushTag('plan', '');
  WebToApk.setPushTag('cart', '');
}
```

**Notes:** tags belong to this install, not to the signed-in user. Setting a tag to the value it already has changes nothing on the server. After a tag is removed, setting it again lets a tag automation send once more. Never put an email address, phone number or anything private in a tag. Android only: the iOS app has no `WebToApk.setPushTag`, so the feature check skips it there.

### `setPushTags`

```js
window.WebToApk.setPushTags(json: String): Boolean
```

Sets several tags at once from a JSON object string; "" values remove.

**Example**

Sets several Appwright Push tags at once from a JSON object string, e.g. `'{"plan":"pro","team":"red"}'`. A `""` value removes that tag; tags you do not name are kept.

**Returns:** `true` when saved; `false` when the string is not a JSON object, any key is not 1–32 letters, digits or `_`, any value is longer than 128 characters, or the install would end up with more than 50 tags — then nothing at all is saved. **Needs:** turn on **Appwright Push** in the Integrate step when you build. Tag targeting and automations reach phones running an app built with Appwright from 30 Sep 2026 on.

**After sign-in, tag the profile in one call:**

```js
function tagProfile(profile) {
  if (!(window.WebToApk && window.WebToApk.setPushTags)) return false;
  return WebToApk.setPushTags(JSON.stringify({
    plan: profile.plan,                   // 'free' | 'pro'
    level: String(profile.level),         // values are text: turn numbers into strings
    team: profile.team || ''              // '' removes the tag
  }));
}
```

**On sign-out, remove them all** (read the current tags, send each back as `''`):

```js
function clearPushTags() {
  if (!(window.WebToApk && window.WebToApk.getPushTags)) return;
  const tags = JSON.parse(WebToApk.getPushTags() || '{}');
  Object.keys(tags).forEach(function (k) { tags[k] = ''; });
  WebToApk.setPushTags(JSON.stringify(tags));
  WebToApk.setPushUser('');
}
```

**Finished a level:** tag it, and let a tag automation on `level_done` (say 10 minutes) send the next challenge:

```js
function onLevelComplete(level, score) {
  if (!(window.WebToApk && window.WebToApk.setPushTags)) return;
  WebToApk.setPushTags(JSON.stringify({
    level_done: String(level),            // a new value each level, so the automation can fire again
    last_score: String(score)
  }));
}
```

**Notes:** one call is one change: all of it is saved or none of it. The install reports the new tags to Appwright Push a moment later (or on the next launch when offline). Android only: the iOS app has no `WebToApk.setPushTags`, so the feature check skips it there.

### `setPushUser`

```js
window.WebToApk.setPushUser(id: String): Boolean
```

Appwright Push audience: tells the creator's send center WHO this install is, so a message can go to one signed-in user ("u_42") or to everyone with a tag. Call it after sign-in with your own stable account id; pass "" on sign-out. Letters, digits and _ @ . : + - (up to 128). Returns false for anything else. Never pass an email address or phone number — use your database id. Android installs only.

**Example**

Tells Appwright Push which of YOUR users is signed in on this phone, so the send center (Home > Notifications) can send a message to that one user. Call it after sign-in with your own stable account id; call it with `''` on sign-out.

**Returns:** `true` when the id was saved; `false` for an id with anything other than letters, digits and `_ @ . : + -`, or longer than 128 characters (nothing is saved then). The id is kept on the phone until you change it, and this install reports it to Appwright Push a moment later (or on the next launch when it is offline). **Needs:** turn on **Appwright Push** in the Integrate step when you build. Sending to a user reaches phones running an app built with Appwright from 30 Sep 2026 on; older builds still get messages sent to Everyone.

**After sign-in and on sign-out** — use your database id, never an email address or phone number:

```js
function pushUser(id) {
  if (!(window.WebToApk && window.WebToApk.setPushUser)) return false;   // browser, or an older build
  return WebToApk.setPushUser(id);
}

async function signIn(email, password) {
  const user = await myBackend.signIn(email, password);   // your own sign-in
  pushUser(user.id);                                      // e.g. 'u_42' or a UUID
}

async function signOut() {
  await myBackend.signOut();
  pushUser('');                                           // this phone is nobody's now
}
```

**Supabase:** follow the session, so a restored session counts as well as a fresh sign-in:

```js
supabase.auth.onAuthStateChange(function (event, session) {
  if (!(window.WebToApk && window.WebToApk.setPushUser)) return;
  const id = session ? session.user.id : '';     // the auth UUID: letters, digits and '-'
  if (WebToApk.getPushUser() !== id) WebToApk.setPushUser(id);
});
```

**Make an id acceptable** when yours may contain other characters (spaces, `/`, `#`), and log the rare one that is still refused:

```js
function pushUserIdFrom(rawId) {
  return String(rawId).trim().replace(/[^A-Za-z0-9_@.:+\-]/g, '_').slice(0, 128);
}

if (window.WebToApk && window.WebToApk.setPushUser) {
  const ok = WebToApk.setPushUser(pushUserIdFrom(currentUser.id));
  if (!ok) console.warn('push user id refused', currentUser.id);
}
```

**Notes:** the same user signed in on two phones is reached on both. The id belongs to this install, not to the account — sign-out must clear it, or the phone keeps receiving the previous user's messages. `setPushUser('')` does not clear tags set with `setPushTag`; clear user-specific tags on sign-out too. Android only: the iOS app has no `WebToApk.setPushUser`, so the feature check skips it there.

### `setSecureScreen`

```js
window.WebToApk.setSecureScreen(enabled: Boolean): Boolean
```

Sets protection for the CURRENT page and returns the state now in force. The window flag itself is applied on the UI thread, but the state this returns is already committed, so a page can call this and immediately reveal its sensitive content without waiting for a callback.

**Example**

Blocks screenshots, screen recording, casting and the recents-screen preview while the CURRENT page shows something private.

**Returns:** a boolean at once — the state now in force (the value you passed). You can reveal the private content right after the call. **Needs:** nothing.

```js
function showStatement() {
  if (window.WebToApk && typeof window.WebToApk.setSecureScreen === 'function') {
    window.WebToApk.setSecureScreen(true);
  }
  render(statementView);
}
```

Release it yourself only when you hide the private content WITHOUT navigating (a modal closes, a tab switches inside one page):

```js
function closeCardDetails() {
  hide(cardDetailsModal);
  if (window.WebToApk && typeof window.WebToApk.setSecureScreen === 'function') {
    window.WebToApk.setSecureScreen(false);
  }
}
```

Show a one-time recovery code only once capture is blocked, since the call answers with the state in force:

```js
function revealRecoveryCode(code) {
  var W = window.WebToApk;
  if (!(W && typeof W.setSecureScreen === 'function')) {
    alert('Open the app to see your recovery code.');
    return;
  }
  if (W.setSecureScreen(true)) {
    document.getElementById('recovery-code').textContent = code;
  }
}
```

**Notes:** Protection belongs to the page that asked. It is released as soon as the app navigates anywhere else — a new page, a `pushState` route or a `#hash` route — so there is nothing to clean up on the way out, and after a reload the page must ask again. Do not re-apply it on a timer: the flag must already be on when the user presses the screenshot buttons. Nothing can stop a camera pointed at the screen.

### `setStatusBarColor`

```js
window.WebToApk.setStatusBarColor(color: String, style: String): Boolean
```

Paints the status and navigation bars [color] and picks the icon tint: [style] 'light' means light icons on a dark bar, 'dark' the reverse, '' lets the luminance decide. [color] is `#RGB`, `#RGBA`, `#RRGGBB`, `#AARRGGBB` (alpha FIRST — CSS's `#RRGGBBAA` order is read as alpha-first too) or a basic colour name ('black', 'navy'…); `rgb()`/`hsl()` are not read. Returns false when the colour does not parse.

**Example**

Paints the Android status bar a colour and chooses light or dark icons on it.

**Returns:** `true`, or `false` when the colour cannot be parsed (synchronously). **Needs:** nothing.

`style` is `'light'` (light icons, for a dark bar), `'dark'` (dark icons, for a light bar), or `''` (the shell picks from the colour's brightness):

```js
function paintBar(color, style) {
  if (window.WebToApk && typeof window.WebToApk.setStatusBarColor === 'function') {
    return window.WebToApk.setStatusBarColor(color, style);
  }
  return false;   // in a browser
}

paintBar('#0B0F1A', 'light');   // dark bar, light icons
paintBar('#FFFFFF', 'dark');    // white bar, dark icons
paintBar('#0E7C86', '');        // icons chosen automatically
```

Match the bar to each screen:

```js
function showScreen(name) {
  var colors = { home: '#0B0F1A', settings: '#FFFFFF' };
  paintBar(colors[name] || '#0B0F1A', name === 'settings' ? 'dark' : 'light');
}
```

Follow the phone's light or dark theme, including a switch while the app is open:

```js
var darkTheme = window.matchMedia('(prefers-color-scheme: dark)');

function matchTheme() {
  if (!(window.WebToApk && typeof window.WebToApk.setStatusBarColor === 'function')) return;
  if (darkTheme.matches) {
    window.WebToApk.setStatusBarColor('#121212', 'light');
  } else {
    window.WebToApk.setStatusBarColor('#F5F5F5', 'dark');
  }
}

matchTheme();
darkTheme.addEventListener('change', matchTheme);
```

**Notes:** Colours are Android colour strings: `#RRGGBB` or `#AARRGGBB` (and names like `red`). CSS forms such as `rgb(...)` or `#RGB` return `false`. It works on Android 15+ too, where Capacitor's StatusBar plugin cannot paint the bar. While the bars are hidden (fullscreen) the colour is kept for when they come back. AI-built apps can use `setStatusBar` from `@/lib/appmintNative`.

### `shareFiles`

```js
window.WebToApk.shareFiles(filesJson: String, title: String, text: String): Boolean
```

TRUE native file share for navigator.share({files:[...]}). Android WebView has no Web Share API, and text/plain ACTION_SEND can't carry a file — so before this a shared image was merely SAVED, not shared. filesJson is [{name, mimeType, base64}, ...]; we write each to cache, expose it via the app's FileProvider, and fire ACTION_SEND / ACTION_SEND_MULTIPLE so the real Android share sheet (WhatsApp, Gmail, …) opens. Returns true if the sheet opened.

**Example**

Shares one or more real files (an image, a PDF…) through the Android share sheet. Pages normally use the standard `navigator.share({ files: [...] })`, which the shell routes here.

**Returns:** `true` if the share sheet opened, `false` otherwise (synchronously). **Needs:** nothing.

The standard API first:

```js
async function shareCanvas(canvas) {
  var blob = await new Promise(function (r) { canvas.toBlob(r, 'image/png'); });
  var file = new File([blob], 'drawing.png', { type: 'image/png' });
  if (!(navigator.canShare && navigator.canShare({ files: [file] }))) {
    alert('File sharing is not available here');
    return;
  }
  try {
    await navigator.share({ files: [file], title: 'My drawing', text: 'Made in my app' });
  } catch (e) { /* in a browser: the user closed the sheet */ }
}
```

The raw call takes a JSON string of `[{name, mimeType, base64}]` (base64 without the `data:` prefix), then `title` and `text`:

```js
function shareBase64Png(base64) {
  if (!(window.WebToApk && typeof window.WebToApk.shareFiles === 'function')) return false;
  var files = [{ name: 'receipt.png', mimeType: 'image/png', base64: base64 }];
  return window.WebToApk.shareFiles(JSON.stringify(files), 'Receipt', 'Your receipt');
}
```

Export a table as a CSV file (text is UTF-8 encoded before base64):

```js
function shareCsv(rows) {
  if (!(window.WebToApk && typeof window.WebToApk.shareFiles === 'function')) return;
  var csv = rows.map(function (r) {
    return r.map(function (c) { return '"' + String(c).replace(/"/g, '""') + '"'; }).join(',');
  }).join('\n');
  var base64 = btoa(unescape(encodeURIComponent(csv)));
  var files = [{ name: 'expenses.csv', mimeType: 'text/csv', base64: base64 }];
  if (!window.WebToApk.shareFiles(JSON.stringify(files), 'Expenses', '')) {
    showMessage('Could not open the share sheet');
  }
}
```

**Notes:** Files are written to the app's cache and shared through its FileProvider. Unsafe characters in `name` become `_`. Several files of one type share with that type; mixed types share as `*/*`. The whole file travels as base64 over the bridge, so keep it to a few MB. When `navigator.share({files})` cannot open the sheet, the shell saves the files as downloads instead and the promise still resolves.

### `shareNative`

```js
window.WebToApk.shareNative(title: String, text: String, url: String)
```

*(Share, clipboard and window)* The Android share sheet, the system clipboard, and closing a window the page opened. These are the plain-web equivalents (navigator.share, navigator.clipboard, window.close) answered natively so they behave the same inside the app.

**Example**

Opens the Android share sheet with a title, text and link. Pages normally use the standard `navigator.share({title, text, url})`, which the shell routes here.

**Returns:** nothing (it cannot tell you which app the user picked). **Needs:** nothing.

The standard API first — it also works in a mobile browser:

```js
document.getElementById('share-btn').onclick = async function () {
  if (!navigator.share) { alert('Sharing is not available here'); return; }
  try {
    await navigator.share({ title: 'My app', text: 'Look at this', url: 'https://mysite.com/item/42' });
  } catch (e) {
    // in a browser: AbortError when the user closes the sheet
  }
};
```

The raw call, all three arguments are strings (use `''` to leave one out):

```js
if (window.WebToApk && typeof window.WebToApk.shareNative === 'function') {
  window.WebToApk.shareNative('My app', 'Look at this', 'https://mysite.com/item/42');
}
```

Text only, no link — for example a game score:

```js
function shareScore(score) {
  if (!(window.WebToApk && typeof window.WebToApk.shareNative === 'function')) return;
  window.WebToApk.shareNative('My score', 'I scored ' + score + ' points. Can you beat it?', '');
}
```

**Notes:** The shared text is `text` + a new line + `url` (or just one of them). `title` becomes the email subject and the share sheet's heading. Text and links only — to share a file, use `navigator.share({files})` (see `shareFiles`).

### `showInterstitialAd`

```js
window.WebToApk.showInterstitialAd()
```

Show an interstitial NOW (e.g. between levels / on a deliberate action). Not showable right now → skipped + 'appmint:ad-unavailable' with the reason.

**Example**

Shows a full-screen interstitial ad right now, for example between two game levels (Automatic ad control).

**Returns:** nothing. When the ad closes, `appmint:ad-closed` fires with `{adType:'interstitial', earned:false}`. When it cannot show, `appmint:ad-unavailable` fires with `{adType:'interstitial', reason}`. **Needs:** Ads on and **Full Screen Ad → Interstitial Ad**. Choose **Trigger Mode → On Demand** if only your page should decide when.

```js
var bridge = window.WebToApk;
var waitingForAd = false;

function levelFinished() {
  if (bridge && typeof bridge.isInterstitialAdReady === 'function' && bridge.isInterstitialAdReady()) {
    waitingForAd = true;
    bridge.showInterstitialAd();          // the next level starts when the ad closes
  } else {
    showNextLevel();                      // no ad ready: just continue
  }
}

window.addEventListener('appmint:ad-closed', function (e) {
  if (e.detail.adType === 'interstitial' && waitingForAd) { waitingForAd = false; showNextLevel(); }
});

window.addEventListener('appmint:ad-unavailable', function (e) {
  // reason: ad_free | disabled | consent | not_loaded | cooldown | show_failed
  if (e.detail.adType === 'interstitial' && waitingForAd) { waitingForAd = false; showNextLevel(); }
});
```

Pause the game and its sound before the ad, and resume on either answer:

```js
var pausedForAd = false;

function adBetweenRounds() {
  var b = window.WebToApk;
  if (!b || typeof b.showInterstitialAd !== 'function') return;
  pausedForAd = true;
  pauseGame(); music.pause();
  b.showInterstitialAd();
}

function resumeAfterAd(e) {
  if (e.detail.adType !== 'interstitial' || !pausedForAd) return;
  pausedForAd = false;
  music.play(); resumeGame();
}
window.addEventListener('appmint:ad-closed', resumeAfterAd);
window.addEventListener('appmint:ad-unavailable', resumeAfterAd);
```

Open an article after an ad, as one Promise that resolves `true` when the ad was shown and `false` when it was skipped:

```js
function interstitialThen() {
  return new Promise(function (resolve) {
    var b = window.WebToApk;
    if (!b || typeof b.isInterstitialAdReady !== 'function' || !b.isInterstitialAdReady()) return resolve(false);
    function done(e) {
      if (e.detail.adType !== 'interstitial') return;
      window.removeEventListener('appmint:ad-closed', done);
      window.removeEventListener('appmint:ad-unavailable', done);
      resolve(e.type === 'appmint:ad-closed');
    }
    window.addEventListener('appmint:ad-closed', done);
    window.addEventListener('appmint:ad-unavailable', done);
    b.showInterstitialAd();
  });
}

interstitialThen().then(function () { openArticle(articleId); });
```

**Notes:** The app keeps at least 60 seconds between two interstitials; a call inside that gap answers `cooldown`. `isInterstitialAdReady()` already includes the cooldown, so checking it first avoids the event. Show interstitials at natural breaks, never on every tap. In **Developer-controlled** builds use `AppMintAds.show('interstitial')`.

### `showNotification`

```js
window.WebToApk.showNotification(title: String, body: String, icon: String, tag: String): Boolean
```

The ORIGINAL four-argument form, kept verbatim as an adapter so every app already in the wild and every guide snippet keeps working. It now forwards to the rich builder — which means the `icon` argument, silently discarded by the old implementation, finally does something.

**Example**

The original four-argument notification call. It shows a notification with a title, text, an optional large icon and a tag. New code should use `notify(optionsJson)` or `new Notification(...)`, which do more.

**Returns:** `true` when the notification was handed to the system. **Needs:** turn on **Notifications** in the Permissions step when you build, and the user's permission on Android 13+.

**The standard web API** (it calls this method for you):

```js
async function tellUser() {
  if (!('Notification' in window)) return;
  const p = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission();
  if (p === 'granted') new Notification('Saved', { body: 'Your note was saved', tag: 'note' });
}
```

**The raw call.** Arguments: title, body, icon, tag. Pass `''` for no icon or no tag.

```js
if (window.WebToApk && window.WebToApk.showNotification) {
  WebToApk.showNotification(
    'Saved',                                  // title
    'Your note was saved',                    // body
    'https://example.com/avatar.png',         // large icon (https://, data:, or 'img/x.png' in your app), or ''
    'note'                                    // tag: same tag replaces the old notification
  );
}
```

Update one status notification in place by reusing its tag:

```js
function showSyncStatus(done, total) {
  const b = window.WebToApk;
  if (!(b && b.showNotification)) return;
  if (b.getNotificationPermission() !== 'granted') return;
  b.showNotification(
    'Syncing photos',
    done + ' of ' + total + ' uploaded',
    '',          // no large icon
    'sync'       // same tag every time: the text updates, no new notification
  );
}
```

**Notes:** it forwards to `notify({title, body, largeIcon: icon, tag})` on the default channel. Without a tag every call adds a new notification. For pictures, buttons, progress or a sticky notification use `notify`.

### `showRewardedAd`

```js
window.WebToApk.showRewardedAd()
```

Show a rewarded ad NOW (user-initiated — the only policy-compliant way to show rewarded). Pair it with trigger mode "On Demand" in the wizard to get rewarded ads with NO automatic app-open/navigation ads at all. The reward is delivered back to the page via deliverRewardToPage(): window.addEventListener('appmint:reward', e => { /* e.detail = {type,amount} */ }) When the ad closes, 'appmint:ad-closed' fires with {adType:'rewarded', earned} — earned is false if the user backed out early, so grant nothing in that case. Not showable right now → skipped + 'appmint:ad-unavailable' fired with the reason.

**Example**

Shows a rewarded ad right now, from the user's own "Watch an ad" tap (Automatic ad control).

**Returns:** nothing. The outcome arrives later as events: `appmint:reward` `{type, amount}` when the ad was watched to the end, `appmint:ad-closed` `{adType:'rewarded', earned}` when it closes, or `appmint:ad-unavailable` `{adType:'rewarded', reason}` when nothing could show. **Needs:** Ads on, **Full Screen Ad → Rewarded Ad**, and **Trigger Mode → On Demand** so nothing shows by itself.

Grant the reward only in the reward handler, never on the tap:

```js
var btn = document.getElementById('earn');
var bridge = window.WebToApk;

function refresh() {
  btn.disabled = !(bridge && typeof bridge.isRewardedAdReady === 'function' && bridge.isRewardedAdReady());
}
refresh();
setInterval(refresh, 2000);

btn.addEventListener('click', function () {        // a real user tap: the only place to call it
  if (!bridge || typeof bridge.showRewardedAd !== 'function') return;
  btn.disabled = true;
  bridge.showRewardedAd();
});

// GRANT HERE ONLY: the ad was watched to the end.
window.addEventListener('appmint:reward', function (e) {
  addCoins(e.detail.amount);                        // e.detail = {type, amount}
});

// The ad closed. earned === false means the user left early: grant nothing.
window.addEventListener('appmint:ad-closed', function (e) {
  if (e.detail.adType === 'rewarded' && !e.detail.earned) showMessage('No reward: the ad was closed early.');
  refresh();
});

// Nothing could be shown.
window.addEventListener('appmint:ad-unavailable', function (e) {
  // e.detail = {adType, reason}; reason: ad_free | disabled | consent | not_loaded | show_failed
  if (e.detail.reason === 'ad_free') addCoins(1);   // the user paid to remove ads: reward anyway
  else showMessage('No ad right now. Try again in a moment.');
  refresh();
});
```

The same three moments also call page hooks, if you prefer one function each (each gets the same object as `e.detail`):

```js
window.onAppMintReward = function (reward) { addCoins(reward.amount); };      // {type, amount}
window.onAppMintAdClosed = function (info) { console.log(info.adType, info.earned); };
window.onAppMintAdUnavailable = function (info) { console.log(info.adType, info.reason); };
```

Wrap the call and its events in one Promise, so each "Watch an ad" button reads like a normal async call:

```js
function watchRewardedAd() {                      // call it inside the user's tap
  return new Promise(function (resolve) {
    var b = window.WebToApk;
    if (!b || typeof b.showRewardedAd !== 'function') return resolve({ earned: false, reason: 'no_app' });
    function done(e) {
      if (e.detail.adType !== 'rewarded') return;
      window.removeEventListener('appmint:ad-closed', done);
      window.removeEventListener('appmint:ad-unavailable', done);
      resolve(e.type === 'appmint:ad-closed' ? { earned: e.detail.earned } : { earned: false, reason: e.detail.reason });
    }
    window.addEventListener('appmint:ad-closed', done);
    window.addEventListener('appmint:ad-unavailable', done);
    b.showRewardedAd();
  });
}

document.getElementById('revive').addEventListener('click', function () {
  watchRewardedAd().then(function (r) {
    if (r.earned || r.reason === 'ad_free') revivePlayer();
    else if (r.reason) showMessage('No ad right now.');
  });
});
```

**Notes:** Call it only from a click handler. Never call it from a timer, page load or route change, and never put a core feature behind it. `not_loaded` also starts a new load, so the next tap usually works. In **Ad Control → Developer-controlled** builds use `AppMintAds.show('rewarded')` instead, which answers with a Promise.

### `signInWithGoogle`

```js
window.WebToApk.signInWithGoogle(serverClientId: String, callbackId: String)
```

===== Native Google Sign-In via Credential Manager ===== Bypasses Google's Android WebView OAuth block (disallowed_useragent error) by running the sign-in flow in native Android and bridging just the resulting Google ID token back to JS, where the Firebase Web SDK consumes it via GoogleAuthProvider.credential(idToken) + signInWithCredential(...). JS contract: window.WebToApk.signInWithGoogle(serverClientId, callbackId) serverClientId "" = the Web client (type 3) in the creator's own google-services.json for this package; with none, error "missing_web_client_id". A page that is not the app's own gets error "foreign_origin". -> on completion, window.__webToApkAuth[callbackId](resultJsonString) is called resultJsonString = '{"ok":true,"idToken":"...","email":"...","displayName":"..."}' or '{"ok":false,"error":"<message>"}'

**Example**

Starts native Google sign-in (Android's account picker) and answers with a Google ID token. Pages normally use the Promise helper `WebToApkAuth.signInWithGoogle(webClientId)`, which wraps this call.

**Returns:** nothing. The answer arrives later: the shell calls `window.__webToApkAuth[callbackId](json)` with `'{"ok":true,"idToken":"...","email":"...","displayName":"...","profilePictureUri":"..."}'` or `'{"ok":false,"error":"<code>","detail":"..."}'`, then removes that entry. **Needs:** your OAuth Web client ID, and the app's SHA-1 / SHA-256 registered with your Google or Firebase project. No build switch.

The helper (recommended):

```js
async function googleSignIn() {
  if (!window.WebToApkAuth) return null;
  try {
    var r = await window.WebToApkAuth.signInWithGoogle('1234567890-abc.apps.googleusercontent.com');
    return r.idToken;
  } catch (err) {
    if (err.message !== 'cancelled') alert('Google sign-in failed: ' + err.message);
    return null;
  }
}
```

The raw call (argument order: web client ID, then your callback id):

```js
function googleSignInRaw(done) {
  if (!window.WebToApk || typeof window.WebToApk.signInWithGoogle !== 'function') return;
  var id = 'g_' + Date.now();
  window.__webToApkAuth = window.__webToApkAuth || {};
  window.__webToApkAuth[id] = function (json) {
    var r = JSON.parse(json);
    if (r.ok) done(r.idToken, r.email);
    else if (r.error !== 'cancelled') alert('Google sign-in failed: ' + r.error);
  };
  window.WebToApk.signInWithGoogle('1234567890-abc.apps.googleusercontent.com', id);
}
```

The raw call with an empty client ID, which uses the Web client from the google-services.json you uploaded:

```js
function googleSignInFromUploadedFile() {
  return new Promise(function (resolve, reject) {
    if (!window.WebToApk || typeof window.WebToApk.signInWithGoogle !== 'function') {
      return reject(new Error('not_in_app'));
    }
    var id = 'g_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) {
      var r = JSON.parse(json);
      if (r.ok) resolve(r); else reject(new Error(r.error));
    };
    window.WebToApk.signInWithGoogle('', id);
  });
}

googleSignInFromUploadedFile()
  .then(function (r) { showProfile(r.displayName, r.profilePictureUri); })
  .catch(function (e) {
    if (e.message === 'missing_web_client_id') console.log('Pass your Web client ID: the uploaded file has none.');
  });
```

**Notes:** Error codes: `cancelled` (user closed the picker), `google_play_services_unavailable` (no Google account or provider — also what a missing SHA-1 registration looks like), `missing_web_client_id` (none passed, and no Web client in the `google-services.json` you uploaded), `foreign_origin` (the page on screen is not the app's own), `failed`, `unexpected_credential_type`, `parse_failed: ...`. An empty first argument uses the Web client from your own uploaded `google-services.json` when it has one. Verify the ID token on your server (or hand it to Firebase with `GoogleAuthProvider.credential(idToken)`).

### `signOutGoogle`

```js
window.WebToApk.signOutGoogle(callbackId: String)
```

Clears the saved Credential Manager state so the next signInWithGoogle call shows the account picker again (rather than silently re-selecting the last account). Generated apps should call this on user-initiated sign-out, in addition to firebase.auth().signOut().

**Example**

Clears Android's saved Google sign-in state, so the next sign-in shows the account picker again. Pages normally use the Promise helper `WebToApkAuth.signOutGoogle()`, which wraps this call.

**Returns:** nothing. The answer arrives later: the shell calls `window.__webToApkAuth[callbackId](json)` with `'{"ok":true}'` or `'{"ok":false,"error":"..."}'`. **Needs:** nothing.

The helper (recommended):

```js
async function signOut() {
  await myBackendSignOut();                          // end your own session first
  if (window.WebToApkAuth) {
    try { await window.WebToApkAuth.signOutGoogle(); } catch (err) { /* nothing to undo */ }
  }
}
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.signOutGoogle === 'function') {
  var id = 'gout_' + Date.now();
  window.__webToApkAuth = window.__webToApkAuth || {};
  window.__webToApkAuth[id] = function (json) {
    var r = JSON.parse(json);
    if (!r.ok) console.log('Google sign-out failed: ' + r.error);
  };
  window.WebToApk.signOutGoogle(id);
}
```

The raw call wrapped in a Promise, used when the user deletes their account:

```js
function clearGoogleChoice() {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof window.WebToApk.signOutGoogle !== 'function') return resolve(false);
    var id = 'gout_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json).ok === true); };
    window.WebToApk.signOutGoogle(id);
  });
}

async function deleteAccount() {
  await fetch('https://your-domain.com/account', { method: 'DELETE', credentials: 'include' });
  await clearGoogleChoice();          // the next sign-in starts from the account picker
  showSignInScreen();
}
```

**Notes:** It does not sign the user out of Firebase or your server — do that yourself.

### `splashHold`

```js
window.WebToApk.splashHold()
```

Keeps the branded launch splash up until [splashReady] is called. By default the splash hides as soon as the body has laid-out children, which for an app that paints a skeleton and then fetches its first screen means "splash, then a skeleton". An app that would rather reveal a finished first screen calls this at import and [splashReady] once that screen is on. Capped at the same 6s backstop as the paint poll, so a page that never reports ready still opens.

**Example**

Keeps the app's launch splash on screen until you call `splashReady()` — so the user sees your finished first screen, not "splash, then a loading skeleton".

**Returns:** nothing. **Needs:** nothing.

Call it as early as possible (at the top of your first script), then call `splashReady()` once the first screen is drawn:

```js
if (window.WebToApk && typeof window.WebToApk.splashHold === 'function') {
  window.WebToApk.splashHold();
}

loadFirstScreen()                      // your own async start-up (fetch data, render)
  .catch(function (err) { showError(err); })
  .then(function () {
    if (window.WebToApk && window.WebToApk.splashReady) window.WebToApk.splashReady();
  });
```

Hold the splash until the first screen's hero image has loaded, so the app opens on a finished picture:

```js
if (window.WebToApk && typeof window.WebToApk.splashHold === 'function') {
  window.WebToApk.splashHold();
}

function releaseSplash() {
  if (window.WebToApk && window.WebToApk.splashReady) window.WebToApk.splashReady();
}

var hero = document.getElementById('hero');
if (hero.complete) {
  releaseSplash();
} else {
  hero.addEventListener('load', releaseSplash);
  hero.addEventListener('error', releaseSplash);   // a broken image must not keep the splash up
}
```

Hold only when the first screen needs data, and let a screen that paints at once open normally:

```js
var signedIn = !!localStorage.getItem('session');

if (signedIn && window.WebToApk && typeof window.WebToApk.splashHold === 'function') {
  window.WebToApk.splashHold();        // the dashboard needs its data before it looks right
}

if (signedIn) {
  loadDashboard().finally(function () {
    if (window.WebToApk && window.WebToApk.splashReady) window.WebToApk.splashReady();
  });
} else {
  showLoginScreen();                   // paints at once; the splash hides by itself
}
```

**Notes:** Without `splashHold()` the splash hides as soon as the page body has visible children. A held splash still hides by itself after a few seconds (the same backstop as the normal paint check), so a page that never calls `splashReady()` still opens. Always call `splashReady()` on the error path too. The creator's minimum splash time still applies. AI-built apps can use `splash` from `@/lib/appmintNative`.

### `splashReady`

```js
window.WebToApk.splashReady()
```

The first screen is on — hide the launch splash now (subject to the creator's minimum splash duration). Idempotent; harmless when nothing was held.

**Example**

Tells the shell your first screen is on, so the launch splash hides now.

**Returns:** nothing. **Needs:** nothing. Safe to call more than once, and harmless when nothing was held.

It pairs with `splashHold()`:

```js
if (window.WebToApk && window.WebToApk.splashHold) window.WebToApk.splashHold();

async function start() {
  try {
    var res = await fetch('/api/today');
    renderToday(await res.json());
  } catch (e) {
    renderOfflineMessage();
  } finally {
    if (window.WebToApk && typeof window.WebToApk.splashReady === 'function') {
      window.WebToApk.splashReady();   // hide the splash in every case
    }
  }
}
start();
```

Reveal the screen only after the browser has painted what you just rendered (two animation frames):

```js
renderApp(document.getElementById('root'));

requestAnimationFrame(function () {
  requestAnimationFrame(function () {
    if (window.WebToApk && typeof window.WebToApk.splashReady === 'function') {
      window.WebToApk.splashReady();
    }
  });
});
```

Wait for several start-up jobs, and hide the splash even when one of them fails:

```js
if (window.WebToApk && window.WebToApk.splashHold) window.WebToApk.splashHold();

Promise.allSettled([
  loadProfile(),          // your own start-up requests
  loadFeed(),
  document.fonts.ready    // no flash of the wrong font
]).then(function (results) {
  results.forEach(function (r) { if (r.status === 'rejected') console.warn(r.reason); });
  if (window.WebToApk && typeof window.WebToApk.splashReady === 'function') {
    window.WebToApk.splashReady();
  }
});
```

**Notes:** The creator's minimum splash duration is still honoured. If you never call `splashHold()`, the splash hides on its own when the page paints, and `splashReady()` does nothing extra.

### `startRemoveAdsPurchase`

```js
window.WebToApk.startRemoveAdsPurchase()
```

Launch the Google Play "Remove Ads" purchase flow from the page (user-initiated, e.g. the app's own "Remove ads" button). Without this the ONLY entry point was the native side-menu item — unreachable in apps built without the side menu. Outcome reaches the page via 'appmint:premium' {premium:true}; already-owned re-notifies immediately; flow errors surface as a native toast.

**Example**

Opens Google Play's purchase sheet for your "Remove Ads" product, from your page's own "Remove ads" button.

**Returns:** nothing. The result arrives as `appmint:premium` `{premium: true}` (and `window.onAppMintPremiumChanged`). **Needs:** Ads on and **Remove Ads (IAP)** with your **Play Store Product ID** (the first ID listed is the one sold). The app has no menu item for this, so your page must have the button.

```js
var b = window.WebToApk;
var btn = document.getElementById('remove-ads');

if (!b || typeof b.startRemoveAdsPurchase !== 'function' || b.isPremium()) {
  btn.hidden = true;                          // browser, or already ad-free
} else {
  btn.addEventListener('click', function () {
    btn.disabled = true;
    b.startRemoveAdsPurchase();
  });
}

window.addEventListener('appmint:premium', function (e) {
  if (e.detail.premium) { btn.hidden = true; showMessage('Thank you! Ads are gone.'); }
});

// A cancel on Google's sheet sends no event: release the button when the page is visible again.
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) btn.disabled = false;
});
```

A settings row that shows whether ads are removed, with the button that buys it:

```js
var row = document.getElementById('ads-setting');
var btn = row.querySelector('button');
var b = window.WebToApk;

function paintAdsRow(premium) {
  row.querySelector('.status').textContent = premium ? 'Ads removed. Thank you!' : 'Ads are on';
  btn.hidden = premium;
}

if (b && typeof b.startRemoveAdsPurchase === 'function') {
  paintAdsRow(b.isPremium());
  btn.addEventListener('click', function () { b.startRemoveAdsPurchase(); });
  window.onAppMintPremiumChanged = function (info) { paintAdsRow(info.premium); };   // {premium}
} else {
  row.hidden = true;                                  // browser: nothing to sell
}
```

Offer it after every third finished round, in a dialog the player answers with a tap:

```js
var rounds = 0;
var offer = document.getElementById('remove-ads-offer');   // a <dialog>

function onRoundFinished() {
  var b = window.WebToApk;
  if (!b || typeof b.startRemoveAdsPurchase !== 'function' || b.isPremium()) return;
  rounds += 1;
  if (rounds % 3 === 0) offer.showModal();
}

offer.querySelector('.buy').addEventListener('click', function () {
  offer.close();
  window.WebToApk.startRemoveAdsPurchase();   // opened from the player's own tap
});
offer.querySelector('.later').addEventListener('click', function () { offer.close(); });
```

**Notes:** If the user already owns it, `appmint:premium` `{premium:true}` fires at once. Errors (Play not reachable, product not found in Play Console) are shown by the app as a short native message; the page gets no event for them. Test with an app installed from a Play testing track.

### `statFile`

```js
window.WebToApk.statFile(pathOrUri: String): String
```

File or folder metadata without reading any of it. A relative path names a file or a sub-folder of the granted folder; a folder answers `isDirectory: true` with `size` 0 and `mime` `vnd.android.document/directory`. @return JSON `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` with `not-found`, `disabled` or `foreign_origin`.

**Example**

Reads a file's metadata (name, size, type) without reading its bytes. Pages normally use `WebToApkFS.stat(pathOrUri)`, which parses the JSON for you.

**Returns:** (sync) a JSON **string** `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` (`'not-found'`, `'disabled'`, `'foreign_origin'`). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way:

```js
const info = WebToApkFS.stat(fileUri);
if (info.ok) console.log(info.name, info.size);
```

The raw call:

```js
if (window.WebToApk && typeof WebToApk.statFile === 'function') {
  const info = JSON.parse(WebToApk.statFile(fileUri));   // or 'notes/today.txt' in the granted folder
  if (info.ok) console.log(info.name, info.size + ' bytes', info.mime, info.canWrite);
  else console.log('stat failed:', info.error);
}
```

Refuse a file that is too big before reading any of it:

```js
const MAX_BYTES = 20 * 1024 * 1024;
function canImport(fileUri) {
  const info = WebToApkFS.stat(fileUri);
  if (!info.ok) { alert('File not available: ' + info.error); return false; }
  if (info.size > MAX_BYTES) {
    alert(info.name + ' is ' + Math.round(info.size / 1048576) + ' MB; the limit is 20 MB.');
    return false;
  }
  return true;
}
```

Look up a sub-folder of the granted folder and only offer "Save here" when it is writable:

```js
function checkExportFolder() {
  const info = WebToApkFS.stat('exports');          // a path relative to the granted folder
  const btn = document.querySelector('#save-here');
  if (!info.ok) {                                    // 'not-found' = the folder is not there yet
    btn.textContent = 'Create "exports" and save';
    return WebToApkFS.mkdir('exports');
  }
  btn.disabled = !(info.isDirectory && info.canWrite);
  console.log('exports last changed', new Date(info.lastModified).toLocaleString());
  return true;
}
```

**Notes:** Accepts a `content://` URI from `pickFile` or a path relative to the folder granted with `requestFolderAccess`. It only answers the app's own pages or its configured website; a page from any other site gets `foreign_origin`.

### `toggleFullscreen`

```js
window.WebToApk.toggleFullscreen()
```

*(Fullscreen bridge)* Lets the page hide the status bar (clock/battery) and navigation bar itself — e.g. a game entering its play screen:     WebToApk.setFullscreen(true)   // hide the system bars     WebToApk.setFullscreen(false)  // bring them back     WebToApk.toggleFullscreen()     WebToApk.isFullscreen() The choice is remembered across launches. It is a no-op in apps the creator already locked to fullscreen/kiosk — there is nothing to restore to there. The side-menu "Fullscreen" item is the equivalent for end users and is gated by the creator's enableFullscreenToggle option; this bridge is always available to the app's own page.

**Example**

Switches the Android status and navigation bars between hidden and shown.

**Returns:** nothing. **Needs:** nothing. The new state is remembered across launches.

A "fullscreen" button that also updates its own label:

```js
var btn = document.getElementById('fs-btn');
if (window.WebToApk && typeof window.WebToApk.toggleFullscreen === 'function') {
  btn.onclick = function () {
    window.WebToApk.toggleFullscreen();
    // The change is applied on the UI thread; read the state a moment later.
    setTimeout(function () {
      btn.textContent = window.WebToApk.isFullscreen() ? 'Exit fullscreen' : 'Fullscreen';
    }, 100);
  };
} else {
  btn.hidden = true;   // in a browser
}
```

Double-tap a photo or a reading view to hide or show the bars:

```js
var viewer = document.getElementById('photo-viewer');

viewer.addEventListener('dblclick', function () {
  if (window.WebToApk && typeof window.WebToApk.toggleFullscreen === 'function') {
    window.WebToApk.toggleFullscreen();
  }
});
```

Toggle with the F key on a hardware keyboard, but not while the user types in a field:

```js
document.addEventListener('keydown', function (e) {
  if (e.key !== 'f' || e.target.closest('input, textarea, [contenteditable]')) return;
  if (window.WebToApk && typeof window.WebToApk.toggleFullscreen === 'function') {
    e.preventDefault();
    window.WebToApk.toggleFullscreen();
  }
});
```

**Notes:** It does nothing in an app built with **Fullscreen** or kiosk mode on (the bars stay hidden). The side-menu "Fullscreen" item for end users is a separate build switch (**Fullscreen toggle in side menu**); this bridge method works without it.

### `ttsCancel`

```js
window.WebToApk.ttsCancel()
```

*(Text-to-speech)* Backs the window.speechSynthesis polyfill, which is installed at document start in every frame — pages should use the STANDARD speechSynthesis API and these will be called for them. Android WebView ships no working speechSynthesis of its own, which is why the reroute exists.

**Example**

Stops speech right now and throws away everything still waiting to be spoken. Pages normally call the standard `speechSynthesis.cancel()`, which calls this for you.

**Returns:** nothing. **Needs:** nothing.

The public way (use this):

```js
document.getElementById('stop-reading').addEventListener('click', function () {
  if ('speechSynthesis' in window) speechSynthesis.cancel();
});
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.ttsCancel === 'function') {
  window.WebToApk.ttsCancel();
}
```

Say only the newest status and drop anything older still queued (a delivery tracker, a live score):

```js
function announceLatest(text) {
  if (!('speechSynthesis' in window)) return;
  speechSynthesis.cancel();   // the old line gets error 'interrupted', queued ones 'canceled'
  var u = new SpeechSynthesisUtterance(text);
  u.onerror = function (e) {
    if (e.error === 'interrupted' || e.error === 'canceled') return;   // replaced, not a failure
    showToast('Could not read that aloud');
  };
  speechSynthesis.speak(u);
}

announceLatest('Your driver is 5 minutes away');
announceLatest('Your driver has arrived');   // cuts the first one off
```

**Notes:** Speech that is cut off or dropped sends **no** `appmint:tts` `end` or `error` event, so an utterance's `onend` does not run after a cancel. If you track raw `ttsSpeak` ids yourself, forget them when you call `ttsCancel()`.

### `ttsGetVoices`

```js
window.WebToApk.ttsGetVoices(): String
```

JSON array of installed voices: [{name, lang, default}]. Empty until the engine finishes init — the page gets 'voiceschanged' then and re-queries, exactly like Chrome's async getVoices() contract.

**Example**

Lists the text-to-speech voices installed on the phone. Pages normally use the standard `speechSynthesis.getVoices()`, which calls this for you.

**Returns:** a JSON **string**, synchronously: `[{ "name": "en-us-x-sfg-local", "lang": "en-US", "default": false }, ...]`. It is `"[]"` until the engine has started. **Needs:** nothing.

The public way (use this):

```js
function fillLanguages() {
  var langs = {};
  speechSynthesis.getVoices().forEach(function (v) { langs[v.lang] = true; });
  document.getElementById('langs').textContent = Object.keys(langs).join(', ');
}
if ('speechSynthesis' in window) {
  fillLanguages();
  speechSynthesis.onvoiceschanged = fillLanguages;
}
```

The raw call (remember `JSON.parse`):

```js
function nativeVoices() {
  if (!(window.WebToApk && typeof window.WebToApk.ttsGetVoices === 'function')) return [];
  try { return JSON.parse(window.WebToApk.ttsGetVoices()); } catch (e) { return []; }
}

window.addEventListener('appmint:tts', function (e) {
  if (e.detail && e.detail.event === 'voiceschanged') {
    var hindi = nativeVoices().filter(function (v) { return v.lang.indexOf('hi') === 0; });
    document.getElementById('hindi-btn').hidden = hindi.length === 0;
  }
});
```

Speak in the phone's language when a voice for it is installed, otherwise let the engine use its default voice:

```js
function bestSpeechLang() {
  if (!(window.WebToApk && typeof window.WebToApk.ttsGetVoices === 'function')) return '';
  var voices;
  try { voices = JSON.parse(window.WebToApk.ttsGetVoices()); } catch (e) { return ''; }
  var want = navigator.language || 'en-US';            // e.g. 'ta-IN'
  var exact = voices.filter(function (v) { return v.lang === want; })[0];
  if (exact) return exact.lang;
  var base = want.split('-')[0];
  var near = voices.filter(function (v) { return v.lang.split('-')[0] === base; })[0];
  return near ? near.lang : '';                        // '' = the engine's default voice
}

if (window.WebToApk && typeof window.WebToApk.ttsSpeak === 'function') {
  window.WebToApk.ttsSpeak(greetingText(), bestSpeechLang(), 1.0, 1.0, 'greeting');
}
```

**Notes:** `lang` is a BCP-47 tag such as `en-US` or `ta-IN`. A voice's `name` is the engine's own id and cannot be chosen by name when speaking; only its language is used.

### `ttsIsSpeaking`

```js
window.WebToApk.ttsIsSpeaking(): Boolean
```

*(Text-to-speech)* Backs the window.speechSynthesis polyfill, which is installed at document start in every frame — pages should use the STANDARD speechSynthesis API and these will be called for them. Android WebView ships no working speechSynthesis of its own, which is why the reroute exists.

**Example**

Tells you if the phone's text-to-speech engine is speaking at this moment. Pages normally use `speechSynthesis.speaking` or the utterance's `onend` instead.

**Returns:** `true` / `false`, synchronously. `false` before the engine has started. **Needs:** nothing.

The public way (use this):

```js
if ('speechSynthesis' in window && speechSynthesis.speaking) {
  speechSynthesis.cancel();
}
```

The raw call asks the engine itself, so it is exact even if speech was started with the raw `ttsSpeak`:

```js
function isSpeaking() {
  if (window.WebToApk && typeof window.WebToApk.ttsIsSpeaking === 'function') {
    return window.WebToApk.ttsIsSpeaking() === true;
  }
  return false;
}

var btn = document.getElementById('read-btn');
setInterval(function () {
  btn.textContent = isSpeaking() ? 'Stop' : 'Read aloud';
}, 500);
```

Wait until the app has stopped talking before you start recording, so the mic does not pick up its own voice:

```js
function whenQuiet(fn) {
  if (!(window.WebToApk && typeof window.WebToApk.ttsIsSpeaking === 'function')) { fn(); return; }
  (function check() {
    if (window.WebToApk.ttsIsSpeaking()) setTimeout(check, 300);
    else fn();
  })();
}

document.getElementById('answer-btn').addEventListener('click', function () {
  whenQuiet(startRecordingAnswer);   // e.g. after the question was read aloud
});
```

**Notes:** Do not poll fast; an event (`onend`, or `appmint:tts` with `event: 'end'`) is better when you started the speech yourself.

### `ttsSpeak`

```js
window.WebToApk.ttsSpeak(text: String, lang: String, rate: Double, pitch: Double, utteranceId: String)
```

*(Text-to-speech)* Backs the window.speechSynthesis polyfill, which is installed at document start in every frame — pages should use the STANDARD speechSynthesis API and these will be called for them. Android WebView ships no working speechSynthesis of its own, which is why the reroute exists.

**Example**

Starts speaking text with the phone's text-to-speech engine. Pages normally use the standard `speechSynthesis` API, which calls this for you.

**Returns:** nothing. Progress arrives later as the `appmint:tts` event (and the `window.onAppMintTts(detail)` hook) with `detail = { id, event }`, where `event` is `'start'`, `'end'` or `'error'`, and `id` is the `utteranceId` you passed. **Needs:** nothing.

The public way (use this):

```js
if ('speechSynthesis' in window) {
  var u = new SpeechSynthesisUtterance('Your order is ready');
  u.lang = 'en-GB';
  u.onend = function () { console.log('done'); };
  speechSynthesis.speak(u);
}
```

The raw call, if you want your own ids. Arguments: `text, lang, rate, pitch, utteranceId`:

```js
var ttsWaiting = {};

window.addEventListener('appmint:tts', function (e) {
  var d = e.detail || {};
  if (d.event === 'voiceschanged') return;        // engine ready, id is ''
  var done = ttsWaiting[d.id];
  if (!done || d.event === 'start') return;
  delete ttsWaiting[d.id];
  done(d.event === 'end');                         // 'end' = spoke it, 'error' = failed
});

function speakRaw(text) {
  return new Promise(function (resolve) {
    if (!(window.WebToApk && typeof window.WebToApk.ttsSpeak === 'function')) { resolve(false); return; }
    var id = 'mine_' + Date.now();
    ttsWaiting[id] = resolve;
    window.WebToApk.ttsSpeak(String(text), 'en-US', 1.0, 1.0, id);
  });
}

speakRaw('Hello').then(function (ok) { console.log(ok ? 'spoken' : 'could not speak'); });
```

Read a list of steps in order with your own ids, and highlight the step being spoken:

```js
var steps = Array.prototype.slice.call(document.querySelectorAll('.recipe-step'));

window.addEventListener('appmint:tts', function (e) {
  var d = e.detail || {};
  var m = /^recipe_(\d+)$/.exec(d.id || '');
  if (!m) return;                                  // not one of ours
  var el = steps[Number(m[1])];
  if (d.event === 'start') el.classList.add('reading');
  if (d.event === 'end' || d.event === 'error') el.classList.remove('reading');
});

document.getElementById('read-recipe').addEventListener('click', function () {
  if (!(window.WebToApk && typeof window.WebToApk.ttsSpeak === 'function')) return;
  steps.forEach(function (el, i) {
    // '' = the engine's default voice; 0.9 = a little slower for cooking
    window.WebToApk.ttsSpeak(el.textContent, '', 0.9, 1.0, 'recipe_' + i);
  });
});
```

**Notes:** Utterances queue one after another. `lang` may be `''` (the engine's default voice); `rate` and `pitch` are clamped to 0.1–4. If the engine is still starting, the text waits and is spoken when it is ready; if the engine cannot start, each waiting utterance gets `'error'`. Use your own id prefix; ids starting `wta_u` belong to the `speechSynthesis` layer. On Android 8+ `appmint:tts` also carries `{id, event: 'boundary', charIndex, charLength}` before each word. This raw call has no volume or voice — `speechSynthesis` has both.

### `ttsWarmUp`

```js
window.WebToApk.ttsWarmUp()
```

*(Text-to-speech)* Backs the window.speechSynthesis polyfill, which is installed at document start in every frame — pages should use the STANDARD speechSynthesis API and these will be called for them. Android WebView ships no working speechSynthesis of its own, which is why the reroute exists.

**Example**

Starts the phone's text-to-speech engine early, so the first `speak()` has no delay and `getVoices()` fills in. The app's `speechSynthesis` layer already calls this at page start, so pages almost never need it.

**Returns:** nothing. When the engine is ready, the `appmint:tts` event fires with `detail = { id: '', event: 'voiceschanged' }` (and `speechSynthesis.onvoiceschanged` runs). **Needs:** nothing.

The public way (use this): just read voices when they are ready.

```js
if ('speechSynthesis' in window) {
  speechSynthesis.onvoiceschanged = function () {
    console.log('voices:', speechSynthesis.getVoices().length);
  };
}
```

The raw call, then wait for the ready event:

```js
window.addEventListener('appmint:tts', function (e) {
  if (e.detail && e.detail.event === 'voiceschanged') {
    var list = JSON.parse(window.WebToApk.ttsGetVoices());
    console.log('engine ready,', list.length, 'voices');
  }
});

if (window.WebToApk && typeof window.WebToApk.ttsWarmUp === 'function') {
  window.WebToApk.ttsWarmUp();   // safe to call many times
}
```

Start the engine when a "Read aloud" screen opens and enable its button once the engine is ready:

```js
var readBtn = document.getElementById('read-aloud');

function engineReady() {
  try { return JSON.parse(window.WebToApk.ttsGetVoices()).length > 0; } catch (e) { return false; }
}

function openReaderScreen() {
  showReaderScreen();
  if (!(window.WebToApk && typeof window.WebToApk.ttsWarmUp === 'function')) return;
  readBtn.disabled = !engineReady();       // already started earlier? enable right away
  window.WebToApk.ttsWarmUp();             // no-op when the engine is already running
}

window.addEventListener('appmint:tts', function (e) {
  if (e.detail && e.detail.event === 'voiceschanged') readBtn.disabled = false;
});
```

**Notes:** `voiceschanged` fires once, when the engine finishes starting. If it already started (for example because the `speechSynthesis` layer warmed it), you will not get it again; call `ttsGetVoices()` directly.

### `vibrate`

```js
window.WebToApk.vibrate(durationMs: Long)
```

*(Vibration)* Simple haptics for the page (navigator.vibrate and a pattern form). Both are ignored unless the build enabled the Vibrate permission.

**Example**

Vibrates the phone once for the given number of milliseconds. Most pages use the standard `navigator.vibrate(ms)`, which calls this for you.

**Returns:** nothing. **Needs:** turn on **Vibrate** in Step 4 (Access) when you build. Without it the call does nothing.

The standard way (works the same in a browser):

```js
if ('vibrate' in navigator) navigator.vibrate(50);
```

The raw call:

```js
document.getElementById('like').addEventListener('click', function () {
  if (window.WebToApk && typeof window.WebToApk.vibrate === 'function') {
    window.WebToApk.vibrate(40);      // 40 ms
  }
});

// 0 (or any value <= 0) stops a running vibration
if (window.WebToApk && window.WebToApk.vibrate) window.WebToApk.vibrate(0);
```

A tiny tick each time a slider snaps to a new step, like a native picker:

```js
var slider = document.getElementById('size');
var last = slider.value;
slider.addEventListener('input', function () {
  if (slider.value === last) return;          // same step, no tick
  last = slider.value;
  if (window.WebToApk && typeof window.WebToApk.vibrate === 'function') {
    window.WebToApk.vibrate(10);              // 10 ms: a light tick
  }
});
```

A longer buzz when a form cannot be sent, so the user feels the error:

```js
document.getElementById('signup').addEventListener('submit', function (e) {
  if (this.checkValidity()) return;
  e.preventDefault();
  if (window.WebToApk && typeof window.WebToApk.vibrate === 'function') {
    window.WebToApk.vibrate(250);
  }
  this.reportValidity();
});
```

**Notes:** Pass a whole number. The strength is the phone's default; for stronger/weaker taps use `Capacitor.Plugins.Haptics.impact` or `Capacitor.Plugins.AppwrightHaptics.play`. For a buzz-pause-buzz pattern use `vibratePattern`.

### `vibratePattern`

```js
window.WebToApk.vibratePattern(patternJson: String)
```

Vibrates an on/off pattern, e.g. "[100,50,100]" = buzz 100ms, pause 50, buzz 100. Backs navigator.vibrate(array); prefer the standard API. No-op unless the creator enabled the Vibrate permission.

**Example**

Vibrates an on/off pattern, given as a JSON string. Most pages use the standard `navigator.vibrate([...])`, which calls this for you.

**Returns:** nothing. **Needs:** turn on **Vibrate** in Step 4 (Access) when you build. Without it the call does nothing.

The standard way:

```js
if ('vibrate' in navigator) navigator.vibrate([100, 50, 100]);   // buzz, pause, buzz
```

The raw call takes the same web-style pattern as a **JSON string**:

```js
function pattern(list) {
  if (window.WebToApk && typeof window.WebToApk.vibratePattern === 'function') {
    window.WebToApk.vibratePattern(JSON.stringify(list));
  }
}

pattern([200]);                     // one 200 ms buzz
pattern([100, 50, 100, 50, 300]);   // short, short, long
pattern([]);                        // empty list stops a running vibration
```

The pattern plays once, so an alarm repeats it itself until the user stops it:

```js
var alarm = null;
var ALARM = JSON.stringify([500, 300, 500, 300, 500]);   // about 2.1 s

function startAlarm() {
  if (!window.WebToApk || typeof window.WebToApk.vibratePattern !== 'function') return;
  window.WebToApk.vibratePattern(ALARM);
  alarm = setInterval(function () { window.WebToApk.vibratePattern(ALARM); }, 3000);
}

function stopAlarm() {
  if (alarm === null) return;
  clearInterval(alarm);
  alarm = null;
  window.WebToApk.vibratePattern('[]');                 // stop the buzz that is still running
}
```

**Notes:** The first number is a **buzz**, then pause, buzz, pause ... (web order). The app adds Android's leading "wait" for you, so do not put a `0` first yourself: `[0, 100]` means "buzz 0 ms, pause 100 ms" and you feel nothing. Negative numbers count as 0. The pattern plays once.

### `wbConnect`

```js
window.WebToApk.wbConnect(requestId: String, deviceId: String)
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `device.gatt.connect()`. Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** (through `gatt.connect()`) a Promise of the `BluetoothRemoteGATTServer`, after the device's services are discovered. **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

```js
async function connectAndList(device) {
  try {
    var server = await device.gatt.connect();
    var services = await server.getPrimaryServices();
    services.forEach(function (s) { console.log('service', s.uuid); });
    return server;
  } catch (err) {
    showStatus('Could not connect: ' + err.message);    // NetworkError, NotFoundError
    return null;
  }
}
```

**Reconnect when the device drops.** Calling `connect()` again on the same device works:

```js
device.addEventListener('gattserverdisconnected', async function () {
  showStatus('Reconnecting...');
  try { await connectAndList(device); showStatus('Connected'); } catch (e) {}
});
```

Connect to two sensors at the same time, for example a heart-rate strap and a power meter:

```js
async function connectBoth(hrDevice, powerDevice) {
  var servers = await Promise.all([hrDevice.gatt.connect(), powerDevice.gatt.connect()]);
  var hr = await servers[0].getPrimaryService('heart_rate');
  var power = await servers[1].getPrimaryService('cycling_power');
  return {
    heartRate: await hr.getCharacteristic('heart_rate_measurement'),
    power: await power.getCharacteristic('cycling_power_measurement')
  };
}
```

**Notes:** Raw signature `wbConnect(requestId, deviceId)`. Connecting to a device that is already connected resolves again at once. `getPrimaryService(s)` rejects with `NetworkError` before `connect()` has finished. Several devices can be connected at the same time.

### `wbDisconnect`

```js
window.WebToApk.wbDisconnect(deviceId: String)
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `device.gatt.disconnect()` (and `device.forget()`). Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** nothing (`disconnect()` is synchronous, as in the web standard). The device then fires `gattserverdisconnected`. **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

```js
var device = null;   // from navigator.bluetooth.requestDevice(...)

document.getElementById('stop').addEventListener('click', function () {
  if (device && device.gatt.connected) device.gatt.disconnect();
});

// Fires for your own disconnect() and when the device goes away by itself.
function watch(dev) {
  dev.addEventListener('gattserverdisconnected', function () {
    showStatus('Disconnected from ' + (dev.name || dev.id));
  });
}
```

Tell your own Stop apart from the sensor going out of range, and reconnect only in the second case:

```js
var userStopped = false;

function stopSession(device) {
  userStopped = true;
  if (device.gatt.connected) device.gatt.disconnect();
}

function watchDrops(device) {
  device.addEventListener('gattserverdisconnected', function () {
    if (userStopped) { userStopped = false; showStatus('Session ended'); return; }
    showStatus('Sensor out of range, reconnecting...');
    device.gatt.connect()
      .then(resubscribe)                 // your code: get the characteristics, startNotifications()
      .catch(function () { showStatus('Could not reconnect'); });
  });
}
```

A "Remove sensor" button: `forget()` disconnects and drops the device, and no `gattserverdisconnected` follows it:

```js
var device = null;   // from navigator.bluetooth.requestDevice(...)

document.getElementById('forget').addEventListener('click', async function () {
  if (!device) return;
  await device.forget();
  device = null;
  showStatus('Sensor removed. Tap Connect to pick one again.');
});
```

**Notes:** Raw signature `wbDisconnect(deviceId)`. After a disconnect the old service and characteristic objects stop receiving notifications; after the next `connect()`, get them again and call `startNotifications()` again. The app drops every connection when it closes.

### `wbGetCharacteristics`

```js
window.WebToApk.wbGetCharacteristics(requestId: String, deviceId: String, service: String)
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `service.getCharacteristic(uuid)` and `service.getCharacteristics()`. Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** (through the service) a Promise of one `BluetoothRemoteGATTCharacteristic`, or of an array of them, each with `uuid` and `properties` (`read`, `write`, `writeWithoutResponse`, `notify`, `indicate`, `broadcast`, `authenticatedSignedWrites`). **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

```js
async function describe(server) {
  var service = await server.getPrimaryService('heart_rate');

  // One characteristic, by standard name, 16-bit number or full UUID:
  var measurement = await service.getCharacteristic('heart_rate_measurement'); // same as 0x2a37

  // All of them:
  var list = await service.getCharacteristics();
  list.forEach(function (c) {
    var p = c.properties;
    console.log(c.uuid, p.read ? 'read' : '', p.write ? 'write' : '', p.notify ? 'notify' : '');
  });
  return measurement;
}
```

Find the characteristics of a custom service by what they can do, instead of hard-coding their UUIDs:

```js
async function findSerialPipes(server) {
  var svc = await server.getPrimaryService('0000ffe0-0000-1000-8000-00805f9b34fb');
  var list = await svc.getCharacteristics();
  var incoming = list.find(function (c) { return c.properties.notify || c.properties.indicate; });
  var outgoing = list.find(function (c) { return c.properties.write || c.properties.writeWithoutResponse; });
  if (!incoming || !outgoing) throw new Error('This device does not look like a serial adapter');
  return { incoming: incoming, outgoing: outgoing };
}
```

Read an optional characteristic and treat `NotFoundError` as "this device does not have it":

```js
var LOCATIONS = ['Other', 'Chest', 'Wrist', 'Finger', 'Hand', 'Ear lobe', 'Foot'];

async function sensorLocation(server) {
  var hr = await server.getPrimaryService('heart_rate');
  try {
    var chr = await hr.getCharacteristic('body_sensor_location');   // 0x2a38, optional in the spec
    var v = await chr.readValue();
    return LOCATIONS[v.getUint8(0)] || 'Unknown';
  } catch (err) {
    if (err.name === 'NotFoundError') return null;                   // this strap does not report it
    throw err;
  }
}
```

**Notes:** Raw signature `wbGetCharacteristics(requestId, deviceId, service)`. A UUID the service does not have rejects with `NotFoundError`; an unknown name throws `TypeError` (pass a full UUID for custom characteristics). The list is fetched once per service and then reused.

### `wbIsAvailable`

```js
window.WebToApk.wbIsAvailable(): Boolean
```

getAvailability(): whether the phone HAS Bluetooth (Chrome's answer), not whether it is switched on. requestDevice() asks the user to turn it on; answering false while it was merely off made pages that check first ("if (!await getAvailability())") say "Bluetooth not supported" and never reach that prompt (fitness report 2026-10-03).

**Example**

Internal transport behind `navigator.bluetooth.getAvailability()`. Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** (through `getAvailability()`) a Promise of `true` when the build has Bluetooth and the phone's Bluetooth is on, else `false`. **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

```js
async function updateBluetoothHint() {
  var hint = document.getElementById('bt-hint');
  if (!navigator.bluetooth) { hint.textContent = 'Bluetooth is not available in this app.'; return; }
  var on = await navigator.bluetooth.getAvailability();
  hint.textContent = on ? '' : 'Turn on Bluetooth to connect your sensor.';
}
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) updateBluetoothHint();
});
```

Enable the Connect button only while Bluetooth is on, checking again whenever the user comes back to the app:

```js
async function refreshConnectButton() {
  var btn = document.getElementById('connect');
  if (!navigator.bluetooth) { btn.hidden = true; return; }
  btn.disabled = !(await navigator.bluetooth.getAvailability());
}
window.addEventListener('load', refreshConnectButton);
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) refreshConnectButton();
});
```

Give a clear "Bluetooth is off" message before opening the chooser, instead of a NotFoundError afterwards:

```js
document.getElementById('scan').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  if (!(await navigator.bluetooth.getAvailability())) {
    showStatus('Bluetooth is off. Turn it on, then tap Scan again.');
    return;
  }
  try {
    var device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true });
    showStatus('Picked ' + (device.name || device.id));
  } catch (err) {
    showStatus(err.name === 'NotFoundError' ? 'No device chosen.' : err.message);
  }
});
```

**Notes:** Raw signature `wbIsAvailable(): Boolean` (synchronous). It checks the adapter only — it does not ask for any permission. The `availabilitychanged` event of the web standard is not fired, so check again when the page becomes visible.

### `wbNotify`

```js
window.WebToApk.wbNotify(
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `characteristic.startNotifications()` and `stopNotifications()`. Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** (through the characteristic) a Promise of the characteristic itself. Each new value fires `characteristicvaluechanged` on it, with the value in `event.target.value` (a `DataView`). **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

```js
var cadenceChr = null;

async function startCadence(server) {
  var svc = await server.getPrimaryService('running_speed_and_cadence');
  cadenceChr = await svc.getCharacteristic('rsc_measurement');
  cadenceChr.addEventListener('characteristicvaluechanged', onRsc);
  await cadenceChr.startNotifications();
}

function onRsc(e) {
  var v = e.target.value;
  var kmh = v.getUint16(1, true) / 256 * 3.6;
  var spm = v.getUint8(3);
  show('run', kmh.toFixed(1) + ' km/h, ' + spm + ' steps/min');
}

async function stopCadence() {
  if (!cadenceChr) return;
  cadenceChr.removeEventListener('characteristicvaluechanged', onRsc);
  try { await cadenceChr.stopNotifications(); } catch (e) {}
}
```

A thermometer whose measurement only supports indications is subscribed the same way, here through the `oncharacteristicvaluechanged` property:

```js
async function watchThermometer(server) {
  var svc = await server.getPrimaryService('health_thermometer');
  var chr = await svc.getCharacteristic('temperature_measurement');
  chr.oncharacteristicvaluechanged = function (e) {
    var v = e.target.value;
    var mantissa = v.getUint32(1, true) & 0x00ffffff;          // IEEE-11073 FLOAT
    if (mantissa & 0x800000) mantissa -= 0x1000000;
    var fahrenheit = v.getUint8(0) & 1;
    show('temp', (mantissa * Math.pow(10, v.getInt8(4))).toFixed(1) + (fahrenheit ? ' °F' : ' °C'));
  };
  await chr.startNotifications();
}
```

Receive text lines from a serial-style device, joining notifications that split a line in the middle:

```js
async function listenForLines(server, onLine) {
  var uart = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');
  var tx = await uart.getCharacteristic('6e400003-b5a3-f393-e0a9-e50e24dcca9e');
  if (!tx.properties.notify && !tx.properties.indicate) throw new Error('This device does not stream data');
  var decoder = new TextDecoder(), buffer = '';
  tx.addEventListener('characteristicvaluechanged', function (e) {
    buffer += decoder.decode(e.target.value, { stream: true });
    var lines = buffer.split('\n');
    buffer = lines.pop();
    lines.forEach(onLine);
  });
  await tx.startNotifications();
  return tx;
}
```

**Notes:** Raw signature `wbNotify(requestId, deviceId, service, characteristic, enable)`. A characteristic that only supports indications is subscribed with indications automatically. A characteristic that cannot notify rejects with `NotSupportedError`. `oncharacteristicvaluechanged = fn` works too. After a disconnect, subscribe again.

### `wbRead`

```js
window.WebToApk.wbRead(requestId: String, deviceId: String, service: String, characteristic: String)
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `characteristic.readValue()`. Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** (through `readValue()`) a Promise of a `DataView`; `characteristic.value` is updated too. **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

```js
async function readInfo(server) {
  try {
    var battery = await server.getPrimaryService('battery_service');
    var level = await (await battery.getCharacteristic('battery_level')).readValue();
    show('battery', level.getUint8(0) + '%');

    var info = await server.getPrimaryService('device_information');
    var maker = await (await info.getCharacteristic('manufacturer_name_string')).readValue();
    show('maker', new TextDecoder().decode(maker));
  } catch (err) {
    // NotFoundError: the device has no such service/characteristic
    // NotSupportedError: read rejected by the device   NetworkError: not connected / read failed
    show('info', err.message);
  }
}
```

Poll a sensor that can only be read, here the standard Temperature characteristic (0x2a6e, in hundredths of a degree):

```js
var pollTimer = null;

async function startTemperaturePolling(server) {
  var env = await server.getPrimaryService('environmental_sensing');
  var temp = await env.getCharacteristic(0x2a6e);
  pollTimer = setInterval(async function () {
    try {
      var v = await temp.readValue();
      show('temp', (v.getInt16(0, true) / 100).toFixed(1) + ' °C');
    } catch (err) {
      clearInterval(pollTimer);          // NetworkError once the sensor is gone
      show('temp', err.message);
    }
  }, 5000);
}
```

Fill an "About this device" panel by starting several reads at once; they run one after another for you:

```js
async function readDeviceInfo(server) {
  var info = await server.getPrimaryService('device_information');
  var names = ['model_number_string', 'serial_number_string', 'firmware_revision_string'];
  var decoder = new TextDecoder();
  var results = await Promise.allSettled(names.map(async function (name) {
    var chr = await info.getCharacteristic(name);
    return decoder.decode(await chr.readValue());
  }));
  results.forEach(function (r, i) {
    show(names[i], r.status === 'fulfilled' ? r.value : '-');   // missing ones reject with NotFoundError
  });
}
```

**Notes:** Raw signature `wbRead(requestId, deviceId, service, characteristic)`. Reads, writes and notification changes on one device run one after another in the order you call them, so you can fire several without waiting in between.

### `wbRequestDevice`

```js
window.WebToApk.wbRequestDevice(requestId: String, optionsJson: String)
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `navigator.bluetooth.requestDevice(options)`: it shows the app's device chooser. Pages use `navigator.bluetooth.requestDevice`; do not call this directly.

**Returns:** (through `requestDevice`) a Promise of a `BluetoothDevice` `{id, name, gatt}`. **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`) and the Nearby devices permission, asked on first use.

```js
document.getElementById('pick').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    var device = await navigator.bluetooth.requestDevice({
      filters: [{ services: ['cycling_power'] }, { namePrefix: 'KICKR' }],
      optionalServices: ['battery_service']
    });
    showStatus('Picked ' + (device.name || device.id));
  } catch (err) {
    if (err.name === 'NotFoundError') showStatus('No device chosen.');   // also: Bluetooth off
    else showStatus(err.name + ': ' + err.message);                     // SecurityError = permission
  }
});
```

To list every nearby device instead of filtering: `requestDevice({ acceptAllDevices: true })`.

Pick a custom device by its exact name or name prefix, listing its own 128-bit service so it can be used after connecting:

```js
async function pickLamp() {
  try {
    return await navigator.bluetooth.requestDevice({
      filters: [{ name: 'Desk Lamp' }, { namePrefix: 'LAMP-' }],   // either one matches
      optionalServices: ['0000ffe0-0000-1000-8000-00805f9b34fb']
    });
  } catch (err) {
    if (err.name === 'SecurityError') showStatus('Allow Nearby devices to find your lamp.');
    else if (err.name !== 'NotFoundError') showStatus(err.message);  // NotFoundError = cancelled
    return null;
  }
}
```

Ask for the device once per session and reuse it on later taps (`getDevices()` is not available, so keep the object you got):

```js
var scale = null;

document.getElementById('weigh').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    if (!scale) {
      scale = await navigator.bluetooth.requestDevice({
        filters: [{ services: ['weight_scale'], namePrefix: 'MI' }]   // both keys must match
      });
    }
    var server = await scale.gatt.connect();
    var svc = await server.getPrimaryService('weight_scale');
    var chr = await svc.getCharacteristic('weight_measurement');
    chr.addEventListener('characteristicvaluechanged', onWeight);
    await chr.startNotifications();
  } catch (err) {
    showStatus(err.name + ': ' + err.message);
  }
});
```

**Notes:** Raw signature `wbRequestDevice(requestId, optionsJson)`; the answer comes back to the polyfill, not to the page. Call `requestDevice` from a tap. Filters use `services` (must be advertised), `name` (exact) and `namePrefix`; a filter matches when all its keys match, and the device matches when any filter matches. Neither `filters` nor `acceptAllDevices` → `TypeError`. Cancelling the chooser rejects with `NotFoundError`. The chooser scans for 30 seconds. `device.id` is the device's Bluetooth address.

### `wbWrite`

```js
window.WebToApk.wbWrite(
```

*(Web Bluetooth (navigator.bluetooth))* Raw transport for WebBluetoothPolyfill. A page never calls these directly — it calls navigator.bluetooth, exactly as it would in a browser.

**Example**

Internal transport behind `characteristic.writeValue()`, `writeValueWithResponse()` and `writeValueWithoutResponse()`. Pages use `navigator.bluetooth`; do not call this directly.

**Returns:** (through the characteristic) a Promise of `undefined`. **Needs:** Bluetooth (turned on by the build when your code mentions `navigator.bluetooth`).

**With response** (`writeValue` is the same): resolves when the device confirms the write.

```js
async function setTargetPower(server, watts) {
  var ftms = await server.getPrimaryService('fitness_machine');
  var cp = await ftms.getCharacteristic('00002ad9-0000-1000-8000-00805f9b34fb'); // FTMS control point
  await cp.writeValueWithResponse(new Uint8Array([0x00]));                       // request control
  var cmd = new DataView(new ArrayBuffer(3));
  cmd.setUint8(0, 0x05);                 // set target power
  cmd.setInt16(1, watts, true);
  await cp.writeValueWithResponse(cmd);
}
```

**Without response:** faster, for streams where a lost packet does not matter.

```js
async function sendText(characteristic, text) {
  if (!characteristic.properties.writeWithoutResponse) throw new Error('This characteristic needs writeValue');
  await characteristic.writeValueWithoutResponse(new TextEncoder().encode(text));
}
```

Send a long text command in 20-byte pieces. The app does not ask the device for a larger packet size, so 20 bytes is what fits in one write:

```js
async function sendLine(rx, text) {
  var bytes = new TextEncoder().encode(text + '\n');
  for (var i = 0; i < bytes.length; i += 20) {
    await rx.writeValueWithoutResponse(bytes.subarray(i, i + 20));
  }
}
// sendLine(rxCharacteristic, 'SET COLOR 255 120 0 BRIGHTNESS 80');
```

**Notes:** Raw signature `wbWrite(requestId, deviceId, service, characteristic, valueBase64, withResponse)`. The value can be an `ArrayBuffer`, any typed array, a `DataView` or a plain array of bytes; anything else throws `TypeError`. Errors: `NotSupportedError` (write rejected), `NetworkError` (write failed or not connected), `NotFoundError`.

### `widgetClear`

```js
window.WebToApk.widgetClear()
```

Removes the card; placed widgets go back to their empty state.

**Example**

Internal plumbing behind `AppMint.widget.clear()` — removes the widget's card; every placed widget goes back to "Nothing to show yet".

**Returns:** nothing. **Needs:** nothing.

The public API first:

```js
async function onSignOut() {
  if (window.AppMint && AppMint.widget) await AppMint.widget.clear();
}
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.widgetClear === 'function') {
  window.WebToApk.widgetClear();
}
```

Tie the widget to a "Show on home screen" switch in your settings — off empties it, on fills it again:

```js
document.getElementById('widget-toggle').onchange = async function (e) {
  if (!(window.AppMint && AppMint.widget)) return;
  if (e.target.checked) {
    await AppMint.widget.set(buildWidgetCard()); // your own card builder
  } else {
    await AppMint.widget.clear();
  }
};
```

After deleting the account, empty the widget and tell the user if a copy is still on their home screen:

```js
function afterAccountDeleted() {
  var W = window.WebToApk;
  if (!W || typeof W.widgetClear !== 'function') return;
  W.widgetClear();
  if (W.widgetPlacedCount() > 0) {
    showMessage('Your widget is now empty. You can remove it from your home screen.');
  }
}
```

**Notes:** The widget stays on the home screen; only its content is removed. Call `AppMint.widget.set()` to fill it again.

### `widgetPlacedCount`

```js
window.WebToApk.widgetPlacedCount(): Int
```

How many copies of the widget the user has placed on the home screen.

**Example**

Internal plumbing behind `AppMint.widget.placedCount()` — how many copies of your widget the user has put on the home screen.

**Returns:** a number, synchronously (`0` when none). **Needs:** nothing.

The public API first — show an "Add widget" button only when the widget is not placed yet:

```js
async function showAddWidgetButton() {
  var btn = document.getElementById('add-widget');
  if (!(window.AppMint && AppMint.widget)) { btn.hidden = true; return; }
  btn.hidden = (await AppMint.widget.placedCount()) > 0;
}
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.widgetPlacedCount === 'function') {
  var n = window.WebToApk.widgetPlacedCount();
  console.log('Widgets on the home screen: ' + n);
}
```

Check again each time the user comes back from the launcher, and thank them once the widget is there:

```js
document.addEventListener('visibilitychange', async function () {
  if (document.visibilityState !== 'visible') return;
  if (!(window.AppMint && AppMint.widget)) return;
  var n = await AppMint.widget.placedCount();
  document.getElementById('widget-promo').hidden = n > 0;
  if (n > 0) document.getElementById('widget-thanks').hidden = false;
});
```

Show the widget's status on a settings page with the synchronous raw call:

```js
var status = document.getElementById('widget-status');
if (window.WebToApk && typeof window.WebToApk.widgetPlacedCount === 'function') {
  var n = window.WebToApk.widgetPlacedCount();
  status.textContent = n === 0 ? 'Not on your home screen'
    : n === 1 ? 'On your home screen' : 'On your home screen ' + n + ' times';
} else {
  status.textContent = 'Available in the installed app';
}
```

**Notes:** Check it again when the app comes back to the front (`visibilitychange`), since the user adds widgets from the launcher, outside your app.

### `widgetRequestPin`

```js
window.WebToApk.widgetRequestPin(): Boolean
```

Opens the launcher's "Add to home screen" sheet for the widget (Android 8+). False where the launcher cannot.

**Example**

Internal plumbing behind `AppMint.widget.requestPin()` — opens the launcher's "Add to home screen" sheet for your widget.

**Returns:** `true` if the sheet opened, `false` where the launcher cannot (synchronously). **Needs:** Android 8+ and a launcher that supports pinning widgets.

The public API first, from a button tap:

```js
document.getElementById('add-widget').onclick = async function () {
  if (!(window.AppMint && AppMint.widget)) return;
  var opened = await AppMint.widget.requestPin();
  if (!opened) {
    alert('Touch and hold your home screen, tap Widgets, then pick this app.');
  }
};
```

The raw call:

```js
if (window.WebToApk && typeof window.WebToApk.widgetRequestPin === 'function') {
  if (!window.WebToApk.widgetRequestPin()) showManualSteps();
}
```

Fill the card first, then open the sheet, so the widget shows real content the moment it lands:

```js
document.getElementById('add-widget').onclick = async function () {
  if (!(window.AppMint && AppMint.widget)) return;
  await AppMint.widget.set({ title: 'Streak', value: '12 days', lines: ['Best 30'], route: '/streak' });
  if (!(await AppMint.widget.requestPin())) {
    document.getElementById('widget-help').hidden = false; // manual steps
  }
};
```

Find out whether the user accepted: compare the placed count before the sheet and after they come back:

```js
document.getElementById('add-widget').onclick = function () {
  var W = window.WebToApk;
  if (!W || typeof W.widgetRequestPin !== 'function') return;
  var before = W.widgetPlacedCount();
  if (!W.widgetRequestPin()) { showManualSteps(); return; }
  document.addEventListener('visibilitychange', function check() {
    if (document.visibilityState !== 'visible') return;
    document.removeEventListener('visibilitychange', check);
    if (W.widgetPlacedCount() > before) showToast('Widget added');
  });
};
```

**Notes:** `true` only means the sheet opened — the user can still cancel it. Use `AppMint.widget.placedCount()` later to see whether it was added. Fill the card with `AppMint.widget.set()` first, so the new widget is not empty.

### `widgetSet`

```js
window.WebToApk.widgetSet(cardJson: String): String
```

Saves the widget card [cardJson] `{title?, value?, lines?, route?, bg?, ink?, accent?}` and redraws every placed widget. Returns `{ok, placed}`.

**Example**

Internal plumbing behind `AppMint.widget.set(card)` — pages use that helper to fill the home-screen widget.

**Returns:** a JSON string `{"ok":true,"placed":<number>}` or `{"ok":false,"error":"…"}` (synchronously). **Needs:** nothing — every build has the widget.

The public API first:

```js
if (window.AppMint && AppMint.widget) {
  AppMint.widget.set({ title: 'Steps', value: '8 214', lines: ['Goal 10 000'], route: '/' })
    .then(function (r) { if (!r.ok) console.log(r.error); });
}
```

The raw call takes the card as a JSON string and returns JSON text:

```js
if (window.WebToApk && typeof window.WebToApk.widgetSet === 'function') {
  var card = { title: 'Steps', value: '8 214', lines: ['Goal 10 000'], route: '/',
               bg: '#15161A', ink: '#FFFFFF', accent: '#22D3EE' };
  var r = JSON.parse(window.WebToApk.widgetSet(JSON.stringify(card)));
  console.log(r.ok ? 'on ' + r.placed + ' home screen(s)' : r.error);
}
```

Keep the widget in step with a to-do list — the open count as the value, the first six tasks as lines, and a tap opens the tasks screen:

```js
async function syncTaskWidget(tasks) {
  if (!(window.AppMint && AppMint.widget)) return;
  var open = tasks.filter(function (t) { return !t.done; });
  var r = await AppMint.widget.set({
    title: 'To do',
    value: open.length ? open.length + ' left' : 'All done',
    lines: open.slice(0, 6).map(function (t) { return '• ' + t.title; }),
    route: '/tasks'
  });
  if (!r.ok) console.warn('Widget not updated: ' + r.error);
}
```

Write the latest state when the user leaves the app; the raw call is synchronous, so it finishes before the page is paused:

```js
document.addEventListener('visibilitychange', function () {
  if (document.visibilityState !== 'hidden') return;
  if (!window.WebToApk || typeof window.WebToApk.widgetSet !== 'function') return;
  var s = getTodayStats(); // your app's own numbers
  window.WebToApk.widgetSet(JSON.stringify({
    title: 'Today', value: s.spent + ' spent',
    lines: ['Budget ' + s.budget, 'Left ' + (s.budget - s.spent)], route: '/budget'
  }));
});
```

**Notes:** Card keys: `title`, `value`, `lines` (up to six), `route` (a `/` is added if missing), `bg`, `ink`, `accent`. Every placed widget redraws at once. `placed` is `0` when the user has not added the widget yet — the card is still saved and appears when they do.

### `workCancel`

```js
window.WebToApk.workCancel(id: String)
```

*(Background jobs (WorkManager): durable auto-save + periodic native sync)* No permission needed; results echo as `appmint:work` / window.onAppMintWork.

**Example**

Cancels a background job (one-off or repeating) by its id and deletes its saved request.

**Returns:** nothing directly. The `appmint:work` event (and `window.onAppMintWork`) answers `{ id, status: 'cancelled' }`. **Needs:** nothing.

```js
function stopSync() {
  if (!(window.WebToApk && window.WebToApk.workCancel)) return;
  WebToApk.workCancel('sync');
}

window.addEventListener('appmint:work', function (e) {
  if (e.detail.id === 'sync' && e.detail.status === 'cancelled') {
    document.getElementById('sync-status').textContent = 'Background sync is off';
  }
});
```

Throw away a draft: cancel its auto-save that has not been sent yet, then clear the editor once Android confirms:

```js
function discardDraft() {
  if (!(window.WebToApk && window.WebToApk.workCancel)) { clearEditor(); return; }
  function onWork(e) {
    if (e.detail.id !== 'autosave' || e.detail.status !== 'cancelled') return;
    window.removeEventListener('appmint:work', onWork);
    clearEditor();
  }
  window.addEventListener('appmint:work', onWork);
  WebToApk.workCancel('autosave');
}
```

On sign-out, cancel every job this app has, using the ids that workList reports:

```js
function cancelAllJobs() {
  if (!(window.WebToApk && window.WebToApk.workList && window.WebToApk.workCancel)) return;
  const requestId = 'signout-' + Date.now();
  function onWork(e) {
    if (String(e.detail.requestId) !== requestId) return;
    window.removeEventListener('appmint:work', onWork);
    (e.detail.jobs || []).forEach(function (job) {
      WebToApk.workCancel(job.id);                      // each answers { id, status: 'cancelled' }
    });
  }
  window.addEventListener('appmint:work', onWork);
  WebToApk.workList(requestId);
}
```

**Notes:** it answers `cancelled` even when no job had that id. An auto-save that has not been sent yet is dropped, so cancel only when you really do not want it delivered.

### `workEnqueueOnline`

```js
window.WebToApk.workEnqueueOnline(id: String, url: String, method: String, headersJson: String, payload: String)
```

*(Background jobs (WorkManager): durable auto-save + periodic native sync)* No permission needed; results echo as `appmint:work` / window.onAppMintWork.

**Example**

Hands one HTTP request to Android to send when the phone is online — even if the user closes the app or restarts the phone. Use it for a reliable auto-save.

**Returns:** nothing directly. The answer comes as the `appmint:work` event (and `window.onAppMintWork(detail)` if you define it): `{ id, status: 'enqueued' }`, or `{ id, status: 'failed', reason }` with reason `invalid_arguments` (empty id or a URL not starting with `http`) or `enqueue_failed`. **Needs:** nothing.

**Durable auto-save:** the same id replaces a save that has not been sent yet, so the newest state wins.

```js
function autoSave(state, token) {
  if (!(window.WebToApk && window.WebToApk.workEnqueueOnline)) {
    showMessage('Offline auto-save works in the installed app only.');
    return;
  }
  WebToApk.workEnqueueOnline(
    'autosave',                                        // your job id
    'https://api.example.com/save',                    // http(s) URL
    'POST',                                            // POST | PUT | GET ...
    JSON.stringify({ Authorization: 'Bearer ' + token }), // headers as a JSON string
    JSON.stringify(state)                              // body as a string ('' for none)
  );
}

window.addEventListener('appmint:work', function (e) {
  const d = e.detail;
  if (d.id !== 'autosave') return;
  if (d.status === 'enqueued') showSavedBadge('Will sync when online');
  if (d.status === 'failed') showMessage('Could not queue the save: ' + d.reason);
});
```

Queue every form submission as its own job (a unique id per submission, so a second one does not replace the first) and wait for Android's answer:

```js
function queueFeedback(form) {
  return new Promise(function (resolve, reject) {
    if (!(window.WebToApk && window.WebToApk.workEnqueueOnline)) {
      reject(new Error('not_in_app'));
      return;
    }
    const id = 'feedback-' + Date.now();
    function onWork(e) {
      if (e.detail.id !== id) return;                  // another job's answer
      window.removeEventListener('appmint:work', onWork);
      if (e.detail.status === 'enqueued') resolve(id);
      else reject(new Error(e.detail.reason));         // invalid_arguments | enqueue_failed
    }
    window.addEventListener('appmint:work', onWork);
    WebToApk.workEnqueueOnline(id, 'https://api.example.com/feedback', 'POST', '{}',
      JSON.stringify({ rating: form.rating.value, text: form.text.value }));
  });
}
```

Save a note as plain text with PUT; setting your own Content-Type stops the JSON default from being added:

```js
function saveNote(noteId, text) {
  if (!(window.WebToApk && window.WebToApk.workEnqueueOnline)) return;
  WebToApk.workEnqueueOnline(
    'note-' + noteId,                                  // same note, same id: only the newest text is sent
    'https://api.example.com/notes/' + encodeURIComponent(noteId),
    'PUT',
    JSON.stringify({ 'Content-Type': 'text/plain; charset=utf-8' }),
    text
  );
}

document.getElementById('note').addEventListener('change', function (e) {
  saveNote(e.target.dataset.noteId, e.target.value);
});
```

**Notes:** `enqueued` means Android accepted the job, not that the server got it; there is no event when it is delivered. Delivery rules: a 2xx reply finishes the job; a 4xx reply stops it (no retry); a network error or 5xx retries with growing delays, up to 8 tries. `Content-Type: application/json` is added unless you set one. A `GET` sends no body. The page's JavaScript does not run in the background — only this native request does.

### `workList`

```js
window.WebToApk.workList(requestId: String)
```

*(Background jobs (WorkManager): durable auto-save + periodic native sync)* No permission needed; results echo as `appmint:work` / window.onAppMintWork.

**Example**

Lists the background jobs this app has queued or scheduled.

**Returns:** nothing directly. The `appmint:work` event (and `window.onAppMintWork`) answers `{ requestId, jobs: [{ id, periodic, url, createdAt, pendingDelivery }] }`. `pendingDelivery` is `true` while a one-off job has not been delivered yet (and always for a periodic job). **Needs:** nothing.

**A small Promise wrapper that matches the answer by `requestId`:**

```js
function listJobs() {
  return new Promise(function (resolve) {
    if (!(window.WebToApk && window.WebToApk.workList)) { resolve([]); return; }
    const requestId = 'jobs-' + Date.now();
    function onWork(e) {
      if (String(e.detail.requestId) !== requestId) return;   // not our answer
      window.removeEventListener('appmint:work', onWork);
      resolve(e.detail.jobs || []);
    }
    window.addEventListener('appmint:work', onWork);
    WebToApk.workList(requestId);
  });
}

listJobs().then(function (jobs) {
  const unsent = jobs.filter(function (j) { return !j.periodic && j.pendingDelivery; });
  document.getElementById('unsent').textContent =
    unsent.length ? unsent.length + ' change(s) waiting for network' : 'All changes saved';
});
```

Show every job on a settings screen, with when it was queued:

```js
window.addEventListener('appmint:work', function (e) {
  if (e.detail.requestId !== 'settings-jobs') return;
  const list = document.getElementById('jobs');
  list.innerHTML = '';
  (e.detail.jobs || []).forEach(function (job) {
    const li = document.createElement('li');
    li.textContent = job.id + (job.periodic ? ' (repeats)' : job.pendingDelivery ? ' (waiting)' : '') +
      ' - since ' + new Date(job.createdAt).toLocaleString();
    list.appendChild(li);
  });
});

if (window.WebToApk && window.WebToApk.workList) WebToApk.workList('settings-jobs');
```

When the app opens, set a "Background sync" switch from whether the repeating 'sync' job is still registered:

```js
window.addEventListener('appmint:work', function (e) {
  if (e.detail.requestId !== 'startup') return;
  const on = (e.detail.jobs || []).some(function (j) { return j.id === 'sync' && j.periodic; });
  document.getElementById('sync-toggle').checked = on;
});

document.addEventListener('DOMContentLoaded', function () {
  if (window.WebToApk && window.WebToApk.workList) WebToApk.workList('startup');
});
```

**Notes:** a one-off job leaves the list once it is delivered, rejected with a 4xx, or given up after 8 tries. A numeric `requestId` may come back as a number, so compare with `String(...)`.

### `workSchedulePeriodic`

```js
window.WebToApk.workSchedulePeriodic(id: String, url: String, method: String, headersJson: String, payload: String, intervalMinutes: Int)
```

*(Background jobs (WorkManager): durable auto-save + periodic native sync)* No permission needed; results echo as `appmint:work` / window.onAppMintWork.

**Example**

Schedules a repeating HTTP request that Android sends in the background (when online), even while the app is closed. Good for a sync trigger or a heartbeat.

**Returns:** nothing directly. The answer comes as the `appmint:work` event (and `window.onAppMintWork(detail)`): `{ id, status: 'scheduled', intervalMinutes }`, or `{ id, status: 'failed', reason }` with reason `invalid_arguments` or `schedule_failed`. **Needs:** nothing.

```js
function startBackgroundSync(userId) {
  if (!(window.WebToApk && window.WebToApk.workSchedulePeriodic)) {
    showMessage('Background sync works in the installed app only.');
    return;
  }
  WebToApk.workSchedulePeriodic(
    'sync',                                   // job id (scheduling again updates it)
    'https://api.example.com/sync',
    'POST',
    JSON.stringify({ 'X-User': userId }),     // headers as a JSON string
    JSON.stringify({ userId: userId }),       // the same body is sent every time
    30                                        // minutes; values under 15 become 15
  );
}

// Optional page hook instead of addEventListener:
window.onAppMintWork = function (d) {
  if (d.id === 'sync' && d.status === 'scheduled') {
    console.log('sync every', d.intervalMinutes, 'minutes');
  }
  if (d.status === 'failed') console.warn('job', d.id, 'failed:', d.reason);
};
```

A once-an-hour heartbeat: a GET sends no body, so pass an empty string, and '{}' when you need no headers:

```js
function startHeartbeat(deviceId) {
  if (!(window.WebToApk && window.WebToApk.workSchedulePeriodic)) return;
  WebToApk.workSchedulePeriodic(
    'heartbeat',
    'https://api.example.com/ping?device=' + encodeURIComponent(deviceId),
    'GET',
    '{}',
    '',
    60
  );
}
```

Let the user pick how often to sync, and show the interval Android actually uses (anything under 15 comes back as 15):

```js
document.getElementById('sync-every').addEventListener('change', function (e) {
  if (!(window.WebToApk && window.WebToApk.workSchedulePeriodic)) return;
  const minutes = parseInt(e.target.value, 10);        // e.g. 5, 30, 120
  WebToApk.workSchedulePeriodic('sync', 'https://api.example.com/sync', 'POST', '{}',
    JSON.stringify({ reason: 'periodic' }), minutes);  // same id: updates the existing job
});

window.addEventListener('appmint:work', function (e) {
  const d = e.detail;
  if (d.id !== 'sync') return;
  if (d.status === 'scheduled') {
    document.getElementById('sync-note').textContent = 'Syncing about every ' + d.intervalMinutes + ' minutes';
  } else if (d.status === 'failed') {
    document.getElementById('sync-note').textContent = 'Could not schedule sync (' + d.reason + ')';
  }
});
```

**Notes:** Android decides the exact moment; 15 minutes is the shortest interval, and battery saving can delay runs. The body is fixed when you schedule; to change it, schedule again with the same id. A 4xx reply does not cancel a periodic job; the next run tries again. Stop it with `workCancel('sync')`.

### `writeFileBase64`

```js
window.WebToApk.writeFileBase64(pathOrUri: String, base64: String, offset: Long, mode: String): String
```

Writes raw bytes and confirms how many landed. @param mode `"patch"` writes at [offset] leaving the rest of the file intact (what a multi-pass overwrite needs), `"truncate"` replaces the file, `"append"` ignores [offset] and writes at the end. @return JSON `{ok, bytesWritten, offset, size, error}` — `size` is re-read from the file after an fsync, so a caller can verify the write for real rather than trust a boolean.

**Example**

Writes base64 bytes into a file at an offset (`patch`), over the whole file (`truncate`), or at the end (`append`). Pages normally use `WebToApkFS.writeBytes(pathOrUri, base64, offset, mode)` or `WebToApkFS.writeAll(pathOrUri, base64)`.

**Returns:** (sync) a JSON **string** `{ok, bytesWritten, offset, size}` (`size` re-read after an fsync) or `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

The public way:

```js
const r = WebToApkFS.writeBytes(fileUri, btoa('hello'), 0, 'truncate');
if (r.ok) console.log('saved', r.bytesWritten, 'bytes');
```

The raw call:

```js
if (window.WebToApk && typeof WebToApk.writeFileBase64 === 'function') {
  const r = JSON.parse(WebToApk.writeFileBase64('notes/today.txt', btoa('Buy milk\n'), 0, 'append'));
  if (!r.ok) console.log('write failed:', r.error);
  else console.log('file is now', r.size, 'bytes');
}
```

Save a canvas drawing as a PNG into a file the user picked, replacing what was there:

```js
document.querySelector('#save').addEventListener('click', async function () {
  const f = await WebToApkFS.pickFile('image/png');
  if (!f.ok) return;
  const base64 = document.querySelector('canvas').toDataURL('image/png').split(',')[1];
  const r = WebToApkFS.writeAll(f.uri, base64);   // truncates, then writes in 4 MB chunks
  if (r.ok) console.log('saved', r.bytesWritten, 'bytes to', f.name);
  else alert('Save failed: ' + r.error);
});
```

Overwrite a file in place with zeros before deleting it, and confirm every pass landed:

```js
function zeroFill(fileUri) {
  const info = WebToApkFS.stat(fileUri);
  if (!info.ok) return false;
  const slice = 32768;
  for (let at = 0; at < info.size; at += slice) {
    const len = Math.min(slice, info.size - at);
    const zeros = btoa(String.fromCharCode.apply(null, new Uint8Array(len)));
    const w = WebToApkFS.writeBytes(fileUri, zeros, at, 'patch');   // the rest of the file is kept
    if (!w.ok || w.bytesWritten !== len) { console.log('write failed at', at, w.error); return false; }
  }
  return true;   // each call is fsync'ed before it answers
}
```

**Notes:** Mode is `'patch'` (default when empty), `'truncate'` or `'append'`. One call takes at most 4 MB of decoded bytes (`getMaxIoChunkSize()`). A relative path is created inside the granted folder when missing. Errors: `disabled`, `foreign_origin` (the page on screen is not the app's own), `bad mode: x`, `offset must be >= 0`, `bad base64`, `chunk too large (max N bytes)`, `not-found`, `cannot-open`, `short write`.

### `writeTextFile`

```js
window.WebToApk.writeTextFile(relativePath: String, content: String): String
```

*(Native Folder Access (SAF) Bridge)* Play-safe folder access using ACTION_OPEN_DOCUMENT_TREE with persistable URI permissions.  JavaScript usage:   window.WebToApk.requestFolderAccess('myRequestId')   window.WebToApk.hasFolderAccess()                // "true" / "false"   window.WebToApk.getFolderAccessUri()             // "content://..." or ""   window.WebToApk.clearFolderAccess()              // releases persisted access  Callback from Android → JS:   window.WebToApkOnFolderAccessResult(success, folderUri, requestId, error) `error` (4th argument, "" on success) says why a request failed: "disabled", "foreign_origin", "busy" (a picker is already open for an earlier request — that one still gets its own answer), "cancelled", "not_persisted", "no_picker".

**Example**

Writes a text file (UTF-8) into the granted folder, creating it or replacing its content.

**Returns:** the STRING `"true"` or `"false"`, synchronously. `WebToApkFS.writeText(path, text)` returns a real boolean. **Needs:** **Native Folder Access (SAF)** ticked in Step 4 (Access) and a granted folder.

With the helper:

```js
function saveNote(path, text) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) {
    alert('Choose a folder first.');
    return false;
  }
  var ok = window.WebToApkFS.writeText(path, text);   // missing parent folders are created
  if (!ok) alert('Could not save ' + path);
  return ok;
}

saveNote('notes/2026/today.txt', document.getElementById('editor').value);
```

The raw call, whose answer is text:

```js
if (window.WebToApk && typeof window.WebToApk.writeTextFile === 'function') {
  var saved = window.WebToApk.writeTextFile('data/settings.json', JSON.stringify({ theme: 'dark' })) === 'true';
}
```

Append a line to a daily log — writing replaces the file, so read it first:

```js
function appendLog(line) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var now = new Date().toISOString();
  var path = 'logs/' + now.slice(0, 10) + '.log';          // one file per day
  var old = window.WebToApkFS.readText(path);              // '' the first time
  return window.WebToApkFS.writeText(path, old + now + ' ' + line + '\n');
}

appendLog('Order #1042 marked as shipped');
```

**Notes:** An existing file is replaced completely. It fails when the path is an existing folder, contains `..`, or no folder is granted. The file type comes from the extension (`.json`, `.txt`, `.md`…), so the name you give is the name on disk.

## Helpers the app injects

Promise-based objects the shell adds on top of the raw bridge. Prefer these where they exist.

### `AppMintAds.appOpen`

**Example**

Pauses or resumes the app-open ad, the full-screen ad shown at launch and when the user returns from another app.

**Returns:** nothing. **Needs:** **Ad Control → Developer-controlled** and an app-open ad unit ID.

Pause it while the user leaves the app on purpose for a short moment (a payment app, the camera, a share sheet), so coming back does not show an ad:

```js
var ads = window.AppMintAds;

async function payWithUpi(link) {
  if (ads) ads.appOpen.setEnabled(false);
  try {
    await openPaymentApp(link);           // your own code; the user switches apps and returns
  } finally {
    if (ads) ads.appOpen.setEnabled(true);
  }
}
```

Check whether the build has an app-open unit at all:

```js
var hasAppOpen = !!(window.AppMintAds && window.AppMintAds.config().formats.app_open);
```

Pause it while the user picks a photo, since the system photo picker takes them out of the app for a moment:

```js
var ads = window.AppMintAds;
var input = document.getElementById('photo-input');   // <input type="file" accept="image/*" hidden>
var pausedForPicker = false;

document.getElementById('pick-photo').addEventListener('click', function () {
  if (ads) { ads.appOpen.setEnabled(false); pausedForPicker = true; }
  input.click();
});

input.addEventListener('change', function () {
  if (input.files[0]) uploadPhoto(input.files[0]);      // your own code
});

// Back in the app (a photo was picked or the picker was cancelled): turn it on again.
document.addEventListener('visibilitychange', function () {
  if (document.hidden || !pausedForPicker) return;
  pausedForPicker = false;
  setTimeout(function () { ads.appOpen.setEnabled(true); }, 1000);
});
```

**Notes:** It is on by default. At cold start the ad shows when the page is ready, or when it finishes loading within 15 seconds; a later load is kept for next time. An app-open ad older than 4 hours is never shown. You can also show one yourself with `AppMintAds.show('app_open')`.

### `AppMintAds.banner`

**Example**

Places a banner or a 300×250 medium-rectangle ad anywhere in your page, on top of an element you choose.

**Returns:** `attach` returns a Promise of `{ok:true, slot, width, height}` (CSS px) once the ad has loaded, or `{ok:false, slot?, code, message?}`. `detach` returns nothing. **Needs:** **Ad Control → Developer-controlled** and a banner (or medium rectangle) ad unit ID.

The element only reserves the space. The ad is drawn over it and follows it when the page scrolls or resizes:

```html
<div id="ad-slot"></div>

<script>
var ads = window.AppMintAds;
var slotEl = document.getElementById('ad-slot');

if (ads) {
  ads.banner.attach(slotEl, { size: 'banner' }).then(function (res) {
    // On success the helper sets slotEl.style.minHeight to res.height (pass keepHeight:true to stop that).
    if (!res.ok) {                         // no_fill, network, too_many_slots, ...
      if (res.slot) ads.banner.detach(res.slot);   // a failed slot still counts toward the limit of two
      slotEl.hidden = true;
    }
  });
} else {
  slotEl.hidden = true;                    // browser: no ads here
}
</script>
```

**Medium rectangle** (one at a time), and removing a banner again:

```js
var box = document.querySelector('.article-end-ad');
var mrecSlot = null;

if (window.AppMintAds) {
  window.AppMintAds.banner.attach(box, { size: 'medium_rectangle' }).then(function (res) {
    if (res.ok) mrecSlot = res.slot;
    else { if (res.slot) window.AppMintAds.banner.detach(res.slot); box.hidden = true; }
  });
}

function leaveArticle() {
  if (window.AppMintAds && mrecSlot) window.AppMintAds.banner.detach(mrecSlot);   // or detach(box)
  mrecSlot = null;
}
```

Try a slot again later when no ad was available, freeing the failed slot first so it does not use up one of the two:

```js
function fillSlot(el, triesLeft) {
  var ads = window.AppMintAds;
  if (!ads) { el.hidden = true; return; }             // browser: no ads here
  ads.banner.attach(el, { size: 'banner' }).then(function (res) {
    if (res.ok) return;
    if (res.slot) ads.banner.detach(res.slot);
    if ((res.code === 'no_fill' || res.code === 'network') && triesLeft > 0) {
      setTimeout(function () { fillSlot(el, triesLeft - 1); }, 60000);
    } else {
      el.hidden = true;                                 // ad_free, disabled, consent, ...
    }
  });
}

fillSlot(document.getElementById('footer-ad'), 2);
```

**Notes:** `target` is an element or a CSS selector; `size` is `'banner'` (default, adaptive width, at most 90 dp tall), `'medium_rectangle'` or `'mrec'`. At most two slots at once, and a slot whose ad failed to load still counts until you detach it. `detach` needs the element or the slot id from `attach`, not a selector. Removing the element from the DOM detaches it too. Codes: `no_element`, `duplicate_slot`, `too_many_slots`, `detached`, `no_fill`, `network`, `invalid_unit`, `ad_free`, `disabled`, `consent`, `automatic_mode`, `internal`.

### `AppMintAds.config`

**Example**

Tells the page which ad control mode the build uses and which ad formats it can serve.

**Returns:** an object, synchronously: `{control: 'automatic' | 'developer', formats: {banner, mrec, interstitial, rewarded, rewarded_interstitial, app_open}}` (each format `true` / `false`). On an internal error it returns `{control:'automatic', formats:{}}`. **Needs:** nothing; `window.AppMintAds` exists only inside the installed app.

Show only the ad UI this build can really serve:

```js
var ads = window.AppMintAds;               // undefined in a browser
var cfg = ads ? ads.config() : null;

var developer = !!(cfg && cfg.control === 'developer');
document.getElementById('watch-ad').hidden = !(developer && cfg.formats.rewarded);
document.getElementById('ad-slot').hidden  = !(developer && cfg.formats.banner);
```

Pick the rewarded format the build has for a "bonus" button, and hide the button when it has neither:

```js
var ads = window.AppMintAds;
var cfg = ads ? ads.config() : null;
var bonusBtn = document.getElementById('bonus');

var bonusType = null;
if (cfg && cfg.control === 'developer') {
  if (cfg.formats.rewarded) bonusType = 'rewarded';
  else if (cfg.formats.rewarded_interstitial) bonusType = 'rewarded_interstitial';
}
bonusBtn.hidden = !bonusType;

bonusBtn.addEventListener('click', async function () {
  var res = await ads.show(bonusType);                  // inside the tap: plays at once
  if (res.earned) addCoins(res.reward.amount);
});
```

Choose the ad size for the end of an article: a medium rectangle when the build has one, otherwise a banner:

```js
var ads = window.AppMintAds;
var cfg = ads ? ads.config() : null;
var box = document.getElementById('article-end-ad');

var size = null;
if (cfg && cfg.control === 'developer') {
  size = cfg.formats.mrec ? 'medium_rectangle' : (cfg.formats.banner ? 'banner' : null);
}

if (!size) box.hidden = true;
else ads.banner.attach(box, { size: size }).then(function (res) {
  if (!res.ok) { if (res.slot) ads.banner.detach(res.slot); box.hidden = true; }
});
```

**Notes:** A format is `true` when ads are on and the build has an ad unit ID for it. In Automatic mode (`control: 'automatic'`) every `load` / `show` / `banner.attach` call answers `code: 'automatic_mode'`; use `WebToApk.showRewardedAd()` / `showInterstitialAd()` there. Choose **Ad Control → Developer-controlled** in the build wizard to drive ads from the page.

### `AppMintAds.isReady`

**Example**

Tells you whether a full-screen ad of this type is loaded and can be shown right now.

**Returns:** `true` / `false`, synchronously. `type`: `'interstitial'`, `'rewarded'`, `'rewarded_interstitial'` or `'app_open'`. **Needs:** **Ad Control → Developer-controlled** and an ad unit ID for the type.

Keep a "Watch ad" button in step with the ad, using the `appmint:ad` status event:

```js
var ads = window.AppMintAds;
var btn = document.getElementById('watch-ad');

function refresh() { btn.disabled = !(ads && ads.isReady('rewarded')); }

if (ads) {
  refresh();
  window.addEventListener('appmint:ad', function (e) {
    if (e.detail.type === 'rewarded') refresh();    // phases: loaded, failed, closed, ...
  });
} else {
  btn.hidden = true;                                // browser: no ads here
}
```

Offer "double your coins" on the results screen only when a rewarded ad is already waiting:

```js
function showResults(coins) {
  var ads = window.AppMintAds;
  var offer = document.getElementById('double-coins');
  renderResults(coins);                                 // your own screen
  offer.hidden = !(ads && ads.isReady('rewarded'));
  offer.onclick = async function () {
    offer.hidden = true;
    var res = await ads.show('rewarded');               // inside the tap: plays at once
    addCoins(res.earned ? coins : 0);                   // the bonus: the same amount again
  };
}
```

In a hint button, play whichever rewarded format is loaded right now:

```js
document.getElementById('hint').addEventListener('click', async function () {
  var ads = window.AppMintAds;
  if (!ads) return;                                     // browser: no ads here
  var type = ads.isReady('rewarded') ? 'rewarded'
           : ads.isReady('rewarded_interstitial') ? 'rewarded_interstitial'
           : null;
  if (!type) { showMessage('No hint video right now. Try again in a minute.'); return; }
  var res = await ads.show(type);
  if (res.earned) revealHint();
});
```

**Notes:** `banner` and `mrec` always answer `false` (they are attached, not shown). An interstitial in its 60-second cooldown answers `false`; an app-open ad older than 4 hours answers `false`. In Automatic mode this is always `false`.

### `AppMintAds.load`

**Example**

Loads a full-screen ad so that a later `show(type)` can play it at once.

**Returns:** a Promise that resolves (never rejects) with `{ok:true}`, `{ok:true, cached:true}` when one is already waiting, or `{ok:false, code, reason, message?, adCode?}`. **Needs:** **Ad Control → Developer-controlled** and an ad unit ID for the type (`interstitial`, `rewarded`, `rewarded_interstitial`, `app_open`).

```js
var ads = window.AppMintAds;
var btn = document.getElementById('watch-ad');
btn.disabled = true;

async function prepareRewarded() {
  if (!ads) { btn.hidden = true; return; }          // browser: no ads
  var res = await ads.load('rewarded');
  if (res.ok) { btn.disabled = false; return; }
  if (res.code === 'no_fill' || res.code === 'network') setTimeout(prepareRewarded, 30000);
  else console.log('rewarded ad unavailable:', res.code, res.message);
}
prepareRewarded();
```

The whole life of every request also streams on the `appmint:ad` event:

```js
window.addEventListener('appmint:ad', function (e) {
  var a = e.detail;   // {type, phase, slot?, code?, adCode?, message?, width?, height?, reward?, earned?}
  // phase: loading, loaded, failed, opened, impression, clicked, closed, rewarded, show_failed, opt_in, declined
  if (a.phase === 'failed') console.log(a.type, 'failed to load:', a.code, a.adCode, a.message);
});
// The same object also goes to window.onAppMintAd, if you define that function.
window.onAppMintAd = function (a) { console.log('ad', a.type, a.phase); };
```

Load the interstitial when a level starts, so it is there when the level ends, and stop asking once the user has removed ads:

```js
var ads = window.AppMintAds;
var interstitialsOff = false;

async function startLevel(n) {
  runLevel(n);                                          // your own game
  if (!ads || interstitialsOff) return;
  var res = await ads.load('interstitial');
  if (!res.ok && (res.code === 'ad_free' || res.code === 'disabled' || res.code === 'automatic_mode')) {
    interstitialsOff = true;                            // no interstitials in this app session
  }
}

async function levelEnded() {
  if (ads && !interstitialsOff && ads.isReady('interstitial')) await ads.show('interstitial');
  showLevelSummary();
}
```

**Notes:** Codes: `no_fill`, `network`, `invalid_unit`, `internal` (from AdMob), `automatic_mode`, `unknown_type`, `ad_free`, `disabled`, `consent`, and `use_slot` for `banner` / `mrec` (use `AppMintAds.banner.attach`). The app already preloads each full-screen type with a unit ID and loads the next one after every show, so `load` is mostly for knowing when the button may be enabled.

### `AppMintAds.show`

**Example**

Shows a loaded full-screen ad: `interstitial`, `rewarded`, `rewarded_interstitial` or `app_open`.

**Returns:** a Promise that resolves (never rejects) when the ad has closed: `{ok:true, shown:true, earned, reward?}` (`reward` = `{type, amount}` for the rewarded formats), or `{ok:false, shown:false, code, reason, message?}` when nothing was shown. **Needs:** **Ad Control → Developer-controlled** and an ad unit ID for the type.

**Rewarded:** call it inside the user's own tap. That tap is the user's opt-in, so the ad plays at once. Grant only when `earned` is true:

```js
var ads = window.AppMintAds;

document.getElementById('watch-ad').addEventListener('click', async function () {
  if (!ads) return;                                  // browser: no ads here
  var res = await ads.show('rewarded');
  if (res.earned) addCoins(res.reward.amount);
  else if (res.reason === 'ad_free') addCoins(50);   // user paid to remove ads: reward anyway
  else if (res.shown) showMessage('Watch to the end to get the reward.');
  else if (res.reason !== 'declined') showMessage('No ad available right now.');
});
```

**Interstitial:** at a natural break. Continue whatever the answer is:

```js
async function levelFinished() {
  if (window.AppMintAds && window.AppMintAds.isReady('interstitial')) {
    await window.AppMintAds.show('interstitial');    // resolves when the ad closes
  }
  showNextLevel();
}
```

**Rewarded interstitial** behind your own intro screen with a clear "No thanks". The tap on "Watch" is the opt-in:

```js
var ads = window.AppMintAds;
var intro = document.getElementById('free-life-intro');   // your own dialog: Watch / No thanks

function offerFreeLife() {
  if (ads && ads.isReady('rewarded_interstitial')) intro.hidden = false;
  else gameOver();
}

document.getElementById('free-life-watch').addEventListener('click', async function () {
  intro.hidden = true;
  var res = await ads.show('rewarded_interstitial');
  if (res.earned) continueWithExtraLife();
  else gameOver();
});

document.getElementById('free-life-skip').addEventListener('click', function () {
  intro.hidden = true;
  gameOver();
});
```

**Notes:** A rewarded or rewarded-interstitial `show()` that does NOT come from a user tap (a timer, page load, route change) first gets a "Watch an ad? / No thanks" screen, and "No thanks" answers `declined`. Never trigger rewarded formats that way. Other codes: `automatic_mode`, `unknown_type`, `ad_free`, `disabled`, `consent`, `not_full_screen`, `showing`, `cooldown` (60 s between interstitials), `not_loaded` (a new load starts for next time), `show_failed`, `internal`. In Automatic mode use `WebToApk.showRewardedAd()`.

### `AppMintIAP.available`

**Example**

A property (not a function): `true` when this page can really buy through Google Play.

**Returns:** a boolean property. **Needs:** **💰 Sell In-App Products**. Without it, `window.AppMintIAP` does not exist at all.

Check it before you show anything for sale, and say plainly when buying is not possible here:

```js
var inApp = !!(window.AppMintIAP && window.AppMintIAP.available);
var buyBtn = document.getElementById('buy-pro');

if (!inApp) {
  buyBtn.disabled = true;
  buyBtn.textContent = 'Available in the Android app';
}
```

One upgrade button for your website and your app: Google Play inside the app, your own checkout on the web:

```js
document.getElementById('upgrade').addEventListener('click', function () {
  var iap = window.AppMintIAP;
  if (iap && iap.available) {
    iap.buy('pro_unlock');                                   // inside the app
  } else if (window.WebToApk) {
    showMessage('Upgrades are not available in this version of the app.');
  } else {
    location.href = '/checkout/pro';                         // your website's own checkout
  }
});
```

In React or Next.js, read it inside an effect (the server render has no `window`), so nothing to buy is rendered outside the app:

```js
import { useEffect, useState } from 'react';

export function useCanBuy() {
  const [canBuy, setCanBuy] = useState(false);     // false during the server render
  useEffect(() => {
    setCanBuy(!!(window.AppMintIAP && window.AppMintIAP.available));
  }, []);
  return canBuy;
}

// const canBuy = useCanBuy();
// return canBuy ? <UpgradeButton /> : null;
```

**Notes:** `window.AppMintIAP` is installed before your page's first script runs; only on WebViews older than about 2021 does it arrive once the page has finished loading, so code that must also run there should wait for it (see the `whenIap` pattern in `AppMintIAP.sell`). The same value is in `AppMintIAP.state().available`. Wrapping the offer in `data-iap-container` hides it outside the app with no code.

### `AppMintIAP.buy`

**Example**

Opens Google Play's purchase sheet for a product, from your own button. It grants nothing by itself.

**Returns:** nothing. The result arrives as `appmint:purchase` `{productId, owned:true}` (and through `AppMintIAP.onChange`) or `appmint:purchase-failed` `{productId, reason}`. **Needs:** **💰 Sell In-App Products** and the product active in Play Console.

Use it when you draw the button yourself; the unlock comes from ownership, not from the tap:

```js
var btn = document.getElementById('buy-pro');

btn.addEventListener('click', function () {
  if (!window.AppMintIAP || !window.AppMintIAP.available) { showMessage('Available in the Android app.'); return; }
  btn.disabled = true;
  window.AppMintIAP.buy('pro_unlock');
});

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock') unlockPro();     // must be safe to run twice
  btn.disabled = false;
});
window.addEventListener('appmint:purchase-failed', function (e) {
  btn.disabled = false;
  showMessage(e.detail.reason === 'pending' ? 'Payment pending. It unlocks once Google confirms.' : 'Purchase failed: ' + e.detail.reason);
});
document.addEventListener('visibilitychange', function () { if (!document.hidden) btn.disabled = false; });   // cancel: no event
```

A plan picker with one "Continue" button that buys whichever plan is selected:

```js
document.getElementById('continue').addEventListener('click', function () {
  var choice = document.querySelector('input[name="plan"]:checked');   // value = product ID
  var iap = window.AppMintIAP;
  if (!choice || !iap || !iap.available) return;
  if (iap.isOwned(choice.value)) { showMessage('You already have this plan.'); return; }
  iap.buy(choice.value);
});

window.addEventListener('appmint:purchase', function () {
  document.getElementById('plan-picker').hidden = true;
  showMessage('Thanks! Your plan is active.');
});
```

Tapping a Pro-only tool runs it when owned, and otherwise opens the purchase sheet:

```js
document.querySelectorAll('[data-pro-tool]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    var iap = window.AppMintIAP;
    if (iap && iap.isOwned('pro_unlock')) { runTool(btn.getAttribute('data-pro-tool')); return; }
    if (iap && iap.available) iap.buy('pro_unlock');
    else showMessage('Pro tools are available in the Android app.');
  });
});

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock') document.body.classList.add('pro');
});
```

**Notes:** `buy()` is the bare `WebToApk.purchase(id)`: no busy label, no group check. Buttons wired with `sell()`, `start()` or `data-iap-buy` do all of that for you, so prefer those.

### `AppMintIAP.isOwned`

**Example**

Checks, instantly and offline, whether the user owns a product (a one-time product, or a subscription that is still active).

**Returns:** `true` / `false`, synchronously; `false` outside the app. **Needs:** **💰 Sell In-App Products**.

```js
function paintPro() {
  var pro = !!(window.AppMintIAP && window.AppMintIAP.isOwned('pro_unlock'));
  document.getElementById('pro-tools').hidden = !pro;
}

// Re-check whenever ownership can change.
window.addEventListener('appmint:purchase', paintPro);
window.addEventListener('appmint:owned-changed', paintPro);

// AppMintIAP arrives after the page has loaded: paint once it is there.
var waitIap = setInterval(function () {
  if (window.AppMintIAP) { clearInterval(waitIap); paintPro(); }
}, 200);
setTimeout(function () { clearInterval(waitIap); }, 10000);   // not in the app, or selling is off
```

Pro through any of several plans: a monthly, a yearly and a lifetime product all count:

```js
var PRO_PLANS = ['pro_monthly', 'pro_yearly', 'pro_lifetime'];

function isPro() {
  var iap = window.AppMintIAP;
  return !!iap && PRO_PLANS.some(function (id) { return iap.isOwned(id); });
}

function paintPlan() { document.body.classList.toggle('pro', isPro()); }
paintPlan();
window.addEventListener('appmint:purchase', paintPlan);
window.addEventListener('appmint:owned-changed', paintPlan);   // an expired subscription or a refund
```

A route guard: Pro-only screens send a user without Pro to the upgrade screen:

```js
function route() {
  var path = location.hash.slice(1) || '/';
  var proOnly = path.indexOf('/reports') === 0;
  if (proOnly && !(window.AppMintIAP && window.AppMintIAP.isOwned('pro_unlock'))) {
    location.replace('#/upgrade');
    return;
  }
  render(path);
}

window.addEventListener('hashchange', route);
route();
```

**Notes:** Same answer as `WebToApk.isOwned(id)`. Ownership is restored from the user's Google account at every launch and saved on the phone, so it survives reinstalls and works offline. Never keep the entitlement only in `localStorage`.

### `AppMintIAP.onChange`

**Example**

Calls your function with the purchase state right away, and again every time it changes (prices arrived, a purchase landed, the launch restore, a refund, an expired subscription).

**Returns:** an unsubscribe function. `fn` gets `{available, adFree, owned, ownedIds, products}`; `products` is `{id: {price, currency, type, period, owned}}` for the products the helper knows (from `sell`, `start` or `data-iap-buy`). **Needs:** **💰 Sell In-App Products**.

A React hook. It returns the unsubscribe function straight from the effect:

```ts
import { useEffect, useState } from 'react';

type IapState = { available: boolean; adFree: boolean; owned: boolean; ownedIds: string[];
  products: Record<string, { price: string | null; currency: string | null; type: string | null; period: string | null; owned: boolean }> };

export function useIap(ids: string[]) {
  const [iap, setIap] = useState<IapState>({ available: false, adFree: false, owned: false, ownedIds: [], products: {} });
  useEffect(() => {
    let stop: (() => void) | undefined;
    const t = setInterval(() => {                   // AppMintIAP arrives after page load
      const api = (window as any).AppMintIAP;
      if (!api) return;
      clearInterval(t);
      ids.forEach((id) => api.sell(id, null));      // track them, so their prices are fetched
      stop = api.onChange(setIap);
    }, 200);
    return () => { clearInterval(t); if (stop) stop(); };
  }, []);
  return iap;
}

// const iap = useIap(['pro_unlock']);
// const isPro = iap.ownedIds.includes('pro_unlock');
// const price = iap.products.pro_unlock?.price;
```

A plain-JavaScript paywall: track two plans without buttons, then draw their prices and the current plan from every change:

```js
var iap = window.AppMintIAP;
var PLANS = ['pro_monthly', 'pro_yearly'];

if (iap) {
  iap.start({ plans: PLANS.map(function (id) { return { id: id }; }) });   // tracked, not drawn
  iap.onChange(function (s) {
    document.getElementById('paywall').hidden = !s.available;
    PLANS.forEach(function (id) {
      var el = document.querySelector('[data-plan="' + id + '"]');
      var p = s.products[id];
      el.hidden = !p;                                // Google has not answered for it (yet)
      if (p) el.querySelector('.price').textContent = p.owned ? 'Your plan' : p.price;
    });
  });
}
```

Subscribe while an account screen is open and unsubscribe when it closes. `adFree` and `ownedIds` are filled in once the helper is running (the page uses `sell()`, `start()` or `data-iap-buy`):

```js
var stopAccount = null;

function openAccountScreen() {
  if (window.AppMintIAP) {
    stopAccount = window.AppMintIAP.onChange(function (s) {
      document.getElementById('ad-status').textContent = s.adFree ? 'Ad-free' : 'With ads';
      document.getElementById('owned-count').textContent = s.ownedIds.length + ' purchase(s)';
    });
  }
  showScreen('account');
}

function closeAccountScreen() {
  if (stopAccount) { stopAccount(); stopAccount = null; }   // no updates for a hidden screen
  showScreen('home');
}
```

**Notes:** Read ownership per product (`ownedIds` or `products[id].owned`). The top-level `owned` only means "anything is owned". `adFree` is the Remove Ads entitlement (`WebToApk.isPremium()`). A product is in `products` only after Google answered for it.

### `AppMintIAP.owned`

**Example**

Lists every product ID the user owns.

**Returns:** an array of product IDs, synchronously, for example `['pro_unlock', 'level_pack_2']`; `[]` outside the app or when nothing is owned. **Needs:** **💰 Sell In-App Products**.

```js
function unlockOwnedLevels() {
  var ids = window.AppMintIAP ? window.AppMintIAP.owned() : [];
  document.querySelectorAll('[data-level-pack]').forEach(function (el) {
    el.classList.toggle('locked', ids.indexOf(el.getAttribute('data-level-pack')) === -1);
  });
}

window.addEventListener('appmint:owned-changed', unlockOwnedLevels);   // restore or refund changed the list
window.addEventListener('appmint:purchase', unlockOwnedLevels);
```

Count how many levels are open when every level pack is its own product:

```js
function unlockedLevelCount() {
  var ids = window.AppMintIAP ? window.AppMintIAP.owned() : [];
  var packs = ids.filter(function (id) { return /^level_pack_\d+$/.test(id); }).length;
  return 10 + packs * 10;                         // 10 free levels, 10 more per pack
}

function paintLevelCount() {
  document.getElementById('levels-label').textContent = unlockedLevelCount() + ' levels unlocked';
}
paintLevelCount();
window.addEventListener('appmint:owned-changed', paintLevelCount);
```

A "My purchases" list on your settings screen, with your own name for each product:

```js
var LABELS = { pro_unlock: 'Pro features', remove_ads: 'No ads', level_pack_2: 'Levels 11-20' };

function renderPurchases() {
  var ids = window.AppMintIAP ? window.AppMintIAP.owned() : [];
  var list = document.getElementById('purchases');
  list.textContent = '';
  ids.forEach(function (id) {
    var li = document.createElement('li');
    li.textContent = LABELS[id] || id;
    list.appendChild(li);
  });
  document.getElementById('no-purchases').hidden = ids.length > 0;
}
renderPurchases();
window.addEventListener('appmint:owned-changed', renderPurchases);
```

**Notes:** Parsed from `WebToApk.getOwnedProducts()` for you. Includes active subscriptions and the Remove Ads product. Works offline.

### `AppMintIAP.refresh`

**Example**

Looks again for `data-iap-buy` buttons that were added since the last scan, and wires them (or starts the helper if it had found none yet).

**Returns:** the `AppMintIAP` object. **Needs:** **💰 Sell In-App Products**.

The helper already watches the page for new `data-iap-buy` buttons, so you rarely need this. Call it after you insert buttons in a way the watcher could miss, for example by changing attributes on an existing element:

```js
function showShop() {
  var btn = document.getElementById('buy-coins');
  btn.setAttribute('data-iap-buy', 'coins_500');          // attribute added to an EXISTING node
  btn.setAttribute('data-iap-label', '500 coins — {price}');
  if (window.AppMintIAP) window.AppMintIAP.refresh();
}
```

Shop cards whose products come from your own server: the buttons are already on the page, only their attributes change, so wire them with `refresh()`:

```js
fetch('/shop.json').then(function (r) { return r.json(); }).then(function (shop) {
  shop.items.forEach(function (item) {           // [{slot, productId, name}]
    var btn = document.querySelector('.shop-card[data-slot="' + item.slot + '"] button');
    if (!btn) return;
    btn.setAttribute('data-iap-buy', item.productId);
    btn.setAttribute('data-iap-label', item.name + ' - {price}');
  });
  if (window.AppMintIAP) window.AppMintIAP.refresh();
});
```

A holiday bundle that goes on sale at a set time, on a button that was on the page from the start:

```js
var saleStart = new Date('2026-12-01T00:00:00').getTime();

function openSale() {
  var btn = document.getElementById('sale-btn');
  btn.setAttribute('data-iap-buy', 'holiday_bundle');
  btn.setAttribute('data-iap-owned', 'Bundle unlocked');
  document.getElementById('sale-card').hidden = false;
  if (window.AppMintIAP) window.AppMintIAP.refresh();
}

var wait = saleStart - Date.now();
if (wait <= 0) openSale();
else if (wait < 86400000) setTimeout(openSale, wait);   // later than a day: the next launch opens it
```

**Notes:** Each new button gets its price from Google and its owned state. A button that is already wired is not wired twice. Buttons whose element was removed from the page are dropped.

### `AppMintIAP.sell`

**Example**

Wires one product to one button in a single line. Call it once per product.

**Returns:** the `AppMintIAP` object (for chaining). **Needs:** **💰 Sell In-App Products** in the build wizard and the product created and active in Play Console. `window.AppMintIAP` is installed before your page's first script runs (on WebViews older than about 2021, once the page has finished loading — the `whenIap` pattern below covers both).

```js
function whenIap(fn) {
  if (window.AppMintIAP) { fn(window.AppMintIAP); return; }
  var tries = 0;
  var t = setInterval(function () {
    if (window.AppMintIAP) { clearInterval(t); fn(window.AppMintIAP); }
    else if (++tries > 50) clearInterval(t);          // not in the app, or selling is off
  }, 200);
}

whenIap(function (iap) {
  iap.sell('pro_unlock', '#buy-pro', {
    label: 'Unlock Pro — {price}',     // {price} = Google's live, localized price
    owned: '✓ Unlocked',               // the button text once owned
    show: '#thanks',                   // shown once owned
    hide: '.upsell'                    // hidden once owned
  });
  iap.sell('coins_500', '#buy-coins', { label: '500 coins — {price}' });
});
```

Several plans where buying one replaces the others share a `group`:

```js
whenIap(function (iap) {
  iap.sell('pass_monthly', '#monthly', { label: 'Monthly — {price}', group: 'pass' })
     .sell('pass_yearly', '#yearly', { label: 'Yearly — {price}', group: 'pass' });
});
```

Track a product with no button and draw the offer yourself (`null` wires nothing; your button calls `buy`):

```js
whenIap(function (iap) {
  iap.sell('remove_ads', null);                      // tracked, never drawn by the helper
  iap.onChange(function (s) {
    var p = s.products.remove_ads;                   // undefined until Google answers
    var btn = document.getElementById('remove-ads');
    if (!s.available || (p && p.owned)) { btn.hidden = true; return; }
    btn.hidden = false;
    btn.textContent = p && p.price ? 'Remove ads for ' + p.price : 'Remove ads';
  });
  document.getElementById('remove-ads').addEventListener('click', function () {
    iap.buy('remove_ads');
  });
});
```

Wire a button that only exists once your shop dialog opens (a late call joins the running helper):

```js
document.getElementById('open-shop').addEventListener('click', function () {
  openShopDialog();                                  // your code: adds <button id="buy-levels">
  if (!window.AppMintIAP) return;                    // not in the app
  window.AppMintIAP.sell('level_pack_2', '#buy-levels', {
    label: 'Levels 11-20 — {price}',
    owned: '✓ Levels 11-20 unlocked',
    show: '#levels-2-link'                           // your menu entry, shown once owned
  });
});
```

**Notes:** `button` is an element or a CSS selector; pass `null` to track a product without a button (your own UI reads `AppMintIAP.state()`). Other `opts`: `labelEl` (the element inside the button whose text changes). It works before or after the helper started, including from a React effect. The same as the `data-iap-buy` attributes (see `AppMintIAP.start`). Unlock paid content only from ownership (`isOwned`, `onChange`), never in your own click handler.

### `AppMintIAP.start`

**Example**

Wires your buy buttons to Google Play in one step: live prices on the buttons, the purchase sheet on tap, "owned" labels, thank-you blocks, restore at launch, and a visible message for every failure.

**Returns:** the `AppMintIAP` object (for chaining). **Needs:** **💰 Sell In-App Products** in the build wizard and the product IDs created and active in Play Console. The app installs `window.AppMintIAP` before your page's first script runs (on WebViews older than about 2021, only once the page has finished loading).

**No code at all:** mark the HTML. The helper reads these attributes by itself as soon as it is injected, and watches for buttons that React or Vue render later:

```html
<div data-iap-container>                              <!-- shown only inside the app -->
  <button data-iap-buy="pass_monthly" data-iap-group="pass"
          data-iap-label="Monthly — {price}" data-iap-owned="Active">Monthly</button>
  <button data-iap-buy="pass_lifetime" data-iap-group="pass"
          data-iap-label="Lifetime — {price}" data-iap-owned="Owned">
    <img src="star.svg" alt=""> <span data-iap-text>Lifetime</span>   <!-- only this text changes -->
  </button>
  <button data-iap-buy="coins_500" data-iap-label="500 coins — {price}">500 coins</button>
</div>

<p data-iap-show="pass" hidden>You are a member. Thank you!</p>   <!-- shown when any "pass" plan is owned -->
<p data-iap-hide="pass">Members get every level and no ads.</p>    <!-- hidden once it is owned -->
```

**With code:** call `start()` yourself when you want your own message function or unlock hooks. Use it on pages WITHOUT `data-iap-buy` buttons in the HTML (with them, the helper has already started and a second start does nothing):

```js
function whenIap(fn) {                               // AppMintIAP arrives after page load
  if (window.AppMintIAP) { fn(window.AppMintIAP); return; }
  var tries = 0;
  var t = setInterval(function () {
    if (window.AppMintIAP) { clearInterval(t); fn(window.AppMintIAP); }
    else if (++tries > 50) clearInterval(t);         // not in the app, or selling is off
  }, 200);
}

whenIap(function (iap) {
  iap.start({
    plans: [
      { id: 'pro_unlock', button: '#buy-pro', label: 'Unlock Pro — {price}', owned: '✓ Pro' },
      { id: 'coins_500', button: '#buy-coins', label: '500 coins — {price}' }
    ],
    show: '#pro-area',                 // shown once anything is owned
    hide: '.upsell',                   // hidden once anything is owned
    busyLabel: 'Opening Google Play…',
    onUnlock: function (id) { console.log('now owned:', id); },
    onLock: function (id) { console.log('no longer owned (refund or expiry):', id); },
    toast: function (msg) { showMessage(msg); }     // failure messages; default: window.showToast, else alert
  });
});
```

**In React:** start with plans that have no `button` (nothing is drawn) and let React render from `onChange`, which returns the unsubscribe function for the effect:

```js
function ProButton() {
  const [iap, setIap] = React.useState({ available: false, products: {} });

  React.useEffect(() => {
    if (typeof window === 'undefined' || !window.AppMintIAP) return;   // server render / browser
    window.AppMintIAP.start({ plans: [{ id: 'pro_unlock' }] });       // safe to run twice
    return window.AppMintIAP.onChange(setIap);
  }, []);

  const pro = iap.products?.pro_unlock;                                // read ownership per product
  if (!iap.available) return null;                                     // nothing to buy outside the app
  return pro?.owned
    ? <p>Thanks! Pro is unlocked.</p>
    : <button onClick={() => window.AppMintIAP.buy('pro_unlock')}>Unlock Pro {pro?.price}</button>;
}
```

**Notes:** Attributes: `data-iap-buy="id"`, `data-iap-label` (`{price}` becomes Play's live price), `data-iap-owned` (label once owned; default "✓ Purchased"), `data-iap-group` (plans that replace each other; after one is owned the others hide unless `hideOtherPlans: false`), `data-iap-show` / `data-iap-hide` (value = product id or group; empty = anything owned), `data-iap-text` (the part of a button whose text changes), `data-iap-container`. An owned button gets `data-iap-state="owned"` and the class `iap-unlocked`. A cancel on Google's sheet sends no event; the button is released when the page is visible again (or after 30 s). Calling `start(options)` after the helper already started on its own from `data-iap-buy` attributes MERGES the options (plans, show/hide, container, `onUnlock`/`onLock`, `toast`, `busyLabel`, `hideOtherPlans`) into the running set; calling it twice is harmless. A page that ships its own `appmint-iap.js` keeps it — the built-in copy stands down.

### `AppMintIAP.started`

**Example**

A property (not a function): `true` once the helper is running, i.e. it has wired at least one product from `start()`, `sell()` or `data-iap-buy` buttons.

**Returns:** a boolean property. **Needs:** **💰 Sell In-App Products**.

Use it to decide between `start()` (with your own options) and `sell()` (which also works on a running helper):

```js
function setUpShop(iap) {
  if (!iap.started) {
    iap.start({
      plans: [{ id: 'pro_unlock', button: '#buy-pro', label: 'Unlock Pro — {price}' }],
      toast: function (msg) { showMessage(msg); }
    });
  } else {
    // Already running (the page had data-iap-buy buttons): start() would do nothing now.
    iap.sell('pro_unlock', '#buy-pro', { label: 'Unlock Pro — {price}' });
  }
}
if (window.AppMintIAP) setUpShop(window.AppMintIAP);
```

Warn yourself during development when nothing on the page was wired (a typo in `data-iap-buy`, or the buttons never rendered):

```js
window.addEventListener('load', function () {
  setTimeout(function () {
    var iap = window.AppMintIAP;
    if (iap && !iap.started) {
      console.warn('AppMintIAP is still waiting: no data-iap-buy button, sell() or start() on this page');
    }
  }, 3000);
});
```

Tell a store screen apart from "still loading" and "purchases unavailable":

```js
function storeStatus() {
  var iap = window.AppMintIAP;
  if (!iap) return 'web';                            // opened in a normal browser
  if (!iap.started) return 'waiting';                // no product wired yet
  return iap.state().available ? 'ready' : 'unavailable';
}
document.getElementById('store').setAttribute('data-status', storeStatus());
```

**Notes:** A second `start()` never starts it twice (React StrictMode runs effects twice); its options are merged into the running set. It stays `false` while no product is wired yet; the helper then keeps watching the page for `data-iap-buy` buttons.

### `AppMintIAP.state`

**Example**

Returns the current purchase state once, without subscribing.

**Returns:** `{available, adFree, owned, ownedIds, products}`, synchronously. `ownedIds` is an array of product IDs; `products` is `{id: {price, currency, type: 'inapp'|'subs', period, owned}}` for the products the helper has asked Google about. **Needs:** **💰 Sell In-App Products**.

```js
function describePass() {
  if (!window.AppMintIAP) return 'Available in the Android app';
  var s = window.AppMintIAP.state();
  if (s.ownedIds.indexOf('season_pass') !== -1) return 'Your season pass is active';
  var p = s.products.season_pass;                       // undefined until Google answers
  if (!p || !p.price) return 'Season pass';
  var every = { P1W: 'week', P1M: 'month', P1Y: 'year' }[p.period] || 'period';
  return 'Season pass — ' + p.price + ' / ' + every;
}
document.getElementById('pass-label').textContent = describePass();
```

Check ownership at the moment the player taps a locked level, and send them to your shop otherwise:

```js
function openLevel(n) {
  var s = window.AppMintIAP ? window.AppMintIAP.state() : null;
  var needsPack = n > 10;
  if (needsPack && !(s && s.ownedIds.indexOf('level_pack_2') !== -1)) {
    showShop('level_pack_2');                        // your own screen
    return;
  }
  startLevel(n);
}
```

Hide your own "go ad-free" prompts once the build's ad-free entitlement is active, and list the prices Google has sent so far:

```js
if (window.AppMintIAP) {
  var s = window.AppMintIAP.state();
  document.querySelectorAll('.go-ad-free').forEach(function (el) { el.hidden = s.adFree; });

  var list = document.getElementById('price-list');
  Object.keys(s.products).forEach(function (id) {
    var p = s.products[id];
    var li = document.createElement('li');
    li.textContent = id + ': ' + (p.owned ? 'owned' : (p.price || '…')) +
                     (p.type === 'subs' ? ' (subscription)' : '');
    list.appendChild(li);
  });
}
```

**Notes:** It is a snapshot. To follow changes use `AppMintIAP.onChange(fn)`, which gives the same object every time something changes. `owned` is `true` when anything at all is owned; check a product with `ownedIds` or `AppMintIAP.isOwned(id)`.

### `WebToApkFS.clearAccess`

**Example**

Forgets the granted folder and gives the permission back to Android.

**Returns:** nothing (synchronous). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Always offer a way to disconnect next to the folder's name:

```js
document.getElementById('disconnect').addEventListener('click', function () {
  if (!window.WebToApkFS) return;
  window.WebToApkFS.clearAccess();
  console.log('still connected?', window.WebToApkFS.hasAccess());   // false
  document.getElementById('folder-name').textContent = 'No folder chosen';
  document.getElementById('choose-folder').hidden = false;
});
```

One button that connects or disconnects, depending on the current state:

```js
var btn = document.getElementById('folder-toggle');
function paint() {
  btn.textContent = window.WebToApkFS.hasAccess() ? 'Disconnect folder' : 'Connect a folder';
}

btn.addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  if (window.WebToApkFS.hasAccess()) {
    if (!confirm('Stop saving to this folder? Your files stay where they are.')) return;
    window.WebToApkFS.clearAccess();
  } else {
    await window.WebToApkFS.requestAccess();
  }
  paint();
});
if (window.WebToApkFS) paint();
```

Switch accounts: forget the folder with the rest of the signed-in state:

```js
function switchAccount() {
  sessionStorage.clear();
  if (window.WebToApkFS) window.WebToApkFS.clearAccess();   // the next account picks its own folder
  showLoginScreen();
}
```

**Notes:** The files stay where they are; the app simply cannot reach them any more. To use a folder again, call `WebToApkFS.requestAccess()` from a tap.

### `WebToApkFS.delete`

**Example**

Deletes a file or folder inside the granted folder.

**Returns:** a boolean, synchronously: `true` when it was deleted. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build, and a granted folder.

Delete one file, after asking:

```js
if (window.WebToApkFS && confirm('Delete notes/old.txt?')) {
  var ok = window.WebToApkFS.delete('notes/old.txt');
  if (!ok) alert('Could not delete the file.');
}
```

Delete a folder and everything inside it — pass `{ recursive: true }`:

```js
if (window.WebToApkFS && confirm('Delete the whole 2025 folder?')) {
  var gone = window.WebToApkFS.delete('notes/2025', { recursive: true });
  console.log(gone ? 'deleted' : 'not deleted');
}
```

Clean up exports older than 30 days, file by file:

```js
function pruneOldExports() {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return 0;
  var cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
  var removed = 0;
  window.WebToApkFS.list('exports').forEach(function (e) {
    if (e.isDirectory || e.lastModified >= cutoff) return;
    if (window.WebToApkFS.delete('exports/' + e.name)) removed++;
  });
  return removed;
}

if (confirm('Delete exports older than 30 days?')) {
  document.getElementById('status').textContent = pruneOldExports() + ' old exports deleted';
}
```

**Notes:** Without `{ recursive: true }` a folder is deleted only when it is empty. The granted folder itself (`''`) can never be deleted. Deletion is immediate and there is no trash, so always confirm with the user first.

### `WebToApkFS.getAccessUri`

**Example**

Gives the address of the granted folder, so you can show the user where files go.

**Returns:** a string, synchronously: a tree URI such as `content://com.android.externalstorage.documents/tree/primary%3ADocuments%2FNotes`, or `''` when no folder is granted. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Show a readable folder name next to a Disconnect button:

```js
function connectedFolderName() {
  if (!window.WebToApkFS) return '';
  var uri = window.WebToApkFS.getAccessUri();
  if (!uri) return '';
  var tail = uri.split('/tree/')[1] || '';
  try { tail = decodeURIComponent(tail); } catch (e) {}
  return tail.split(':').pop() || tail;        // "Documents/Notes"
}

var name = connectedFolderName();
document.getElementById('folder-name').textContent = name ? 'Folder: ' + name : 'No folder chosen';
```

Notice that the user picked a different folder since the last visit, and rebuild what you cached about the old one:

```js
function checkFolderChanged() {
  if (!window.WebToApkFS) return;
  var uri = window.WebToApkFS.getAccessUri();
  var last = '';
  try { last = localStorage.getItem('folderUri') || ''; } catch (e) {}
  if (uri && uri !== last) {
    rebuildFileIndex();                         // your app: re-read the new folder
    try { localStorage.setItem('folderUri', uri); } catch (e) {}
  }
}

checkFolderChanged();
```

A Disconnect button that forgets the folder and confirms it is gone:

```js
document.getElementById('disconnect').addEventListener('click', function () {
  if (!window.WebToApkFS || !window.WebToApkFS.getAccessUri()) return;
  if (!confirm('Stop saving to this folder? Your files stay where they are.')) return;
  window.WebToApkFS.clearAccess();
  var still = window.WebToApkFS.getAccessUri();
  document.getElementById('folder-name').textContent = still ? 'Could not disconnect' : 'No folder chosen';
});
```

**Notes:** The file methods do not take this URI — they take paths relative to the granted folder (`'notes/today.txt'`). The URI format belongs to the Files provider the user picked from, so only use it for display.

### `WebToApkFS.hasAccess`

**Example**

Tells you whether the user has already given the app a folder.

**Returns:** a boolean, synchronously. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Show "Choose folder" or the file list, depending on the answer:

```js
function renderFolderScreen() {
  if (!window.WebToApkFS) {
    document.getElementById('folder-panel').hidden = true;   // not in the app, or not enabled
    return;
  }
  var connected = window.WebToApkFS.hasAccess();
  document.getElementById('choose-folder').hidden = connected;
  document.getElementById('file-list').hidden = !connected;
  if (connected) {
    var entries = window.WebToApkFS.list('');
    document.getElementById('file-list').textContent =
      entries.map(function (e) { return e.name; }).join('\n');
  }
}
```

Autosave only while a folder is connected, and say so when it is not:

```js
var saveTimer = null;
document.getElementById('editor').addEventListener('input', function () {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(function () {
    var status = document.getElementById('status');
    if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) {
      status.textContent = 'Choose a folder to turn on autosave';
      return;
    }
    var ok = window.WebToApkFS.writeText('notes/draft.txt', document.getElementById('editor').value);
    status.textContent = ok ? 'Saved' : 'Could not save';
  }, 800);
});
```

On start-up, reopen the last note when a folder is remembered, otherwise show the welcome screen:

```js
window.addEventListener('load', function () {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) {
    showWelcome();                                 // your app: explains "Choose folder"
    return;
  }
  var last = 'notes/today.txt';
  try { last = localStorage.getItem('lastNote') || last; } catch (e) {}
  document.getElementById('editor').value = fs.readText(last);
});
```

**Notes:** It only says the app remembers a folder. If the user deleted or moved that folder, `list('')` returns `[]` — offer "Choose folder" again. `window.WebToApkFS` appears when the page finishes loading; during load use the raw `window.WebToApk.hasFolderAccess() === 'true'`.

### `WebToApkFS.isAvailable`

**Example**

Tells you whether this app can use a user-granted folder at all.

**Returns:** a boolean, synchronously: `true` when the build has folder access switched on. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build — without it (and without **Secure Keys**) `window.WebToApkFS` is not added to the page; with only Secure Keys it exists and answers `false`.

`window.WebToApkFS` is added before your page's first script runs; on WebViews older than about 2021 it arrives only when the page finishes loading. To cover those too, wait for it briefly before you decide:

```js
function whenFolderHelperReady(done) {
  var tries = 0;
  (function check() {
    if (window.WebToApkFS) return done(window.WebToApkFS);
    if (++tries > 30) return done(null);       // in a browser, or the build did not enable it
    setTimeout(check, 100);
  })();
}

whenFolderHelperReady(function (fs) {
  var usable = !!(fs && fs.isAvailable());
  document.getElementById('folder-panel').hidden = !usable;
});
```

Inside a button tap the page has long finished loading, so a direct check is enough:

```js
if (window.WebToApkFS && window.WebToApkFS.isAvailable()) {
  // show the folder features
}
```

Turn the two checks into one state that drives the whole folder screen:

```js
function folderState() {
  var fs = window.WebToApkFS;
  if (!fs || !fs.isAvailable()) return 'unsupported';   // this build has no folder access
  return fs.hasAccess() ? 'ready' : 'needs-folder';
}

var state = folderState();
document.getElementById('folder-panel').hidden = state === 'unsupported';
document.getElementById('choose-folder').hidden = state !== 'needs-folder';
document.getElementById('export').disabled = state !== 'ready';
```

**Notes:** This says nothing about whether a folder is chosen yet — use `WebToApkFS.hasAccess()` for that. Before the page has loaded, the raw `window.WebToApk.isFolderAccessEnabled()` (string `"true"`/`"false"`) answers the same question.

### `WebToApkFS.list`

**Example**

Lists the files and sub-folders inside a folder of the granted folder.

**Returns:** an array, synchronously: `[{ name, uri, isDirectory, length, lastModified }]` (`length` in bytes, `lastModified` in ms since 1970). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build, and a granted folder.

A simple file browser (`''` is the granted folder itself):

```js
function renderFolder(path) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return;
  var ul = document.getElementById('files');
  ul.innerHTML = '';
  window.WebToApkFS.list(path)
    .sort(function (a, b) { return (b.isDirectory - a.isDirectory) || a.name.localeCompare(b.name); })
    .forEach(function (e) {
      var li = document.createElement('li');
      li.textContent = (e.isDirectory ? '[folder] ' : '') + e.name +
        (e.isDirectory ? '' : ' (' + e.length + ' bytes, ' + new Date(e.lastModified).toLocaleDateString() + ')');
      if (e.isDirectory) li.onclick = function () { renderFolder(path ? path + '/' + e.name : e.name); };
      ul.appendChild(li);
    });
}

renderFolder('');
```

Walk every sub-folder to collect all Markdown notes (paths you can pass to `readText`):

```js
function findNotes(path, out) {
  out = out || [];
  window.WebToApkFS.list(path).forEach(function (e) {
    var full = path ? path + '/' + e.name : e.name;
    if (e.isDirectory) findNotes(full, out);
    else if (/\.md$/i.test(e.name)) out.push(full);
  });
  return out;
}

if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  var notes = findNotes('');
  document.getElementById('count').textContent = notes.length + ' notes';
}
```

Find the newest backup and how much space the backups take:

```js
function backupSummary() {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return null;
  var files = window.WebToApkFS.list('backups').filter(function (e) { return !e.isDirectory; });
  if (!files.length) return null;
  var newest = files.reduce(function (a, b) { return b.lastModified > a.lastModified ? b : a; });
  var bytes = files.reduce(function (sum, e) { return sum + e.length; }, 0);
  return { newest: 'backups/' + newest.name, count: files.length, kb: Math.round(bytes / 1024) };
}

var s = backupSummary();
document.getElementById('backup-info').textContent = s
  ? s.count + ' backups, ' + s.kb + ' KB. Latest: ' + s.newest
  : 'No backups yet';
```

**Notes:** Empty folder, missing folder, a path that is a file, a path with `..`, and no granted folder all return `[]`. The list is not sorted. Each `uri` also works with `WebToApkFS.stat()` and `WebToApkFS.readBytes()`.

### `WebToApkFS.maxChunkSize`

**Example**

Tells you the most bytes one `readBytes` or `writeBytes` call can move (4 MB), so you can size your chunk loop.

**Returns:** (sync) a number of bytes, or `0` when the bridge method is missing. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build (that is what injects `WebToApkFS`).

```js
function copyFile(fromUri, toPath) {
  if (!window.WebToApkFS) return { ok: false, error: 'not in the app' };
  const step = WebToApkFS.maxChunkSize() || 1048576;   // fall back to 1 MB pieces
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fromUri, offset, step);
    if (!r.ok) return r;
    if (r.bytesRead > 0) {
      const w = WebToApkFS.writeBytes(toPath, r.base64, offset, offset === 0 ? 'truncate' : 'patch');
      if (!w.ok) return w;
    }
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, size: offset };
  }
}
```

Show read progress: the chunk size tells you how many steps a file takes:

```js
function readWithProgress(uri, onProgress) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok) return info;
  const step = WebToApkFS.maxChunkSize() || 1048576;
  const steps = Math.max(1, Math.ceil(info.size / step));
  for (let i = 0; i < steps; i++) {
    const r = WebToApkFS.readBytes(uri, i * step, step);
    if (!r.ok) return r;
    onProgress(Math.round(((i + 1) / steps) * 100), r.base64);
  }
  return { ok: true, size: info.size };
}
```

Append a large byte array to a file in the granted folder, one allowed piece at a time:

```js
function appendBytes(path, bytes) {                    // bytes: a Uint8Array
  const step = WebToApkFS.maxChunkSize();
  if (!step) return { ok: false, error: 'not in the app' };
  for (let at = 0; at < bytes.length; at += step) {
    const piece = bytes.subarray(at, at + step);
    let bin = '';
    for (let i = 0; i < piece.length; i += 0x8000) bin += String.fromCharCode.apply(null, piece.subarray(i, i + 0x8000));
    const w = WebToApkFS.writeBytes(path, btoa(bin), 0, 'append');   // 'append' ignores the offset
    if (!w.ok) return w;
  }
  return { ok: true };
}
```

**Notes:** A smaller chunk keeps the page more responsive (each call is synchronous); the cap only says how big one call may be.

### `WebToApkFS.mkdir`

**Example**

Creates a folder inside the granted folder, including any missing parent folders.

**Returns:** a boolean, synchronously: `true` when the folder exists afterwards. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build, and a granted folder.

```js
if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  var month = new Date().toISOString().slice(0, 7);          // "2026-06"
  if (window.WebToApkFS.mkdir('journal/' + month)) {
    window.WebToApkFS.writeText('journal/' + month + '/readme.txt', 'One file per day.');
  } else {
    alert('Could not create the folder.');
  }
}
```

Set up a project's folder layout on first use, and report any folder that could not be made:

```js
function createProjectFolders(project) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var failed = ['images', 'exports', 'drafts'].filter(function (sub) {
    return !window.WebToApkFS.mkdir('projects/' + project + '/' + sub);
  });
  if (failed.length) alert('Could not create: ' + failed.join(', '));
  return failed.length === 0;
}

createProjectFolders('holiday-2026');
```

A "New folder" button that warns when the name is already taken (because `mkdir` answers `true` for an existing folder):

```js
document.getElementById('new-folder').addEventListener('click', function () {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return;
  var name = (prompt('Folder name') || '').trim();
  if (!name || /[\\/]/.test(name) || name === '.' || name === '..') return;
  var taken = window.WebToApkFS.list('').some(function (e) { return e.name === name; });
  if (taken) { alert('"' + name + '" already exists.'); return; }
  if (window.WebToApkFS.mkdir(name)) renderFolder('');   // your app: refresh the list
  else alert('Could not create "' + name + '".');
});
```

**Notes:** It returns `true` if the folder already exists, so calling it before each save is fine. It returns `false` when a FILE with that name is in the way, the path contains `..`, or no folder is granted. `WebToApkFS.writeText()` already creates missing parents, so you only need `mkdir` for empty folders.

### `WebToApkFS.pickFile`

**Example**

Opens the Android file picker so the user chooses ONE file; your page may then read and write that file's real bytes, even after the app restarts.

**Returns:** `Promise<{ok, uri, name, size, mime, error}>`. On success `ok` is `true` and `uri` is a `content://` address you pass to `stat`, `readBytes`, `writeBytes`. On failure `ok` is `false` and `error` is `'cancelled'`, `'disabled'`, `'no-picker'`, `'busy'` (a picker is already open for an earlier call, which keeps its own answer), `'foreign_origin'` or `'unsupported'` (no app). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

```js
async function openOneFile() {
  if (!window.WebToApkFS) { alert('Open this page in the app to pick a file.'); return; }
  const f = await WebToApkFS.pickFile('application/pdf');   // '' = any file, 'image/*' = pictures
  if (!f.ok) {
    if (f.error !== 'cancelled') alert('Cannot open a file: ' + f.error);
    return;
  }
  console.log(f.name, f.size + ' bytes', f.mime);
  localStorage.setItem('lastFile', f.uri);   // the grant survives a restart
}
```

Use the saved `uri` again later, without asking the user a second time:

```js
const uri = localStorage.getItem('lastFile');
if (uri && window.WebToApkFS) {
  const info = WebToApkFS.stat(uri);
  if (info.ok) console.log('still here:', info.name);
  else console.log('file is gone or access was removed:', info.error);
}
```

The filter takes ONE pattern, so pick broadly and check the name yourself:

```js
async function importTable() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/*');        // CSV and TSV files are both text/*
  if (!f.ok) {
    if (f.error === 'busy') return;                     // a picker is already open
    if (f.error !== 'cancelled') alert('Cannot open a file: ' + f.error);
    return;
  }
  if (!/\.(csv|tsv)$/i.test(f.name)) {
    WebToApkFS.releaseFile(f.uri);
    alert(f.name + ' is not a CSV or TSV file.');
    return;
  }
  const r = WebToApkFS.readAll(f.uri);
  if (r.ok) showTable(atob(r.base64), f.name.toLowerCase().endsWith('.tsv') ? '\t' : ',');
  WebToApkFS.releaseFile(f.uri);
}
```

**Notes:** The filter is one MIME type pattern (`'image/*'`, `'text/csv'`), not a list. The picker asks for read AND write access and keeps it. Android limits how many files an app may keep this way, so call `WebToApkFS.releaseFile(uri)` when you are done with a file. For a whole folder use `WebToApkFS.requestAccess()` instead. `WebToApkFS` is installed when Native Folder Access or Secure Keys is ticked; its calls work only with Native Folder Access (`WebToApkFS.isAvailable()`).

### `WebToApkFS.readAll`

**Example**

Reads a whole file into one base64 string. It loops over `readBytes` for you, 4 MB at a time.

**Returns:** (sync) `{ok:true, base64, size}`, or the first failing chunk's `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Load a picked image into an `<img>`:

```js
async function showPicture() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('image/*');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  document.getElementById('photo').src = 'data:' + f.mime + ';base64,' + r.base64;
}
```

Read a UTF-8 text file (works for any language, not only English):

```js
function readUtf8(pathOrUri) {
  const r = WebToApkFS.readAll(pathOrUri);
  if (!r.ok) throw new Error(r.error);
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  return new TextDecoder('utf-8').decode(bytes);
}
```

Show a file's SHA-256 checksum, for example to compare a download with the published one:

```js
async function checksum() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
  const hex = Array.from(hash, function (b) { return b.toString(16).padStart(2, '0'); }).join('');
  console.log(f.name, r.size + ' bytes', hex);
}
```

**Notes:** The whole file ends up in memory (as bytes and then as base64), and the call blocks the page while it runs. Use it for files that easily fit in memory; for large files loop `WebToApkFS.readBytes` and handle each chunk. For plain text inside the granted folder, `WebToApkFS.readText(path)` is simpler.

### `WebToApkFS.readBytes`

**Example**

Reads the real bytes of a file, starting at a byte position, as base64. Safe for images, PDFs, ZIPs — nothing is changed by text decoding.

**Returns:** (sync) `{ok:true, base64, bytesRead, offset, size, eof}` or `{ok:false, error}` (`'not-found'`, `'disabled'`, `'cannot-open'`, `'offset past end of file (N bytes)'`, `'foreign_origin'`, `'unsupported'`). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Read the first 64 KB, for example to check a file header:

```js
async function checkPng() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('image/*');
  if (!f.ok) return;
  const r = WebToApkFS.readBytes(f.uri, 0, 65536);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  const head = atob(r.base64).slice(1, 4);
  console.log(head === 'PNG' ? 'a PNG' : 'not a PNG', '- file is', r.size, 'bytes');
}
```

Read a big file piece by piece, so it never has to fit in memory at once:

```js
function eachChunk(uri, onChunk) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(uri, offset, step);
    if (!r.ok) return r;                 // stop on the first error
    onChunk(r.base64, offset);
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, size: offset };
  }
}
```

Read only the end of a file, for example the 128-byte tag at the end of an MP3:

```js
function readMp3Title(uri) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok || info.size < 128) return null;
  const r = WebToApkFS.readBytes(uri, info.size - 128, 128);
  if (!r.ok) return null;
  const tail = atob(r.base64);
  if (tail.slice(0, 3) !== 'TAG') return null;          // no old-style tag in this file
  return tail.slice(3, 33).replace(/\0+$/, '').trim();  // bytes 3-32 hold the title
}
```

**Notes:** One call reads at most `WebToApkFS.maxChunkSize()` bytes (4 MB); `length` 0 means "as much as one call allows". Loop on `bytesRead` and stop on `eof`. The call is synchronous, so keep chunks moderate on the UI thread. For small files `WebToApkFS.readAll(uri)` does the loop for you.

### `WebToApkFS.readSealed`

**Example**

Reads a file written by `WebToApkFS.writeSealed` and decrypts it with the same keystore key.

**Returns:** (sync) `{ok:true, base64}` (the original bytes), or `{ok:false, error}` — `'keystore-disabled'`, `'corrupt-sealed-file'`, a keystore code (`'no-such-key'` after the key was deleted, `'auth-required'`, `'key-invalidated'`) or a file error (`'not-found'` …). **Needs:** tick **Native Folder Access (SAF)** and **Secure Keys (encryption)** in Step 4 (Access) when you build.

```js
function loadSecret() {
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) return null;
  const out = WebToApkFS.readSealed('secret.bin', 'vault');
  if (out.ok) {
    const bytes = Uint8Array.from(atob(out.base64), function (c) { return c.charCodeAt(0); });
    return new TextDecoder().decode(bytes);
  }
  if (out.error === 'auth-required') {
    alert('Unlock your phone, then try again.');
  } else if (out.error === 'no-such-key') {
    alert('This note was deleted.');
  } else {
    alert('Could not open the note: ' + out.error);
  }
  return null;
}
```

Keep app settings in a sealed JSON file, with defaults before the first save:

```js
function loadSettings() {
  const defaults = { theme: 'light', pinHint: '' };
  const out = WebToApkFS.readSealed('settings.sealed', 'settings');
  if (!out.ok) {
    if (out.error === 'not-found') return defaults;      // nothing saved yet
    throw new Error('Settings could not be opened: ' + out.error);
  }
  const bytes = Uint8Array.from(atob(out.base64), function (c) { return c.charCodeAt(0); });
  return Object.assign(defaults, JSON.parse(new TextDecoder().decode(bytes)));
}
```

Move a sealed file to a new key, then destroy the old key:

```js
function rekey(path, oldAlias, newAlias) {
  const plain = WebToApkFS.readSealed(path, oldAlias);
  if (!plain.ok) return plain;
  if (!AppMintKeystore.hasKey(newAlias)) {
    const k = AppMintKeystore.generateKey(newAlias, {});
    if (!k.ok) return k;
  }
  const w = WebToApkFS.writeSealed(path, plain.base64, newAlias);
  if (!w.ok) return w;
  AppMintKeystore.deleteKey(oldAlias);                  // only after the new copy is written
  return { ok: true };
}
```

**Notes:** It fails for good once `AppMintKeystore.deleteKey(alias)` has run — that is the point. The whole file is read and decrypted in memory, synchronously; keep sealed files small (notes, keys, tokens).

### `WebToApkFS.readText`

**Example**

Reads a text file from the granted folder as UTF-8.

**Returns:** a string, synchronously: the file's text, or `''`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build, and a granted folder.

`''` means "empty file" OR "no such file". Use `WebToApkFS.stat()` when you must know which:

```js
function loadSettings() {
  if (!window.WebToApkFS) return null;
  var info = window.WebToApkFS.stat('data/settings.json');
  if (!info.ok) return null;                                // not saved yet
  try {
    return JSON.parse(window.WebToApkFS.readText('data/settings.json'));
  } catch (e) {
    alert('settings.json is damaged: ' + e.message);
    return null;
  }
}
```

Open a note into a text box:

```js
if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  document.getElementById('editor').value = window.WebToApkFS.readText('notes/today.txt');
}
```

Import a CSV the user dropped into the folder:

```js
function importCsv(path) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return [];
  var text = window.WebToApkFS.readText(path);
  if (!text) return [];                                     // missing or empty
  var lines = text.replace(/\r/g, '').split('\n').filter(Boolean);
  var header = lines.shift().split(',');
  return lines.map(function (line) {
    var cells = line.split(',');
    var row = {};
    header.forEach(function (h, i) { row[h.trim()] = (cells[i] || '').trim(); });
    return row;
  });
}

var contacts = importCsv('import/contacts.csv');
console.log(contacts.length + ' rows imported');
```

**Notes:** The whole file is loaded at once — for big or binary files use `WebToApkFS.readBytes()`. Bytes that are not valid UTF-8 come back as the replacement character. A folder, a path with `..`, or no granted folder also returns `''`.

### `WebToApkFS.releaseFile`

**Example**

Gives back the long-term access your app got when the user picked a file with `WebToApkFS.pickFile`.

**Returns:** (sync) `true` when the grant was released, `false` otherwise (no grant, bad URI, not in the app). **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

```js
async function importOnce() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/csv');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (r.ok) importCsv(atob(r.base64));
  WebToApkFS.releaseFile(f.uri);       // done with it: do not keep the grant
}
```

When the user picks a new default file, give back the grant for the old one:

```js
async function chooseDefaultFile() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const old = localStorage.getItem('defaultFile');
  if (old && old !== f.uri) WebToApkFS.releaseFile(old);
  localStorage.setItem('defaultFile', f.uri);
}
```

A "Forget recent files" button that releases every file the app remembered:

```js
function forgetRecentFiles() {
  const recent = JSON.parse(localStorage.getItem('recentFiles') || '[]');
  let released = 0;
  for (const uri of recent) {
    if (WebToApkFS.releaseFile(uri)) released++;       // false: already gone or never granted
  }
  localStorage.removeItem('recentFiles');
  console.log('released', released, 'of', recent.length);
}
```

**Notes:** Android caps how many picked files one app may keep access to. An app that picks many files (an importer, a viewer) should release each one when finished. After release, `stat` / `readBytes` / `writeBytes` on that URI answer `not-found`. It does not delete the file.

### `WebToApkFS.rename`

**Example**

Renames a file or folder inside the granted folder.

**Returns:** a boolean, synchronously. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build, and a granted folder.

`newName` is only the new name, not a path. The entry stays in the same folder:

```js
function renameNote(path, newName) {
  if (!window.WebToApkFS) return false;
  var ok = window.WebToApkFS.rename(path, newName);     // 'notes/a.txt', 'b.txt' -> notes/b.txt
  if (!ok) alert('Could not rename. A file called "' + newName + '" may already exist.');
  return ok;
}

renameNote('notes/today.txt', 'journal.txt');
renameNote('notes/2025', 'archive-2025');               // folders work too
```

Rename without clashing: try "name (2)", "name (3)" … until a free name is found:

```js
function renameUnique(path, wanted) {
  if (!window.WebToApkFS) return '';
  var dot = wanted.lastIndexOf('.');
  var base = dot > 0 ? wanted.slice(0, dot) : wanted;
  var ext = dot > 0 ? wanted.slice(dot) : '';
  for (var n = 1; n <= 20; n++) {
    var name = n === 1 ? wanted : base + ' (' + n + ')' + ext;
    if (window.WebToApkFS.rename(path, name)) return name;
  }
  return '';
}

var finalName = renameUnique('photos/IMG_0042.jpg', 'Beach.jpg');
```

Save safely: write a temporary file first, then swap it in (rename refuses a name that is taken, so the old file is deleted just before):

```js
function saveSafely(folder, name, text) {
  var fs = window.WebToApkFS;
  if (!fs || !fs.hasAccess()) return false;
  var tmp = folder + '/' + name + '.tmp';
  if (!fs.writeText(tmp, text)) return false;           // the original is untouched
  fs.delete(folder + '/' + name);                       // false when it did not exist yet
  return fs.rename(tmp, name);
}

saveSafely('notes', 'today.txt', document.getElementById('editor').value);
```

**Notes:** It returns `false` when another entry already has `newName`, when `newName` is empty, `.`/`..`, or contains `/` or `\`, when the path does not exist, or when no folder is granted. To move a file between folders, read it, write it in the new place, then delete the old one.

### `WebToApkFS.requestAccess`

**Example**

Opens the system folder picker so the user can give the app one folder, kept across restarts.

**Returns:** a Promise that always resolves (never rejects) with `{ success, folderUri, requestId }`, plus `error` when `success` is false. `success` is a boolean; `folderUri` is the granted `content://…/tree/…` address, or `''`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Ask from a button the user taps, and show which folder is connected:

```js
document.getElementById('choose-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) {
    alert('Folder access is not available in this app.');
    return;
  }
  var r = await window.WebToApkFS.requestAccess();
  if (!r.success) {
    document.getElementById('status').textContent = 'No folder chosen.';
    return;
  }
  var name = decodeURIComponent(r.folderUri.split('/tree/')[1] || '').split(':').pop();
  document.getElementById('status').textContent = 'Saving to: ' + name;
  window.WebToApkFS.writeText('hello.txt', 'Connected at ' + new Date().toISOString());
});
```

Only ask when no folder is granted yet:

```js
async function ensureFolder() {
  if (!window.WebToApkFS) return false;
  if (window.WebToApkFS.hasAccess()) return true;
  var r = await window.WebToApkFS.requestAccess();
  return r.success;
}
```

A "Change folder" button that explains every way the request can fail:

```js
var folderErrors = {
  cancelled: 'No folder chosen.',
  busy: 'The folder picker is already open.',
  not_persisted: 'Android would not keep access to that folder. Try another one.',
  disabled: 'Folder access is switched off in this app.',
  no_picker: 'This phone has no folder picker.',
  foreign_origin: 'This page is not allowed to use folders.'
};

document.getElementById('change-folder').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  var r = await window.WebToApkFS.requestAccess();
  document.getElementById('status').textContent = r.success
    ? 'Folder changed.'
    : (folderErrors[r.error] || 'Could not open the folder picker.');
  if (r.success) renderFolder('');                      // your app: show the new folder
});
```

**Notes:** Call it from a tap, never on page load — the picker covers the app. `success: false` comes with `error`: `'cancelled'` (the user backed out), `'not_persisted'` (Android would not keep the grant), `'disabled'`, `'busy'` (a picker is already open for an earlier request — that one still gets its own answer), `'no_picker'` or `'foreign_origin'`. Choosing again replaces the earlier folder.

### `WebToApkFS.stat`

**Example**

Tells you a file's name, size, type and date without reading its contents.

**Returns:** (sync) `{ok:true, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` with `error` = `'not-found'`, `'disabled'`, `'foreign_origin'` or `'unsupported'`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

```js
async function showInfo() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const info = WebToApkFS.stat(f.uri);
  if (!info.ok) { alert('Cannot read file info: ' + info.error); return; }
  console.log(info.name, info.size + ' bytes', info.mime,
              new Date(info.lastModified).toLocaleString(),
              info.canWrite ? 'writable' : 'read only');
}
```

A path inside the folder the user granted with `WebToApkFS.requestAccess()` works too:

```js
const info = WebToApkFS.stat('photos/cover.jpg');
if (info.ok && !info.isDirectory) console.log('cover is', info.size, 'bytes');
```

Re-import a remembered file only when it changed since the last import:

```js
function importIfChanged(uri) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok) return 'gone: ' + info.error;
  const seen = JSON.parse(localStorage.getItem('lastImport') || '{}');
  if (seen.size === info.size && seen.lastModified === info.lastModified) return 'unchanged';
  const r = WebToApkFS.readAll(uri);
  if (!r.ok) return 'read failed: ' + r.error;
  importData(atob(r.base64));
  localStorage.setItem('lastImport', JSON.stringify({ size: info.size, lastModified: info.lastModified }));
  return 'imported';
}
```

**Notes:** `pathOrUri` is a `content://` URI from `pickFile()` or a path relative to the granted folder. A URI your app was never given returns `not-found`. `lastModified` is milliseconds since 1970 (0 when the provider does not say).

### `WebToApkFS.writeAll`

**Example**

Replaces a file's whole contents with your bytes. It splits the data into 4 MB pieces for you: the first piece truncates the file, the rest are patched in after it.

**Returns:** (sync) `{ok:true, bytesWritten}`, or the first failing piece's `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

Save UTF-8 text (any language) back to a file the user picked:

```js
function bytesToBase64(bytes) {
  let s = '';
  for (let i = 0; i < bytes.length; i += 0x8000) {
    s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  }
  return btoa(s);
}

async function saveNote(text) {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/plain');
  if (!f.ok) return;
  const r = WebToApkFS.writeAll(f.uri, bytesToBase64(new TextEncoder().encode(text)));
  if (r.ok) console.log('saved', r.bytesWritten, 'bytes');
  else alert('Could not save: ' + r.error);
}
```

Write a canvas image into the granted folder (the file is created if missing):

```js
const b64 = document.querySelector('canvas').toDataURL('image/png').split(',')[1];
const r = WebToApkFS.writeAll('drawings/sketch.png', b64);
if (!r.ok) alert('Could not save: ' + r.error);
```

Save a JSON backup into the granted folder, asking for a folder first if there is none, then confirm its size on disk:

```js
async function backup(data) {
  if (!window.WebToApkFS || !WebToApkFS.isAvailable()) return;
  if (!WebToApkFS.hasAccess()) {
    const a = await WebToApkFS.requestAccess();
    if (!a.success) return;
  }
  const bytes = new TextEncoder().encode(JSON.stringify(data));
  let bin = '';
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  const r = WebToApkFS.writeAll('backup.json', btoa(bin));
  if (!r.ok) { alert('Backup failed: ' + r.error); return; }
  const info = WebToApkFS.stat('backup.json');
  console.log('backup is', info.ok ? info.size : '?', 'bytes');
}
```

**Notes:** The whole input sits in memory while it is written, and the call blocks the page. If a later piece fails, the file already holds the earlier pieces — check `ok` and tell the user. To keep a file secret, use `WebToApkFS.writeSealed` instead.

### `WebToApkFS.writeBytes`

**Example**

Writes real bytes into a file and tells you how many landed. It can patch bytes in the middle, replace the whole file, or append to the end.

**Returns:** (sync) `{ok, bytesWritten, offset, size}` — `size` is read back from the file after the data is flushed to disk — or `{ok:false, error}`. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build.

**Patch** — change 4 bytes at position 100, keep the rest of the file:

```js
const r = WebToApkFS.writeBytes(fileUri, btoa('ABCD'), 100, 'patch');
if (r.ok) console.log('file is now', r.size, 'bytes');
else alert('Write failed: ' + r.error);
```

**Truncate** — replace the whole file (offset is ignored):

```js
const csv = 'name,score\nAsha,12\n';
const r = WebToApkFS.writeBytes('exports/scores.csv', btoa(csv), 0, 'truncate');
if (!r.ok) alert('Could not save: ' + r.error);
```

**Append** — add to the end (offset is ignored):

```js
const line = new Date().toISOString() + ' opened\n';
WebToApkFS.writeBytes('log.txt', btoa(line), 0, 'append');
```

**Notes:** `mode` is `'patch'` (default), `'truncate'` or `'append'`. At most 4 MB per call (`WebToApkFS.maxChunkSize()`); for bigger data use `WebToApkFS.writeAll`. A path inside the granted folder is created if it does not exist; a picked `content://` URI must be one the user chose. `btoa` only accepts Latin-1 text — for other text encode to bytes first (see `WebToApkFS.writeAll`). Errors: `disabled`, `foreign_origin` (the page on screen is not the app's own), `bad mode: x`, `bad base64`, `chunk too large (max N bytes)`, `not-found`, `cannot-open`, `short write`.

### `WebToApkFS.writeSealed`

**Example**

Writes a file encrypted with a hardware-backed key from `AppMintKeystore`. Deleting that key later makes the file unreadable for everyone — the only real "secure delete" on a phone.

**Returns:** (sync) `{ok:true, bytesWritten}` (encrypted size on disk), or `{ok:false, error}` — `'keystore-disabled'`, a keystore code such as `'no-such-key'`, `'auth-required'`, `'key-invalidated'`, or a file error. **Needs:** tick **Native Folder Access (SAF)** and **Secure Keys (encryption)** in Step 4 (Access) when you build.

```js
function saveSecret(text) {
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) {
    alert('Secure storage is not available in this build.');
    return false;
  }
  if (!AppMintKeystore.hasKey('vault')) {
    const k = AppMintKeystore.generateKey('vault', {});
    if (!k.ok) { alert('Could not create key: ' + k.error); return false; }
  }
  const bytes = new TextEncoder().encode(text);
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  const r = WebToApkFS.writeSealed('secret.bin', btoa(bin), 'vault');
  if (!r.ok) alert('Could not save: ' + r.error);
  return r.ok;
}
```

Delete it for real — destroy the key, then remove the file:

```js
AppMintKeystore.deleteKey('vault');   // secret.bin can never be decrypted again
WebToApkFS.delete('secret.bin');
```

Seal a photo the user chose, as raw bytes, into the folder they granted:

```js
async function sealPhoto(file) {                  // a File from <input type="file" accept="image/*">
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) return false;
  if (!WebToApkFS.hasAccess()) {
    const g = await WebToApkFS.requestAccess();
    if (!g.success) return false;
  }
  if (!AppMintKeystore.hasKey('vault')) AppMintKeystore.generateKey('vault', {});
  const dataUrl = await new Promise(function (resolve, reject) {
    const fr = new FileReader();
    fr.onload = function () { resolve(fr.result); };
    fr.onerror = reject;
    fr.readAsDataURL(file);
  });
  const b64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
  const r = WebToApkFS.writeSealed('photo-' + Date.now() + '.sealed', b64, 'vault');
  if (!r.ok) alert('Not saved: ' + r.error);
  return r.ok;
}
```

A diary key that works only for 5 minutes after the phone was unlocked, with each error explained:

```js
if (!AppMintKeystore.hasKey('diary')) {
  AppMintKeystore.generateKey('diary', { requireAuth: true, authValiditySeconds: 300 });
}

function saveDiary(entry) {
  const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(entry))));   // UTF-8 safe
  const r = WebToApkFS.writeSealed('diary.bin', b64, 'diary');
  if (r.ok) return true;
  if (r.error === 'auth-required') showMessage('Unlock your phone again, then tap Save.');
  else if (r.error === 'key-invalidated') showMessage('Your screen lock changed, so this key no longer works.');
  else showMessage('Could not save: ' + r.error);
  return false;
}
```

**Notes:** Do not "overwrite with zeros" to erase: phone flash storage keeps the old blocks. The file format is a list of length-prefixed AES-GCM pieces; read it back only with `WebToApkFS.readSealed` and the same alias. A key created with `{requireAuth:true}` answers `auth-required` until the user has unlocked recently.

### `WebToApkFS.writeText`

**Example**

Writes a UTF-8 text file into the granted folder, creating it (and missing parent folders) or replacing it.

**Returns:** a boolean, synchronously: `true` when the file was written. **Needs:** tick **Native Folder Access (SAF)** in Step 4 (Access) when you build, and a granted folder.

Save from a button:

```js
document.getElementById('save').addEventListener('click', async function () {
  if (!window.WebToApkFS) { alert('Saving to a folder is not available here.'); return; }
  if (!window.WebToApkFS.hasAccess()) {
    var r = await window.WebToApkFS.requestAccess();
    if (!r.success) return;                              // user cancelled
  }
  var ok = window.WebToApkFS.writeText('notes/today.txt', document.getElementById('editor').value);
  document.getElementById('status').textContent = ok ? 'Saved' : 'Could not save';
});
```

Save an object as JSON:

```js
if (window.WebToApkFS && window.WebToApkFS.hasAccess()) {
  window.WebToApkFS.writeText('data/settings.json', JSON.stringify({ theme: 'dark', size: 16 }, null, 2));
}
```

Add a line to a daily log (there is no append, so read, add, and write back):

```js
function logLine(message) {
  if (!window.WebToApkFS || !window.WebToApkFS.hasAccess()) return false;
  var path = 'logs/' + new Date().toISOString().slice(0, 10) + '.log';   // one file per day
  var old = window.WebToApkFS.readText(path);
  var line = new Date().toLocaleTimeString() + '  ' + message + '\n';
  return window.WebToApkFS.writeText(path, old + line);
}

logLine('Order #1042 marked as shipped');
```

**Notes:** An existing file is replaced completely — there is no append. `content` is turned into a string (`null` becomes `''`). It returns `false` when the path is an existing folder, contains `..`, or no folder is granted. The file type is taken from the extension, so the name on disk is exactly the name you gave.

### `AppMintKeystore.decrypt`

**Example**

Decrypts data made by `AppMintKeystore.encrypt` with the same key.

**Returns:** an object, at once (not a Promise): `{ok:true, base64}` (the plain data) or `{ok:false, error, detail}`. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function fromBase64(b64) { return decodeURIComponent(escape(atob(b64))); }

function readSecret() {
  var sealed = localStorage.getItem('secret');
  if (!sealed || !window.AppMintKeystore) return null;

  var r = window.AppMintKeystore.decrypt('vault', sealed);
  if (r.ok) return fromBase64(r.base64);

  switch (r.error) {
    case 'auth-required':   unlockThen(readSecretAndShow); break;   // unlock, then try again
    case 'no-such-key':
    case 'key-invalidated': alert('This secret can no longer be opened.'); break;
    default:                alert('Could not decrypt: ' + r.error);
  }
  return null;
}
```

For a file written with `WebToApkFS.writeSealed`, read it back with `WebToApkFS.readSealed(path, alias)` — same result shape.

Read back a sealed JSON object, unlocking first when the key needs it:

```js
function unlock() {
  return new Promise(function (resolve) {
    var id = 'unlock_' + Date.now();
    window.__webToApkAuth = window.__webToApkAuth || {};
    window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json)); };
    window.WebToApk.authenticateBiometricEx(id, JSON.stringify({
      title: 'Unlock your settings', strong: true, allowDeviceCredential: true
    }));
  });
}

async function loadSealedSettings() {
  var sealed = localStorage.getItem('settings');
  if (!sealed || !window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return null;
  var r = window.AppMintKeystore.decrypt('vault', sealed);
  if (!r.ok && r.error === 'auth-required' && (await unlock()).ok) {
    r = window.AppMintKeystore.decrypt('vault', sealed);
  }
  return r.ok ? JSON.parse(decodeURIComponent(escape(atob(r.base64)))) : null;
}
```

Open a list of sealed diary entries, keeping the ones that fail visible as "locked":

```js
function loadDiary() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return [];
  var entries = JSON.parse(localStorage.getItem('diary') || '[]');   // [{date, sealed}]
  return entries.map(function (e) {
    var r = window.AppMintKeystore.decrypt('diary', e.sealed);
    return {
      date: e.date,
      text: r.ok ? decodeURIComponent(escape(atob(r.base64))) : null,
      error: r.ok ? null : r.error                 // e.g. 'auth-required', 'no-such-key'
    };
  });
}
```

**Notes:** After `deleteKey` the data is gone for good; that is the point. A payload that was changed fails the GCM check. Other errors: `bad base64`, `disabled`, `foreign_origin`, `unsupported`.

### `AppMintKeystore.deleteKey`

**Example**

Destroys a secure key permanently. Everything encrypted with it can never be read again — by anyone.

**Returns:** `true` (deleted) or `false` (no such key, or the feature is off), at once. **Needs:** **Secure Keys (encryption)** turned on when you build.

Overwriting a file does not erase it on flash memory. Encrypt it, then destroy the key — that is a real delete:

```js
function deleteNoteForReal() {
  if (!window.AppMintKeystore || !window.WebToApkFS) return;
  if (!confirm('Delete this note forever?')) return;

  var gone = window.AppMintKeystore.deleteKey('vault');  // secret.bin is unreadable from now on
  window.WebToApkFS.delete('secret.bin');                // tidy up the file too
  alert(gone ? 'Deleted.' : 'Nothing to delete.');
}
```

"Erase everything" in settings: destroy every key this app made, then clear the page's storage:

```js
function eraseEverything() {
  var K = window.AppMintKeystore;
  if (!K || !K.isAvailable()) return;
  if (!confirm('Erase all protected data on this phone? This cannot be undone.')) return;

  var aliases = K.listKeys();
  aliases.forEach(function (alias) { K.deleteKey(alias); });
  localStorage.clear();
  alert('Erased ' + aliases.length + ' key(s).');
  location.reload();
}
```

Self-destruct the vault after too many wrong app PINs:

```js
var MAX_TRIES = 5;

function onWrongPin() {
  var tries = Number(localStorage.getItem('pinTries') || 0) + 1;
  localStorage.setItem('pinTries', String(tries));
  if (tries < MAX_TRIES) return alert((MAX_TRIES - tries) + ' tries left.');

  if (window.AppMintKeystore) window.AppMintKeystore.deleteKey('vault');  // vault unreadable from now on
  localStorage.removeItem('vaultData');
  localStorage.removeItem('pinTries');
  alert('Too many wrong PINs. The vault was erased.');
}
```

**Notes:** There is no undo and no recovery. If `isHardwareBacked(alias)` was `false`, the key lived in software — say so before you promise the user an irreversible delete.

### `AppMintKeystore.encrypt`

**Example**

Encrypts base64 data with a secure key. Store the result anywhere — it is useless without the key.

**Returns:** an object, at once (not a Promise): `{ok:true, base64}` (IV + ciphertext + tag) or `{ok:false, error, detail}`. **Needs:** **Secure Keys (encryption)** turned on when you build, and a key from `AppMintKeystore.generateKey`.

```js
function toBase64(text) { return btoa(unescape(encodeURIComponent(text))); }

function saveSecret(text) {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return false;
  var r = window.AppMintKeystore.encrypt('vault', toBase64(text));
  if (r.ok) {
    localStorage.setItem('secret', r.base64);
    return true;
  }
  if (r.error === 'auth-required') {
    // the key needs a fresh unlock: ask for the fingerprint, then call saveSecret again
    unlockThen(function () { saveSecret(text); });
  } else {
    alert('Could not encrypt: ' + r.error);
  }
  return false;
}
```

`unlockThen` can be built on `authenticateBiometricEx` (see that example).

To write an encrypted FILE of any size, use `WebToApkFS.writeSealed(path, base64, alias)` — it encrypts chunk by chunk with this same key.

Seal a small JSON object (a note, a token, settings) and keep it in localStorage:

```js
function sealJson(alias, obj) {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return null;
  var b64 = btoa(unescape(encodeURIComponent(JSON.stringify(obj))));
  var r = window.AppMintKeystore.encrypt(alias, b64);
  if (!r.ok) { console.warn('encrypt failed:', r.error, r.detail); return null; }
  return r.base64;
}

var sealed = sealJson('notes', { title: 'Shopping', items: ['milk', 'eggs'] });
if (sealed) localStorage.setItem('note-1', sealed);
```

Create the key the first time it is needed, then encrypt:

```js
function encryptWithKeyReady(alias, b64) {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return { ok: false, error: 'unsupported' };
  var r = ks.encrypt(alias, b64);
  if (!r.ok && r.error === 'no-such-key') {
    var g = ks.generateKey(alias);          // plain key, no unlock needed
    if (!g.ok) return g;
    r = ks.encrypt(alias, b64);
  }
  return r;                                 // {ok:true, base64} or {ok:false, error, detail}
}
```

**Notes:** Max 4 MB per call (`chunk too large`). Errors: `no-such-key`, `auth-required`, `key-invalidated` (the screen lock changed — the key is gone), `bad base64`, `disabled`, `foreign_origin`, `unsupported`.

### `AppMintKeystore.generateKey`

**Example**

Creates (or replaces) an AES-256-GCM key in the Android Keystore. The key never leaves secure hardware; your page only uses its name.

**Returns:** an object, at once (not a Promise): `{ok:true, alias, hardwareBacked}` or `{ok:false, error, detail}`. **Needs:** **Secure Keys (encryption)** turned on when you build (see `AppMintKeystore.isAvailable`).

```js
function createVault() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) {
    alert('Secure keys are not available in this app.');
    return;
  }
  var r = window.AppMintKeystore.generateKey('vault', {
    requireAuth: true,         // usable only after a fingerprint / PIN unlock...
    authValiditySeconds: 30    // ...for 30 seconds after it
  });
  if (!r.ok) { alert('Could not create the key: ' + r.error); return; }
  console.log('Hardware key:', r.hardwareBacked);
}
```

A plain key, no unlock needed:

```js
var r = window.AppMintKeystore.generateKey('notes');   // options default to {}
```

Demand the separate secure chip (StrongBox). It fails instead of giving you a weaker key:

```js
var r = window.AppMintKeystore.generateKey('wallet', { strongBox: true });
if (!r.ok && r.error === 'strongbox-unavailable') {
  alert('This phone has no secure chip.');
}
```

**Notes:** Alias: letters, digits, `_ - .`, up to 64 characters (`error:"bad alias"` otherwise). Using an existing alias replaces the key, and data sealed with the old one is lost. Other errors: `disabled`, `bad options json`, `foreign_origin`, `unsupported` (no bridge).

### `AppMintKeystore.hasKey`

**Example**

Tells you whether a secure key with this alias exists.

**Returns:** `true` / `false`, at once. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function startVault() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.isAvailable()) return;
  if (window.AppMintKeystore.hasKey('vault')) {
    showUnlockScreen();
  } else {
    showCreateVaultScreen();
  }
}
window.addEventListener('load', startVault);
```

Make sure a key exists before the first save, creating it only once:

```js
function ensureKey(alias) {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return false;
  if (ks.hasKey(alias)) return true;
  var r = ks.generateKey(alias);   // generateKey REPLACES an existing key, so only call it when hasKey says no
  if (!r.ok) console.warn('Could not create key:', r.error);
  return r.ok;
}

if (ensureKey('notes')) enableSaveButton();
```

Clean up sealed data whose key is gone (deleted, or the app data was restored without it):

```js
function checkVault() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return;
  var sealed = localStorage.getItem('secret');
  if (sealed && !ks.hasKey('vault')) {
    localStorage.removeItem('secret');   // without the key it can never be opened
    showMessage('Your saved secret could not be recovered and was removed.');
  }
}
window.addEventListener('load', checkVault);
```

**Notes:** Answers `false` when Secure Keys is off, so check `isAvailable()` first if you need to tell "no key yet" from "feature not in this app".

### `AppMintKeystore.isAvailable`

**Example**

Tells you whether hardware-backed secure keys can be used in this app.

**Returns:** `true` / `false`, at once (not a Promise). **Needs:** **Secure Keys (encryption)** turned on when you build. The `window.AppMintKeystore` object is installed (before your first script runs) whenever **Secure Keys** or **Native Folder Access (SAF)** is on; `isAvailable()` answers for Secure Keys.

```js
function secureKeysReady() {
  return !!window.AppMintKeystore && window.AppMintKeystore.isAvailable();
}

// Check when the user acts (the helper is added when the page finishes loading):
document.getElementById('vault-button').addEventListener('click', function () {
  if (!secureKeysReady()) {
    alert('The secure vault is not available in this app.');
    return;
  }
  openVault();
});
```

Hide every secure-vault control when the feature is not in this app:

```js
window.addEventListener('load', function () {
  var ok = !!window.AppMintKeystore && window.AppMintKeystore.isAvailable();
  document.querySelectorAll('.needs-secure-keys').forEach(function (el) {
    el.hidden = !ok;
  });
});
```

Tell "feature off" apart from "no key yet" when you decide which screen to show:

```js
function vaultState() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return 'off';   // a browser, or Secure Keys not on in this build
  return ks.hasKey('vault') ? 'ready' : 'new';
}

document.getElementById('vault-button').addEventListener('click', function () {
  var state = vaultState();
  if (state === 'off') alert('The secure vault is not available in this app.');
  else if (state === 'new') showCreateVaultScreen();
  else showUnlockScreen();
});
```

**Notes:** The helper is added after the page has loaded, so do not test for it in a script that runs while the page is still parsing — test in a click handler or after `load`. In a browser there is no `window.AppMintKeystore`; the check above returns `false` without throwing.

### `AppMintKeystore.isHardwareBacked`

**Example**

Tells you whether a key lives in the phone's secure hardware (TEE or StrongBox) rather than in software.

**Returns:** `true` / `false`, at once. `false` when the key does not exist. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function showKeyStrength() {
  if (!window.AppMintKeystore || !window.AppMintKeystore.hasKey('vault')) return;
  var hw = window.AppMintKeystore.isHardwareBacked('vault');
  document.getElementById('strength').textContent = hw
    ? 'Protected by secure hardware'
    : 'Protected by software (this phone has no secure hardware for keys)';
}
```

Only keep a high-value secret (a wallet seed, a recovery code) when its key is in secure hardware:

```js
function walletKeyIsHardware() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return false;
  if (!ks.hasKey('wallet')) {
    var r = ks.generateKey('wallet', { requireAuth: true });
    if (!r.ok) return false;
  }
  return ks.isHardwareBacked('wallet');
}

if (!walletKeyIsHardware()) {
  alert('This phone cannot keep the wallet key in secure hardware.');
}
```

List where every key lives, for a support report:

```js
function keyReport() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return { secureKeys: false };
  return {
    secureKeys: true,
    keys: ks.listKeys().map(function (alias) {
      return { alias: alias, hardware: ks.isHardwareBacked(alias) };
    })
  };
}
console.log(JSON.stringify(keyReport()));
```

**Notes:** `generateKey` also returns `hardwareBacked` right after it creates the key.

### `AppMintKeystore.listKeys`

**Example**

Lists the aliases of every secure key your pages created.

**Returns:** an array of strings, sorted, at once, e.g. `['notes', 'vault']`. `[]` when there are none or the feature is off. **Needs:** **Secure Keys (encryption)** turned on when you build.

```js
function renderKeyList() {
  var list = document.getElementById('key-list');
  list.innerHTML = '';
  if (!window.AppMintKeystore) return;
  window.AppMintKeystore.listKeys().forEach(function (alias) {
    var li = document.createElement('li');
    li.textContent = alias + (window.AppMintKeystore.isHardwareBacked(alias) ? ' (hardware)' : '');
    list.appendChild(li);
  });
}
```

Destroy every key on "Erase all my data", which makes everything sealed with them unreadable:

```js
function eraseAllKeys() {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return 0;
  var removed = 0;
  ks.listKeys().forEach(function (alias) {
    if (ks.deleteKey(alias)) removed++;
  });
  localStorage.clear();   // the sealed copies can no longer be opened anyway
  return removed;
}
```

Keep one key per signed-in user and remove the keys of users who signed out:

```js
function pruneUserKeys(currentUserId) {
  var ks = window.AppMintKeystore;
  if (!ks || !ks.isAvailable()) return;
  var keep = 'user-' + currentUserId;   // aliases: letters, digits, _ - . only
  ks.listKeys().forEach(function (alias) {
    if (alias.indexOf('user-') === 0 && alias !== keep) ks.deleteKey(alias);
  });
}
```

**Notes:** Only keys made through the bridge are listed; the app's own internal keys are never shown.

### `WebToApkAuth.signInWithGoogle`

**Example**

Signs the user in with their Google account through Android's own account picker, and gives you a Google ID token. Google blocks its sign-in page inside app WebViews ("disallowed_useragent"); this native path is not blocked.

**Returns:** a Promise of `{ok:true, idToken, email, displayName, profilePictureUri}`. It REJECTS with an `Error` whose `message` is the code (`cancelled`, `google_play_services_unavailable`, `missing_web_client_id`, `failed`, …). **Needs:** your OAuth **Web client ID** (ends in `.apps.googleusercontent.com`), and the app's SHA-1 and SHA-256 fingerprints registered with your Google / Firebase project (from the `_signkey_info.txt` file of your build, and Play's App signing key if you publish on Play). No build switch.

With Firebase Authentication (compat SDK):

```js
var WEB_CLIENT_ID = '1234567890-abc.apps.googleusercontent.com';

document.getElementById('google-button').addEventListener('click', async function () {
  if (!window.WebToApkAuth) {
    alert('Google sign-in works in the app.');     // or run your normal web sign-in here
    return;
  }
  try {
    var r = await window.WebToApkAuth.signInWithGoogle(WEB_CLIENT_ID);
    var cred = firebase.auth.GoogleAuthProvider.credential(r.idToken);
    await firebase.auth().signInWithCredential(cred);
    showWelcome(r.displayName || r.email);
  } catch (err) {
    if (err.message === 'cancelled') return;                   // user closed the picker
    if (err.message === 'google_play_services_unavailable') {
      alert('No Google account or Google Play services on this phone.');
      return;
    }
    alert('Google sign-in failed: ' + err.message);
  }
});
```

With your own server — send the ID token and verify it there (never trust it in the page alone):

```js
async function signInWithMyServer() {
  var r = await window.WebToApkAuth.signInWithGoogle(WEB_CLIENT_ID);
  await fetch('https://your-domain.com/auth/google', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ idToken: r.idToken })
  });
}
```

With Supabase Auth (hand the ID token to `signInWithIdToken`):

```js
async function signInWithSupabase() {
  if (!window.WebToApkAuth) {
    return supabase.auth.signInWithOAuth({ provider: 'google' });   // normal web sign-in in a browser
  }
  try {
    var r = await window.WebToApkAuth.signInWithGoogle(WEB_CLIENT_ID);
    var res = await supabase.auth.signInWithIdToken({ provider: 'google', token: r.idToken });
    if (res.error) throw res.error;
    showWelcome(res.data.user.email);
  } catch (err) {
    if (err.message !== 'cancelled') alert('Google sign-in failed: ' + err.message);
  }
}
```

**Notes:** Pass the Web client ID. With no argument the helper uses `window.firebaseConfig._googleWebClientId` if your page sets it, then the Web client in the `google-services.json` you uploaded for this app (only when that file is your own Firebase project with Google sign-in enabled — never an Appwright Push build); with none of these it rejects with `missing_web_client_id`. A page that is not the app's own (an external site open inside the app) is refused with `foreign_origin`. Use the WEB client ID, not an Android client ID. "No credentials available" usually means the SHA-1 is not registered. This helper exists only in the app — in a browser use your normal web sign-in. Call it from a button tap.

### `WebToApkAuth.signOutGoogle`

**Example**

Clears Android's saved Google sign-in choice, so the next `signInWithGoogle` shows the account picker again instead of silently reusing the last account.

**Returns:** a Promise of `{ok:true}`; rejects with an `Error` whose `message` says what went wrong. **Needs:** nothing.

Call it together with your own sign-out:

```js
document.getElementById('sign-out').addEventListener('click', async function () {
  await firebase.auth().signOut();            // your normal sign-out (Firebase here)
  if (window.WebToApkAuth) {
    try {
      await window.WebToApkAuth.signOutGoogle();
    } catch (err) {
      console.log('Could not clear the Google choice: ' + err.message);
    }
  }
  showSignInScreen();
});
```

A "Switch account" button: clear the saved choice, then sign in again so the user picks another account:

```js
async function switchGoogleAccount() {
  if (!window.WebToApkAuth) return;
  await firebase.auth().signOut();
  try { await window.WebToApkAuth.signOutGoogle(); } catch (err) { /* nothing saved to clear */ }
  try {
    var r = await window.WebToApkAuth.signInWithGoogle('1234567890-abc.apps.googleusercontent.com');
    await firebase.auth().signInWithCredential(firebase.auth.GoogleAuthProvider.credential(r.idToken));
  } catch (err) {
    if (err.message !== 'cancelled') alert('Google sign-in failed: ' + err.message);
    showSignInScreen();
  }
}
```

Sign out of your own server first, then clear the Google choice without letting a failure block the logout:

```js
async function signOutEverywhere() {
  await fetch('https://your-domain.com/auth/logout', { method: 'POST', credentials: 'include' });
  localStorage.removeItem('profile');
  if (window.WebToApkAuth) {
    var r = await window.WebToApkAuth.signOutGoogle()
      .catch(function (err) { return { ok: false, error: err.message }; });
    if (!r.ok) console.log('Google choice not cleared: ' + r.error);
  }
  location.replace('/login');
}
```

**Notes:** This does not end your app's own session — sign the user out of your backend yourself. It only resets which Google account the phone offers next time.

### `AppMint.copyProtection`

**Example**

`true` when the app was built with **Copy protection** switched on; absent (`undefined`) otherwise. Read-only — a page cannot turn protection on or off, only ask whether it is on.

**Returns:** `true`, or `undefined` when protection is off (and in a browser). **Needs:** **Copy protection** switched on in Step 3 (Integrate), under **Display Options**, when you build. Default off.

With protection on, the app's text cannot be selected, copied or cut; copy / cut, the `contextmenu` event and Ctrl/Cmd + C, X and A are cancelled outside editable fields; and no long-press menu appears. Inputs, textareas, selects and `contenteditable` regions stay fully editable, so forms keep working.

Hide your own "Copy" buttons when protection is on — they would copy nothing and look broken:

```js
const protectedApp = !!(window.AppMint && window.AppMint.copyProtection === true);

document.querySelectorAll('[data-copy-button]').forEach((btn) => {
  btn.hidden = protectedApp;
});
```

Or pick the wording of a hint once, at start-up:

```js
const hint = document.getElementById('share-hint');
hint.textContent = (window.AppMint && window.AppMint.copyProtection === true)
  ? 'Tap Share to send this lesson to a friend.'
  : 'Select any text to copy it, or tap Share.';
```

Combine it with the screenshot lock for a page that must not leave the app:

```js
if (window.AppMint && AppMint.copyProtection === true && window.WebToApk && WebToApk.setSecureScreen) {
  WebToApk.setSecureScreen(true);                    // also blocks screenshots and recents thumbnails
  document.body.classList.add('protected');
}
```

**Notes:** The long-press menu on links and images (open, copy, share or download) is a separate build switch, **Long-press menu on links and images**, in the same place. It is off by default, so a long press on a link does nothing unless you turn it on; copy protection always keeps it off. A page can still refuse the menu on one element by cancelling `contextmenu` (`el.addEventListener('contextmenu', (e) => e.preventDefault())`). Protection stops copying inside the app; it does not stop a screenshot — for that, see `WebToApk.setSecureScreen(true)`.

### `AppMint.downloadFile`

**Example**

Saves a file your page made — from a base64 string, a `data:` URL, a `blob:` URL or a Blob — with the file name you choose. Android's "Save as…" sheet opens with that name filled in.

**Returns:** (sync) `true` once the save was handed to Android (`false` only for a `data:` URL it could not parse). Throws `TypeError` if `data` is not a Blob or string. The user still picks the folder, and can cancel. **Needs:** nothing — it is always present in the app.

Base64 string plus a MIME type:

```js
function exportCsv(rows) {
  const csv = rows.map(function (r) { return r.join(','); }).join('\n');
  const b64 = btoa(unescape(encodeURIComponent(csv)));          // UTF-8 safe
  if (window.AppMint && typeof AppMint.downloadFile === 'function') {
    AppMint.downloadFile(b64, 'Ledger_Q3.csv', 'text/csv');
  } else {
    const a = document.createElement('a');                       // a normal browser
    a.href = 'data:text/csv;base64,' + b64;
    a.download = 'Ledger_Q3.csv';
    a.click();
  }
}
```

A `data:` URL (the MIME type comes from the URL) or a Blob:

```js
const png = document.querySelector('canvas').toDataURL('image/png');
AppMint.downloadFile(png, 'Chart.png');

const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
AppMint.downloadFile(blob, 'Backup.json');                        // same as AppMint.saveBlob
```

A base64 PDF your server returns in JSON; a name without an extension gets `.pdf` from the MIME type:

```js
async function downloadInvoice(id) {
  const res = await fetch('/api/invoices/' + id + '?format=base64');
  const body = await res.json();                                  // { base64: 'JVBERi0x…' }
  if (!window.AppMint || typeof AppMint.downloadFile !== 'function') {
    location.href = 'data:application/pdf;base64,' + body.base64;
    return;
  }
  AppMint.downloadFile(body.base64, 'Invoice_' + id, 'application/pdf');   // saved as Invoice_<id>.pdf
}
```

A `blob:` URL you already made for an on-screen preview:

```js
// earlier: preview.src = URL.createObjectURL(editedImageBlob);
document.getElementById('save-photo').addEventListener('click', function () {
  const preview = document.getElementById('preview');
  if (window.AppMint && typeof AppMint.downloadFile === 'function') {
    AppMint.downloadFile(preview.src, 'Edited_Photo.jpg');
  }
});
```

**Notes:** Only the last part of `fileName` is used (no folders). A name without an extension gets one from the MIME type. The third argument is used only for a plain base64 string. If the phone has no "Save as…" screen at all, the file is written straight into Downloads. Your normal `<a download>` code also works — see `downloads`.

### `AppMint.enableNotifications`

**Example**

"Turn on notifications" in one call, for users who denied or skipped Android's notification prompt: the permission dialog while Android can still show it, otherwise this app's notification settings — and the new state when the user comes back.

**Returns:** `AppMint.enableNotifications()` — a Promise that always resolves `{state, changed, openedSettings}`; `state` is `'granted' | 'prompt' | 'blocked' | 'unavailable'`. `AppMint.getNotificationState()` — a Promise of that state. `AppMint.openNotificationSettings()` — a Promise of `true` when the settings screen opened. **Needs:** call `enableNotifications()` from a button the user tapped; notifications need **Notifications** (or Push / Reminders) in the build.

```js
// A banner that only shows while notifications are off, and turns them on in one tap.
async function setupNotifBanner() {
  if (!(window.AppMint && AppMint.enableNotifications)) return;   // a browser: no banner
  var banner = document.getElementById('notif-banner');
  var state = await AppMint.getNotificationState();
  banner.hidden = !(state === 'prompt' || state === 'blocked');
  document.getElementById('notif-turn-on').onclick = async function () {
    var r = await AppMint.enableNotifications();
    banner.hidden = r.state === 'granted';
    if (r.state === 'blocked') showToast('Switch on "Allow notifications" to get updates.');
  };
}
setupNotifBanner();
```

A plain "Notification settings" link in your own settings page:

```js
document.getElementById('notif-settings').onclick = async function () {
  var opened = window.AppMint && AppMint.openNotificationSettings && await AppMint.openNotificationSettings();
  if (!opened) showToast('Open Settings > Apps > this app > Notifications.');
};
```

**Notes:** a push or in-app message button whose link is `appmint://notifications/enable` runs the same flow without any page code (the page hears `appmint:notifications`). The app's push audience ("Notifications on / off") updates as soon as the user turns them on.

### `AppMint.getNotificationState`

**Example**

Documented together with `AppMint.enableNotifications` — the example there shows this one too.

"Turn on notifications" in one call, for users who denied or skipped Android's notification prompt: the permission dialog while Android can still show it, otherwise this app's notification settings — and the new state when the user comes back.

**Returns:** `AppMint.enableNotifications()` — a Promise that always resolves `{state, changed, openedSettings}`; `state` is `'granted' | 'prompt' | 'blocked' | 'unavailable'`. `AppMint.getNotificationState()` — a Promise of that state. `AppMint.openNotificationSettings()` — a Promise of `true` when the settings screen opened. **Needs:** call `enableNotifications()` from a button the user tapped; notifications need **Notifications** (or Push / Reminders) in the build.

```js
// A banner that only shows while notifications are off, and turns them on in one tap.
async function setupNotifBanner() {
  if (!(window.AppMint && AppMint.enableNotifications)) return;   // a browser: no banner
  var banner = document.getElementById('notif-banner');
  var state = await AppMint.getNotificationState();
  banner.hidden = !(state === 'prompt' || state === 'blocked');
  document.getElementById('notif-turn-on').onclick = async function () {
    var r = await AppMint.enableNotifications();
    banner.hidden = r.state === 'granted';
    if (r.state === 'blocked') showToast('Switch on "Allow notifications" to get updates.');
  };
}
setupNotifBanner();
```

A plain "Notification settings" link in your own settings page:

```js
document.getElementById('notif-settings').onclick = async function () {
  var opened = window.AppMint && AppMint.openNotificationSettings && await AppMint.openNotificationSettings();
  if (!opened) showToast('Open Settings > Apps > this app > Notifications.');
};
```

**Notes:** a push or in-app message button whose link is `appmint://notifications/enable` runs the same flow without any page code (the page hears `appmint:notifications`). The app's push audience ("Notifications on / off") updates as soon as the user turns them on.

### `AppMint.getOpenedFile`

**Example**

Gives your page the file that was shared to the app, or opened with it ("Open with…"), however late your page starts.

**Returns:** `Promise<object | null>` — `null` when the app was opened normally. The object has `token, name, mimeType, size, kind, url, file, getFile()`, plus `text` (text files up to 32 MB) or `base64` (other data files up to 8 MB) when cheap. `kind` is `'image' | 'video' | 'audio' | 'document' | 'data'`. **Needs:** turn on **Receive Shares From Other Apps** when you build and tick the kinds of files your app accepts.

```js
async function handleStartFile() {
  if (!window.AppMint || typeof AppMint.getOpenedFile !== 'function') return;  // not in the app, or feature off
  const f = await AppMint.getOpenedFile();
  if (!f) return;                                  // opened normally
  console.log(f.name, f.mimeType, f.size, f.kind);
  if (f.kind === 'video') {
    document.querySelector('video').src = f.url;   // streams from disk, can seek
  } else if (f.kind === 'image') {
    document.querySelector('img').src = f.url;
  } else if (f.kind === 'document') {
    const file = await f.getFile();                // a real File
    await upload(file);
  } else {
    const text = typeof f.text === 'string' ? f.text : await f.file.text();
    importData(text);
  }
}
handleStartFile();
```

Or listen — the event (and the optional `window.onAppMintFileOpen` hook) fires once per new file, with the same object as `detail`:

```js
window.onAppMintFileOpen = function (f) { console.log('hook:', f.name); };

window.addEventListener('appmint:fileopen', function (e) {
  const f = e.detail;             // {token, name, mimeType, size, kind, url, file, getFile, text?, base64?}
  showFile(f);
});
window.addEventListener('appmint:fileopenerror', function (e) {
  alert('Could not read ' + e.detail.name + ': ' + e.detail.error);   // detail: {name, error}
});
```

In a React app, a small hook picks up the file however late the component mounts, and keeps following new ones:

```js
import { useEffect, useState } from 'react';

export function useOpenedFile() {
  const [file, setFile] = useState(null);
  useEffect(function () {
    const A = window.AppMint;
    if (!A || typeof A.getOpenedFile !== 'function') return;
    let alive = true;
    A.getOpenedFile().then(function (f) { if (alive && f) setFile(f); });
    function onOpen(e) { setFile(e.detail); }
    window.addEventListener('appmint:fileopen', onOpen);
    return function () { alive = false; window.removeEventListener('appmint:fileopen', onOpen); };
  }, []);
  return file;
}
```

**Notes:** Images, videos, audio and any file over 64 MB come only as `url` (`file` is `null`); call `f.getFile()` when you really need the bytes. Data files and documents also come as `f.file`. The file is kept for your page until you ask, so an SPA that mounts late still gets it; the latest one is returned each time. When the app is already open and another file arrives, the event fires again. Call `AppMint.releaseOpenedFile(f)` when done to free the cached copy.

### `AppMint.getOpenedFiles`

**Example**

Gives your page every file shared to, or opened with, the app during this session — useful when the user shares several photos at once.

**Returns:** `Promise<Array>` of the same objects `AppMint.getOpenedFile()` returns (`token, name, mimeType, size, kind, url, file, getFile()`, and `text` / `base64` when cheap). An empty array when nothing was received. **Needs:** turn on **Receive Shares From Other Apps** when you build and tick the kinds of files your app accepts.

```js
async function importShared() {
  if (!window.AppMint || typeof AppMint.getOpenedFiles !== 'function') return;
  const files = await AppMint.getOpenedFiles();
  const gallery = document.getElementById('gallery');
  files.forEach(function (f) {
    if (f.kind === 'image') {
      const img = document.createElement('img');
      img.src = f.url;
      img.alt = f.name;
      gallery.appendChild(img);
    } else {
      console.log('received', f.name, f.size + ' bytes');
    }
  });
}
importShared();
```

New files that arrive while the page is open also fire `appmint:fileopen`, one event per file:

```js
window.addEventListener('appmint:fileopen', function (e) { addToGallery(e.detail); });
```

Uploading everything the user shared in one request, as real files:

```js
async function uploadShared() {
  if (!window.AppMint || typeof AppMint.getOpenedFiles !== 'function') return;
  const files = await AppMint.getOpenedFiles();
  if (!files.length) return;
  const form = new FormData();
  for (const f of files) {
    try {
      form.append('files', await f.getFile(), f.name);   // url-only media is fetched here
    } catch (e) {
      console.warn('skipped', f.name, e.message);        // released earlier
    }
  }
  await fetch('/api/upload', { method: 'POST', body: form });
}
```

**Notes:** The list is a copy; it grows as more files arrive. Files you have released with `AppMint.releaseOpenedFile(f)` stay in the list, but their `url` no longer works.

### `AppMint.openNotificationSettings`

**Example**

Documented together with `AppMint.enableNotifications` — the example there shows this one too.

"Turn on notifications" in one call, for users who denied or skipped Android's notification prompt: the permission dialog while Android can still show it, otherwise this app's notification settings — and the new state when the user comes back.

**Returns:** `AppMint.enableNotifications()` — a Promise that always resolves `{state, changed, openedSettings}`; `state` is `'granted' | 'prompt' | 'blocked' | 'unavailable'`. `AppMint.getNotificationState()` — a Promise of that state. `AppMint.openNotificationSettings()` — a Promise of `true` when the settings screen opened. **Needs:** call `enableNotifications()` from a button the user tapped; notifications need **Notifications** (or Push / Reminders) in the build.

```js
// A banner that only shows while notifications are off, and turns them on in one tap.
async function setupNotifBanner() {
  if (!(window.AppMint && AppMint.enableNotifications)) return;   // a browser: no banner
  var banner = document.getElementById('notif-banner');
  var state = await AppMint.getNotificationState();
  banner.hidden = !(state === 'prompt' || state === 'blocked');
  document.getElementById('notif-turn-on').onclick = async function () {
    var r = await AppMint.enableNotifications();
    banner.hidden = r.state === 'granted';
    if (r.state === 'blocked') showToast('Switch on "Allow notifications" to get updates.');
  };
}
setupNotifBanner();
```

A plain "Notification settings" link in your own settings page:

```js
document.getElementById('notif-settings').onclick = async function () {
  var opened = window.AppMint && AppMint.openNotificationSettings && await AppMint.openNotificationSettings();
  if (!opened) showToast('Open Settings > Apps > this app > Notifications.');
};
```

**Notes:** a push or in-app message button whose link is `appmint://notifications/enable` runs the same flow without any page code (the page hears `appmint:notifications`). The app's push audience ("Notifications on / off") updates as soon as the user turns them on.

### `AppMint.passkey`

**Example**

Passkeys (sign in with fingerprint, face or screen lock — no password) through Android's passkey manager, using the JSON options your passkey server makes.

**Returns:** `create(optionsJSON)` → Promise of the RegistrationResponseJSON; `get(optionsJSON)` → Promise of the AuthenticationResponseJSON (send either to your server to verify). Both reject with a `DOMException` (`err.name`, and `err.reason` = a short code). `isAvailable()` → boolean, `origin()` → string, `assetLinks()` → array — all at once. **Needs:** Android 9+ with Google Play services, a passkey server, and the two set-up steps below. No build switch.

Sign in:

```js
async function signInWithPasskey() {
  if (!window.AppMint || !window.AppMint.passkey || !window.AppMint.passkey.isAvailable()) {
    alert('Passkeys are not available on this phone.');
    return;
  }
  var options = await (await fetch('https://your-domain.com/passkey/login-options')).json();
  var answer;
  try {
    answer = await window.AppMint.passkey.get(options);   // rpId must be your domain
  } catch (err) {
    if (err.name === 'NotAllowedError') return;          // cancelled, or no passkey on this phone
    alert('Passkey problem: ' + err.message);
    return;
  }
  var res = await fetch('https://your-domain.com/passkey/login-verify', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer)
  });
  if (res.ok) showAccount();
}
```

Create one (after the user signed in some other way):

```js
async function addPasskey() {
  var options = await (await fetch('https://your-domain.com/passkey/register-options')).json();
  try {
    var answer = await window.AppMint.passkey.create(options);
    await fetch('https://your-domain.com/passkey/register-verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer)
    });
  } catch (err) {
    if (err.name === 'InvalidStateError') alert('You already have a passkey here.');
    else if (err.name !== 'NotAllowedError') alert('Passkey problem: ' + err.message);
  }
}
```

Set up once — run this in the app installed FROM Google Play (Play signs with its own key):

```js
console.log(JSON.stringify(window.AppMint.passkey.assetLinks(), null, 2));
// save as https://your-domain.com/.well-known/assetlinks.json
console.log(window.AppMint.passkey.origin());
// android:apk-key-hash:...  — add to your server's expected origins
```

**Notes:** `err.name` / `err.reason`: `NotAllowedError` (`cancelled`, `no_credential`, `interrupted`), `InvalidStateError` (already exists), `SecurityError` with `not_linked` (assetlinks.json does not list the app) or `rp_mismatch` (the rpId is not this page's domain), `NotSupportedError` / `unavailable` (Android < 9 or no Play services), `TypeError` / `invalid_options` (a page bundled in the app has no domain, so set `rp.id` / `rpId` to your domain). `window.AppMint.passkey` exists only in the app; in a browser use `navigator.credentials` (the same code works in the app too).

### `AppMint.releaseOpenedFile`

**Example**

Deletes the app's cached copy of a file that was shared to or opened with the app, once your page is done with it.

**Returns:** nothing. **Needs:** turn on **Receive Shares From Other Apps** when you build.

```js
async function importAndForget() {
  if (!window.AppMint || typeof AppMint.getOpenedFile !== 'function') return;
  const f = await AppMint.getOpenedFile();
  if (!f) return;
  const file = await f.getFile();        // take your own copy of the bytes first
  await saveToMyDatabase(file);
  AppMint.releaseOpenedFile(f);          // then free the cache on disk
}
```

A shared video plays from `f.url`, so release it only when playback is over:

```js
async function playShared() {
  if (!window.AppMint || typeof AppMint.getOpenedFile !== 'function') return;
  const f = await AppMint.getOpenedFile();
  if (!f || f.kind !== 'video') return;
  const video = document.querySelector('video');
  video.src = f.url;
  video.play();
  video.addEventListener('ended', function () {
    video.removeAttribute('src');
    video.load();
    AppMint.releaseOpenedFile(f);        // the url stops working from here
  }, { once: true });
}
```

After importing a batch of shared photos, free every cached copy at once:

```js
async function importAllPhotos() {
  if (!window.AppMint || typeof AppMint.getOpenedFiles !== 'function') return;
  const files = await AppMint.getOpenedFiles();
  for (const f of files) {
    if (f.kind !== 'image') continue;
    const file = await f.getFile();      // fetched from f.url
    await savePhoto(file);
    AppMint.releaseOpenedFile(f);
  }
}
```

**Notes:** Pass the object you got (it needs its `token`). A `File` you already pulled stays valid; the `url` stops working, and `getFile()` rejects unless it had already fetched the File. Do not release a video you are still playing from `f.url`. The app also clears these copies when its screen is closed, so releasing early is about disk space during a long session.

### `AppMint.requestReview`

**Example**

Asks Google Play to show its own rating sheet inside the app (Play In-App Review) and tells you what happened.

**Returns:** a Promise that always resolves `{result, openedStore, message?}`. `result` is `'shown'` (Play put its sheet up — it never says whether the user rated), `'not_from_play'` (this install did not come from Google Play: a sideloaded APK or a test install, where Play never shows the sheet), `'quota'` (Play chose not to show it now: its quota for this user, or they already reviewed) or `'error'` (Play could not be asked: no Play Store, offline). With `{openStore: true}` the app's Play listing opens when the sheet did not show, and `openedStore` is `true`. **Needs:** nothing to switch on.

**Any code:**

```js
// A "Rate us" button: the sheet when Play shows it, the Play listing otherwise.
document.getElementById('rate-btn').onclick = async function () {
  if (!(window.AppMint && AppMint.requestReview)) { location.href = 'https://play.google.com/store/apps/details?id=com.example.app'; return; }
  var r = await AppMint.requestReview({ openStore: true });
  if (r.result === 'shown' || r.openedStore) showToast('Thank you!');
};
```

Ask after a success moment instead of from a button, without ever leaving the app:

```js
async function onOrderDelivered(count) {
  if (count !== 3 || !(window.AppMint && AppMint.requestReview)) return;
  var r = await AppMint.requestReview();      // no openStore: nothing opens if Play says no
  if (r.result === 'not_from_play') console.log('Test install: Play only shows the sheet for Play installs');
}
```

When the creator switched on **Ask users to rate the app** (Display step), the app asks by itself at a calm moment and tells the page what happened:

```js
window.addEventListener('appmint:review', function (e) {
  // e.detail = { result, openedStore: false, source: 'auto' }
  analytics.track('rate_prompt', { result: e.detail.result });
});
```

**Notes:** Google Play rate-limits the sheet and decides by itself whether it appears; never reward a review or promise the sheet on a button. Test it with an internal-testing track install from Play — a sideloaded APK always answers `not_from_play`.

### `AppMint.saveBlob`

**Example**

Saves a Blob (or File) with the name you choose. Android's "Save as…" sheet opens with that name filled in.

**Returns:** (sync) `true` once the save was handed to Android. Throws `TypeError` when the first argument is not a Blob. **Needs:** nothing — it is always present in the app.

```js
async function saveReport() {
  const blob = await makePdfBlob();                    // e.g. jsPDF: doc.output('blob')
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Site_Report_2026-08.pdf');
    return;
  }
  const url = URL.createObjectURL(blob);               // a normal browser
  const a = document.createElement('a');
  a.href = url;
  a.download = 'Site_Report_2026-08.pdf';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
}
```

A `File` brings its own name, so the name can be left out:

```js
const file = new File(['name,score\n'], 'Scores.csv', { type: 'text/csv' });
AppMint.saveBlob(file);            // saved as Scores.csv
```

A file from your server that needs a sign-in header, saved under a name you choose:

```js
async function saveStatement(month, token) {
  const res = await fetch('/api/statements/' + month, {
    headers: { Authorization: 'Bearer ' + token }
  });
  if (!res.ok) { alert('Could not fetch the statement (' + res.status + ')'); return; }
  const blob = await res.blob();
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Statement_' + month + '.pdf');
  }
}
```

Several files bundled into one ZIP (here with JSZip) and saved in one go:

```js
async function exportAll(notes) {
  const zip = new JSZip();
  notes.forEach(function (n) { zip.file(n.title + '.txt', n.body); });
  zip.file('index.json', JSON.stringify(notes.map(function (n) { return n.title; })));
  const blob = await zip.generateAsync({ type: 'blob' });
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Notes_' + new Date().toISOString().slice(0, 10) + '.zip');
  }
}
```

**Notes:** The Blob is read into memory and sent to Android in one piece, so very large blobs (hundreds of MB) may run out of memory. The user picks the folder and may cancel; nothing tells the page which one happened.

### `AppMint.scanCode`

**Example**

Opens Google's QR code and barcode scanner screen and gives you what was scanned. One call — no camera permission, no `<video>`, no UI of your own.

**Returns:** a Promise that always resolves: `{ok: true, value, displayValue, format, type, …}` or `{ok: false, code, message}`. **Needs:** nothing to switch on. The phone needs Google Play services.

**Any code:**

```js
async function scan() {
  if (!(window.AppMint && AppMint.scanCode)) { alert('Open this page in the app to scan'); return; }
  var r = await AppMint.scanCode();
  if (!r.ok) {
    if (r.code === 'cancelled') return;                          // the user closed the scanner
    if (r.code === 'unavailable') alert('The scanner is not available on this phone');
    else if (r.code === 'busy') alert('A scan is already open');
    else alert('Scan failed: ' + r.message);                     // 'failed'
    return;
  }
  console.log(r.value, r.format, r.type);   // "TICKET-4821", "qr_code", "text"
}
```

**Only some formats**, and a "type it in" option for damaged labels:

```js
var r = await AppMint.scanCode({
  formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'],   // shop barcodes
  manualInput: true,                                // lets the user type the code
  autoZoom: true                                    // default true
});
if (r.ok) lookUpProduct(r.value);
```

**Ready-made parts** — only the part that matches `r.type` is present:

```js
var r = await AppMint.scanCode({ formats: ['qr_code'] });
if (r.ok) {
  if (r.type === 'url')     openLink(r.url.url);                      // url: { url, title }
  if (r.type === 'wifi')    showWifi(r.wifi.ssid, r.wifi.password, r.wifi.encryption); // 'wpa'|'wep'|'open'
  if (r.type === 'phone')   callNumber(r.phone);                      // a string
  if (r.type === 'email')   writeMail(r.email.address, r.email.subject, r.email.body);
  if (r.type === 'sms')     writeSms(r.sms.phoneNumber, r.sms.message);
  if (r.type === 'geo')     showMap(r.geo.lat, r.geo.lng);
  if (r.type === 'contact') addContact(r.contact.name, r.contact.organization, r.contact.phones, r.contact.emails);
}
```

**Notes:** Format names: `qr_code`, `aztec`, `codabar`, `code_39`, `code_93`, `code_128`, `data_matrix`, `ean_8`, `ean_13`, `itf`, `pdf417`, `upc_a`, `upc_e` (unknown names are ignored; none = all formats). `type` is one of `url`, `wifi`, `text`, `phone`, `email`, `sms`, `geo`, `contact`, `calendar`, `isbn`, `product`, `driver_license`, `unknown`. Error codes: `cancelled` (closed, including with Back), `unavailable` (no Play services, or the scanner could not be downloaded), `busy` (a scan is already open), `failed`. The first scan on a phone can take a few seconds while Play services downloads the scanner. AI-built apps use `scanCode()` from `@/lib/appmintNative`, which also scans on the web.

### `AppMint.setBackHandler`

**Example**

Gives your page the first say when the user presses the phone's Back button (or uses the back gesture). Needed by apps that change screens by showing and hiding elements, where Android's history is empty and Back would exit the app.

**Returns:** nothing. Your `handler` must return `true` (handled, stay here) or `false` (let the app go back / exit). **Needs:** nothing. Installed at document start, so you can register it in your first script.

```js
if (window.AppMint && AppMint.setBackHandler) {
  AppMint.setBackHandler(function () {
    if (isMenuOpen)               { closeMenu();  return true; }   // consumed
    if (currentScreen !== 'home') { goTo('home'); return true; }
    return false;   // nothing to undo: normal Back (history back, exit dialog, exit)
  });
}

// Remove it again, for example when the screen that needed it closes:
// AppMint.setBackHandler(null);
```

**The `appmint:back` event** — cancelable. Call `e.preventDefault()` to consume the press. It runs before the handler, and any number of listeners can use it:

```js
window.addEventListener('appmint:back', function (e) {
  var sheet = document.querySelector('.bottom-sheet.open');
  if (sheet) {
    sheet.classList.remove('open');
    e.preventDefault();   // consumed: the handler and the shell do nothing more
  }
});
```

Keep a half-written note from being lost: Back shows your own "Discard changes?" bar instead of leaving (a blocking `confirm()` would miss the 350 ms answer window, so the shell would go back anyway):

```js
if (window.AppMint && AppMint.setBackHandler) {
  var noteDirty = false;
  document.getElementById('note').addEventListener('input', function () { noteDirty = true; });

  AppMint.setBackHandler(function () {
    if (!noteDirty) return false;        // nothing to lose: normal Back
    showDiscardBar();                    // your in-page "Discard changes? / Keep editing" bar
    return true;                         // stay here while the user decides
  });

  document.getElementById('discard-btn').onclick = function () {
    noteDirty = false;                   // the next Back press passes straight through
    hideDiscardBar();
    closeEditor();
  };
}
```

**Notes:** The answer must be synchronous: returning a Promise counts as "not handled". Answer quickly — if the page does not answer within about 350 ms, the shell goes back by itself. Order on each press: `appmint:back` listeners → your handler → the shell's built-in dialog closer (it closes an open `<dialog>`, ARIA dialog or full-screen modal) → history back / exit confirmation / exit. Apps that use `history.pushState` for every screen (React Router and similar) do not need this. AI-built apps can use `onBackGesture` from `@/lib/appmintNative`.

### `AppMint.setDownloadName`

**Example**

Gives a `blob:` or `data:` URL the file name it should be saved under, before your code (or a library) triggers the download.

**Returns:** nothing. **Needs:** nothing — it is always present in the app.

Useful when a library downloads a URL you built earlier and you cannot pass it a name:

```js
const blob = new Blob([csvText], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
  AppMint.setDownloadName(url, 'Invoice_0042.csv');
}
location.href = url;           // saved as Invoice_0042.csv, not "download.csv"
```

A PDF your page opens with `window.open` goes to the phone's PDF viewer; name it first so it is not "download.pdf":

```js
function previewPdf(pdfBlob, invoiceNo) {
  const url = URL.createObjectURL(pdfBlob);
  if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
    AppMint.setDownloadName(url, 'Invoice_' + invoiceNo + '.pdf');
  }
  window.open(url, '_blank');
}
```

A `data:` URL works the same way, e.g. a chart image a library links to without a `download` attribute:

```js
const dataUrl = chart.toBase64Image();         // e.g. Chart.js: 'data:image/png;base64,…'
if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
  AppMint.setDownloadName(dataUrl, 'Sales_by_month.png');
}
const link = document.getElementById('chart-link');
link.href = dataUrl;                           // a tap on this link saves Sales_by_month.png
link.textContent = 'Save chart';
```

**Notes:** The name is remembered even after `URL.revokeObjectURL(url)`, because some libraries revoke before the save finishes. You rarely need this: `<a download="name">`, `URL.createObjectURL(file)` (a `File` has a name) and FileSaver's `saveAs(blob, name)` are all picked up automatically. The name `'download'` is ignored as "no name".

### `AppMint.widget`

**Example**

Fills your app's home-screen widget: a small native card with a title, one big value, up to six short lines, your colours, and a route that a tap opens. A widget cannot show HTML — you send it the text.

**Returns:** every method returns a Promise. `set(card)` → `{ok: true, placed}` (`placed` = how many copies are on the home screen) or `{ok: false, error}`. `clear()` → `undefined`. `placedCount()` → a number. `requestPin()` → `true` if the launcher's "Add" sheet opened. **Needs:** nothing — every build has the widget.

**Set the card** — once at start, and again every time the data it shows changes:

```js
async function updateWidget(events) {
  if (!(window.AppMint && AppMint.widget)) return;          // in a browser
  var r = await AppMint.widget.set({
    title: 'Today',
    value: events.length + ' events',
    lines: events.slice(0, 6).map(function (ev) { return ev.time + ' ' + ev.name; }),
    route: '/calendar',      // a tap opens the app at this route
    bg: '#0E7C86',           // background
    ink: '#FFFFFF',          // title and lines
    accent: '#FFE08A'        // the big value
  });
  if (!r.ok) console.log('Widget not updated: ' + r.error);
}
```

**An "Add to home screen" button:**

```js
async function addWidget() {
  if (!(window.AppMint && AppMint.widget)) return;
  if ((await AppMint.widget.placedCount()) > 0) { alert('The widget is already on your home screen'); return; }
  var opened = await AppMint.widget.requestPin();
  if (!opened) alert('Touch and hold your home screen, tap Widgets, and pick this app.');
}
```

**Empty it** (for example after the user signs out):

```js
if (window.AppMint && AppMint.widget) AppMint.widget.clear();   // shows "Nothing to show yet"
```

**A tap on the widget** opens the app with `appmint-shortcut://<package><route>` as its launch link — read it with `WebToApk.getLaunchUrl()` on start and the `appmint:deep-link` event while running:

```js
window.addEventListener('appmint:deep-link', function (e) {
  var m = /^appmint-shortcut:\/\/[^\/?#]*([^#]*)/i.exec(e.detail.url);
  if (m) goTo(m[1] || '/');
});
```

**Notes:** All card fields are optional: an empty `title` shows the app name; empty `value` or lines are hidden; lines after the sixth are dropped; colours are `#RRGGBB` / `#AARRGGBB` (a bad colour uses the default). The widget keeps the last card, even after a restart, and changes only when your page calls `set()` again. `requestPin()` needs Android 8+ and a launcher that supports it. AI-built apps can use `widget` from `@/lib/appmintNative`.

## Standard web APIs that work in the app

Write the page exactly as you would for Chrome; the app answers these natively.

### `navigator.vibrate(pattern)`

**Example**

Vibrates the phone, using the normal web Vibration API. Android WebView's own `navigator.vibrate` never moves the motor; the app replaces it at document start so it really buzzes.

**Returns:** `true` when the request was accepted, `false` for bad input (a negative or non-number duration). **Needs:** turn on **Vibrate** in Step 4 (Access) when you build. AI-built apps get it switched on for you when the code vibrates.

```js
function buzz(pattern) {
  if ('vibrate' in navigator) navigator.vibrate(pattern);
}

document.getElementById('save').addEventListener('click', function () {
  buzz(30);                    // one short buzz, 30 ms
});

document.getElementById('wrong').addEventListener('click', function () {
  buzz([80, 40, 80]);          // buzz 80, pause 40, buzz 80
});

document.getElementById('alarm-off').addEventListener('click', function () {
  buzz(0);                     // 0 or [] stops a running vibration
});
```

Use the return value to catch a bad duration, for example one typed by the user:

```js
document.getElementById('test-buzz').addEventListener('click', function () {
  var ms = parseInt(document.getElementById('buzz-ms').value, 10);   // '' gives NaN
  if (!('vibrate' in navigator)) return;
  if (!navigator.vibrate(ms)) {
    showError('Enter a duration of 0 ms or more.');
  }
});
```

One short tick when a pull-to-refresh passes the point where letting go will refresh:

```js
var armed = false;
var list = document.getElementById('feed');

list.addEventListener('touchmove', function () {
  var ready = list.scrollTop <= 0 && pullDistance() > 80;
  if (ready && !armed && 'vibrate' in navigator) navigator.vibrate(15);
  armed = ready;
}, { passive: true });

list.addEventListener('touchend', function () {
  if (armed) refreshFeed();
  armed = false;
});
```

**Notes:**
- The pattern is the web one: the first number is a **buzz**, then pause, buzz, pause ... (the app converts it for Android).
- If Vibrate was not turned on when you built, the call still returns `true` but the phone does not vibrate.
- Decimals are rounded down. In an array, negative numbers count as 0.
- In a desktop browser `navigator.vibrate` may be missing, so keep the `'vibrate' in navigator` check.

### `navigator.share(data) / navigator.canShare(data)`

**Example**

The standard Web Share API, made to work inside the app: text and links open the Android share sheet, and `files` are shared as real files (WhatsApp, Gmail, Drive…).

**Returns:** `navigator.share` → a Promise that resolves when the sheet opens (it does not tell you which app was picked). `navigator.canShare` → `true`/`false`. **Needs:** nothing. Installed at document start, so a check made while your page parses already sees it.

**Text and a link:**

```js
async function shareItem(item) {
  if (!navigator.share) { alert('Sharing is not available here'); return; }
  try {
    await navigator.share({ title: item.name, text: 'Look at this', url: item.link });
  } catch (e) { /* in a browser: AbortError when the user closes the sheet */ }
}
```

**Files** — check with `canShare` first:

```js
async function sharePdf(blob) {
  var file = new File([blob], 'invoice.pdf', { type: 'application/pdf' });
  var data = { files: [file], title: 'Invoice', text: 'Invoice for March' };
  if (!(navigator.canShare && navigator.canShare(data))) {
    alert('File sharing is not available here');
    return;
  }
  try {
    await navigator.share(data);
  } catch (e) { console.log('Share failed', e); }
}
```

**Several files** the user picked — photos of one type share with that type:

```js
document.getElementById('photos').onchange = async function (e) {
  var files = Array.from(e.target.files);
  if (!files.length) return;
  var data = { files: files, title: 'Trip photos' };
  if (!(navigator.canShare && navigator.canShare(data))) {
    alert('File sharing is not available here');
    return;
  }
  try {
    await navigator.share(data);
  } catch (err) { console.log('Share failed', err); }
};
```

**Notes:** In the app `title` becomes the subject, and `text` + `url` are sent as one text. With `files`, each file is read in the page and sent as base64 (`WebToApk.shareFiles`), so keep files to a few MB. If the share sheet cannot open, the files are saved as downloads instead and the promise still resolves. `canShare` returns `true` for any text share. AI-built apps can use `share` from `@/lib/appmintNative`.

### `navigator.clipboard.writeText / readText`

**Example**

The standard Clipboard API for text, answered by the phone's real clipboard inside the app.

**Returns:** `writeText(text)` → a Promise that resolves when copied and rejects with `Error('Copy failed')` otherwise. `readText()` → a Promise with the clipboard text (`""` when empty). **Needs:** nothing.

**Copy:**

```js
async function copyLink(link) {
  if (!(navigator.clipboard && navigator.clipboard.writeText)) { alert('Copy is not available'); return; }
  try {
    await navigator.clipboard.writeText(link);
    showMessage('Link copied');
  } catch (e) {
    showMessage('Could not copy');
  }
}
```

**Paste** — from a tap:

```js
document.getElementById('paste').onclick = async function () {
  try {
    var text = await navigator.clipboard.readText();
    document.getElementById('input').value = text;
  } catch (e) {
    showMessage('Could not read the clipboard');
  }
};
```

**Images and HTML** — `write()` / `read()` with `ClipboardItem`:

```js
async function copyChart(canvas) {
  const png = await new Promise((r) => canvas.toBlob(r, "image/png"));
  await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
  showMessage("Chart copied — paste it into a chat");
}

async function pasteAnything() {
  const [item] = await navigator.clipboard.read();
  if (!item) return;
  if (item.types.includes("image/png")) showImage(URL.createObjectURL(await item.getType("image/png")));
  else if (item.types.includes("text/plain")) insertText(await (await item.getType("text/plain")).text());
}
```

**Notes:** The app version is installed before the page first runs (document start); `writeText`/`readText` call `WebToApk.copyToClipboard` / `WebToApk.readClipboard`, and `write`/`read` carry `text/plain`, `text/html` and one image (PNG, JPEG, WebP) — the image is copied as a real image, so it pastes into WhatsApp or Gmail. Other types reject with `NotAllowedError`. A foreign website opened inside the app reads `""` and `read()` rejects `NotAllowedError`. In a browser the same code works with the browser's own permission rules.

### `window.speechSynthesis + SpeechSynthesisUtterance`

**Example**

Reads text aloud with the phone's own text-to-speech engine, using the normal Web Speech API.

**Returns:** `speechSynthesis.speak(u)` returns nothing; the utterance's `start` / `boundary` / `end` / `error` / `pause` / `resume` events (as `onstart` … properties or with `addEventListener`) tell you what happened. `getVoices()` returns an array of `{ voiceURI, name, lang, localService, default }` synchronously. **Needs:** nothing. Android WebView has no working speech API of its own; the app installs this one at document start in every frame.

Speak a sentence and know when it is done:

```js
function say(text) {
  if (!('speechSynthesis' in window)) { alert('Speech is not available here'); return; }
  var u = new SpeechSynthesisUtterance(text);
  u.lang = 'en-US';   // BCP-47 tag: 'ta-IN', 'hi-IN', 'fr-FR' ...
  u.rate = 1.2;       // 0.1 .. 4 (1 = normal)
  u.pitch = 1;        // 0.1 .. 4 (1 = normal)
  u.onstart = function () { document.body.classList.add('speaking'); };
  u.onend = function () { document.body.classList.remove('speaking'); };
  u.onerror = function (e) {
    document.body.classList.remove('speaking');
    console.log('Speech failed:', e.error);   // 'synthesis-failed'
  };
  speechSynthesis.speak(u);   // queues after anything already speaking
}

document.getElementById('stop').onclick = function () {
  if ('speechSynthesis' in window) speechSynthesis.cancel();   // stops now, clears the queue
};
```

Voices arrive a moment after start-up. Read them again when `voiceschanged` fires:

```js
var voices = [];
function loadVoices() {
  voices = speechSynthesis.getVoices();   // [] until the engine is ready
  var tamil = voices.filter(function (v) { return v.lang.indexOf('ta') === 0; });
  document.getElementById('tamil-btn').hidden = tamil.length === 0;
}
if ('speechSynthesis' in window) {
  loadVoices();
  speechSynthesis.addEventListener('voiceschanged', loadVoices);
}

function sayWithVoice(text, voice) {
  var u = new SpeechSynthesisUtterance(text);
  u.voice = voice;           // a voice from getVoices(): that exact voice is used
  u.volume = 0.8;            // 0 .. 1
  speechSynthesis.speak(u);
}
```

An article reader with one Pause / Resume button that highlights the paragraph being read:

```js
var toggle = document.getElementById('pause-toggle');

toggle.onclick = function () {
  if (!('speechSynthesis' in window) || !speechSynthesis.speaking) return;
  if (speechSynthesis.paused) speechSynthesis.resume();   // continues from the last word
  else speechSynthesis.pause();
};

function readArticle() {
  if (!('speechSynthesis' in window)) return;
  speechSynthesis.cancel();                               // start over
  document.querySelectorAll('article p').forEach(function (p) {
    var u = new SpeechSynthesisUtterance(p.textContent);
    u.onstart = function () { p.classList.add('reading'); };
    u.onend = u.onerror = function () { p.classList.remove('reading'); };
    u.onpause = function () { toggle.textContent = 'Resume'; };
    u.onresume = function () { toggle.textContent = 'Pause'; };
    speechSynthesis.speak(u);                             // paragraphs queue in order
  });
  toggle.textContent = 'Pause';
}
```

**Notes:**
- `volume` (0–1), `voice` (the exact voice from `getVoices()`), `rate`, `pitch` and `lang` all reach the phone's engine. With a `voice` and a `lang`, the voice wins; with neither, the engine's default voice speaks.
- `boundary` fires before each word with `charIndex` / `charLength` (Android 8+; engines that report no word positions send none).
- `pause()` / `resume()`: Android's engine cannot pause mid-word, so `pause()` stops it and `resume()` speaks the rest of the sentence from the last word the engine reported (`pause` / `resume` events fire). On an engine that reports no word positions, `resume()` starts that utterance again from its beginning. `speaking` stays `true` while paused; `pending` is `true` while queued utterances wait.
- `cancel()` fires `error` with `error: 'interrupted'` on the utterance being spoken and `'canceled'` on queued ones, as the standard says — do not show those as failures.
- If the phone has no voice data for the language, the engine may still speak in its default language. Check `getVoices()` for the language first.
- The raw events behind this are `appmint:tts` (see `ttsSpeak`). You do not need them when you use this API.

### `new Notification(title, options)`

**Example**

The standard Web Notifications API works inside the app: `new Notification()` shows a real Android notification, `close()` removes it, and `Notification.requestPermission()` shows the Android permission dialog and waits for the user's answer.

**Returns:** `Notification.permission` is `'granted'`, `'denied'` or `'default'`, read live from the phone (a change in Settings shows without a reload). `requestPermission()` returns a Promise of `'granted'` or `'denied'` (the callback form also works). A new notification fires `show` when posted, or `error` when permission is missing. **Needs:** turn on **Notifications** in the Permissions step when you build.

```js
async function notifyWhenSaved() {
  if (!('Notification' in window)) return;
  let perm = Notification.permission;
  if (perm === 'default') perm = await Notification.requestPermission();
  if (perm !== 'granted') {
    showMessage('Turn on notifications for this app in Settings to get alerts.');
    return;
  }
  const n = new Notification('Saved', {
    body: 'Your note was saved',
    icon: 'https://example.com/avatar.png',   // shown as the large icon
    image: 'https://example.com/note.jpg',    // big picture
    tag: 'note'                               // same tag replaces the old one
  });
  n.onshow = function () { console.log('posted'); };
  n.onerror = function () { console.log('not posted'); };
  setTimeout(function () { n.close(); }, 60000);   // removes it from the shade
}

document.getElementById('save').addEventListener('click', notifyWhenSaved);
```

**Callback form** (older code):

```js
Notification.requestPermission(function (result) {
  if (result === 'granted') new Notification('Welcome!', { body: 'Alerts are on', silent: true });
});
```

**Service-worker style** (PWA code) also posts through the app when the page has no real service worker:

```js
if ('serviceWorker' in navigator && Notification.permission === 'granted') {
  navigator.serviceWorker.ready.then(function (reg) {
    return reg.showNotification('Backup done', { body: '12 files', tag: 'backup' });
  });
}
```

**A tap** fires `click` on the notification — the app comes to the front and the page gets it. If the app was closed (the tap started it), the object is gone, so the window gets a `notificationclick` event instead, shaped like a service worker's — `event.notification.tag / title / body / data`:

```js
const n = new Notification('Order shipped', { body: '#7', tag: 'order-7', data: { orderId: 7 } });
n.onclick = () => openOrder(7);                         // the app was running
window.addEventListener('notificationclick', (e) => {   // the tap started the app
  if (e.notification.data && e.notification.data.orderId) openOrder(e.notification.data.orderId);
});
```

**Notes:** used options: `body`, `icon`, `image`, `tag`, `silent`, `data` (JSON, under 16 KB). The tap reaches the page through the splash and PIN screens (event behind it: `appmint:notification-click`). On Android 12 and older there is no runtime permission, so it is `'granted'` unless the user switched notifications off. Web Push (`pushManager.subscribe`) is not supported; use Appwright Push. For buttons, progress or a sticky notification use `WebToApk.notify(...)`. The same code works in a normal browser.

### `new NDEFReader() — Web NFC`

**Example**

Standard Web NFC: `new NDEFReader()` reads, writes and locks NFC tags in the installed app, with the same code Chrome on Android accepts.

**Returns:** Promises and `reading` / `readingerror` events, as in the web standard. Records are `NDEFRecord`s: `recordType` (`'text'`, `'url'`, `'absolute-url'`, `'mime'`, `'smart-poster'`, `'empty'`, `'unknown'` or an external type such as `'example.com:thing'`), `mediaType` (mime records), `id`, `encoding` and `lang` (text records) and `data` as a `DataView`. **Needs:** turn on **Web NFC Support** in Step 4 (Access) when you build. Without it `NDEFReader` does not exist. No runtime permission prompt; `navigator.permissions.query({name:'nfc'})` answers `granted`.

**Read tags.** Decode `data` with `TextDecoder`, exactly as in Chrome:

```js
function describe(record) {
  if (record.recordType === 'text') return new TextDecoder(record.encoding).decode(record.data);
  if (record.recordType === 'url' || record.recordType === 'absolute-url') return new TextDecoder().decode(record.data);
  if (record.recordType === 'mime') return record.mediaType + ' (' + record.data.byteLength + ' bytes)';
  return record.recordType;
}

var scanStop = null;

document.getElementById('read').addEventListener('click', async function () {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  var reader = new NDEFReader();
  scanStop = new AbortController();
  reader.onreading = function (e) {
    showStatus('Tag ' + e.serialNumber + ': ' + e.message.records.map(describe).join(' | '));
  };
  reader.onreadingerror = function () { showStatus('That tag holds no NFC data this phone can read.'); };
  try {
    await reader.scan({ signal: scanStop.signal });
    showStatus('Hold a tag to the back of the phone.');
  } catch (err) {
    showStatus('NFC error: ' + err.name + ' ' + err.message);   // NotReadableError = NFC switched off
  }
});

document.getElementById('stop').addEventListener('click', function () {
  if (scanStop) scanStop.abort();   // the standard way to stop scanning
});
```

**Write text or a link.** The Promise resolves after the user taps a tag:

```js
async function writeTag(kind, value) {
  if (!('NDEFReader' in window)) { showStatus('NFC is not available in this app.'); return; }
  var reader = new NDEFReader();
  showStatus('Tap a tag to write it...');
  try {
    if (kind === 'text') await reader.write(value);   // same as { records: [{ recordType: 'text', data: value }] }
    else await reader.write({ records: [{ recordType: 'url', data: value }] }, { overwrite: false });
    showStatus('Tag written.');
  } catch (err) {
    showStatus('Write failed: ' + err.name + ' ' + err.message);   // NotAllowedError: read-only, or overwrite:false on a used tag
  }
}
```

**Lock a tag for ever.** `makeReadOnly()` cannot be undone — ask first:

```js
async function lockTag() {
  if (!('NDEFReader' in window)) return;
  if (!confirm('Lock the next tag you tap? It can never be changed again.')) return;
  try { await new NDEFReader().makeReadOnly(); showStatus('Tag locked.'); }
  catch (err) { showStatus('Lock failed: ' + err.message); }
}
```

**Notes:** Record types for `write`: `text` (string, or bytes with `encoding`), `url` and `absolute-url` (string), `mime` (bytes + `mediaType`), `smart-poster` (`{records: [...]}`), an external type `'domain:thing'` (bytes or `{records: [...]}`), `unknown` (bytes) and `empty`. A plain string writes a text record; plain bytes write an `application/octet-stream` mime record. Only readers that called `scan()` get `reading` events; `AbortController.abort()` on the `signal` stops that reader (there is no `stop()` in the standard). `readingerror` fires for a tag that cannot hold NFC data. The phone waits for ONE tag operation at a time: a new `write()` or `makeReadOnly()` while one waits replaces it, and the replaced call rejects with `AbortError` (Chrome would queue it). A `signal` on `write()` / `makeReadOnly()` cancels the wait. Errors: `NotSupportedError` (no NFC, or the tag cannot hold NDEF, or it is too small), `NotReadableError` (NFC switched off), `NotAllowedError` (read-only tag, or `overwrite: false` on a tag with records), `NetworkError` (the tag moved away), `TypeError` (a record the standard does not allow). `NDEFReader` exists in the main page, not inside iframes.

### `navigator.bluetooth — Web Bluetooth`

**Example**

Standard Web Bluetooth: the same `navigator.bluetooth` code you would write for Chrome works in the installed app, for any Bluetooth Low Energy device.

**Returns:** Promises, as in the web standard. Values are `DataView`s. **Needs:** Bluetooth. There is no switch: the build turns it on by itself when your code mentions `navigator.bluetooth` (or `BluetoothUUID`). Without it, `navigator.bluetooth` does not exist. The first `requestDevice()` asks for the Nearby devices permission (Location on Android 11 and older).

**Heart-rate strap, from a button tap.** Check for `navigator.bluetooth` inside the handler — the app installs it while the page loads, not before your first script runs:

```js
var hrDevice = null;

document.getElementById('connect').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    hrDevice = await navigator.bluetooth.requestDevice({
      filters: [{ services: ['heart_rate'] }],        // or { name: 'Polar H10' } / { namePrefix: 'Polar' }
      optionalServices: ['battery_service']
    });
    hrDevice.addEventListener('gattserverdisconnected', function () {
      showStatus('Strap disconnected');
    });
    var server = await hrDevice.gatt.connect();
    var hr = await server.getPrimaryService('heart_rate');
    var chr = await hr.getCharacteristic('heart_rate_measurement');
    chr.addEventListener('characteristicvaluechanged', function (e) {
      var v = e.target.value;                                   // DataView
      var bpm = (v.getUint8(0) & 1) ? v.getUint16(1, true) : v.getUint8(1);
      document.getElementById('bpm').textContent = bpm;
    });
    await chr.startNotifications();

    var battery = await server.getPrimaryService('battery_service');
    var level = await (await battery.getCharacteristic('battery_level')).readValue();
    document.getElementById('battery').textContent = level.getUint8(0) + '%';
  } catch (err) {
    // NotFoundError: user cancelled the chooser, Bluetooth is off, or no such service.
    // SecurityError: permission denied.  NetworkError: connection lost.
    showStatus(err.name + ': ' + err.message);
  }
});

document.getElementById('disconnect').addEventListener('click', function () {
  if (hrDevice && hrDevice.gatt.connected) hrDevice.gatt.disconnect();
});
```

**Writing.** `writeValue` and `writeValueWithResponse` wait for the device to confirm; `writeValueWithoutResponse` does not:

```js
async function sendCommands(server) {
  // Heart Rate Control Point: 0x01 = reset energy expended
  var hr = await server.getPrimaryService('heart_rate');
  var ctrl = await hr.getCharacteristic('heart_rate_control_point');
  await ctrl.writeValueWithResponse(new Uint8Array([0x01]));

  // A custom UART-style service (full 128-bit UUIDs work everywhere)
  var uart = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');
  var rx = await uart.getCharacteristic('6e400002-b5a3-f393-e0a9-e50e24dcca9e');
  await rx.writeValueWithoutResponse(new TextEncoder().encode('LED ON\n'));
}
```

**Device explorer.** Pick any nearby device and list every service and characteristic it has, with what each one can do:

```js
document.getElementById('explore').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    var device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true });
    var server = await device.gatt.connect();
    var lines = [];
    for (var service of await server.getPrimaryServices()) {
      lines.push(service.uuid === BluetoothUUID.getService('battery_service') ? 'Battery' : service.uuid);
      var chars = await service.getCharacteristics().catch(function () { return []; });
      chars.forEach(function (c) {
        var can = Object.keys(c.properties).filter(function (k) { return c.properties[k]; });
        lines.push('  ' + c.uuid + '  ' + can.join(', '));
      });
    }
    document.getElementById('tree').textContent = lines.join('\n');
    device.gatt.disconnect();
  } catch (err) {
    showStatus(err.name + ': ' + err.message);
  }
});
```

**Notes:** The device chooser is drawn by the app and scans for 30 seconds; a page can only reach a device the user picked. A `services` filter matches only services the device advertises. After `connect()` every service the device has can be used, even ones not in `optionalServices` (keep them anyway, Chrome needs them). Also available: `getAvailability()`, `getPrimaryServices()`, `getCharacteristics()`, `stopNotifications()`, `characteristic.properties`, `device.forget()`, `BluetoothUUID.getService/getCharacteristic/canonicalUUID`. `getDevices()`, `requestLEScan()`, `watchAdvertisements()` and descriptors reject with `NotSupportedError`. For the standard fitness sensors with the numbers already parsed, see `bleStartScan`.

### `navigator.mediaSession + MediaMetadata`

**Example**

Shows what your page is playing in a media notification and on the lock screen, with Previous / Play-Pause / Next / Stop buttons that call back into your page. Uses the normal Media Session API.

**Returns:** nothing; button presses call the handlers you set with `setActionHandler`, with `{ action }` as the standard says. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. AI-built apps get it switched on for you when the code uses `navigator.mediaSession`.

```js
var audio = document.getElementById('player');
var tracks = [
  { src: 'https://example.com/a.mp3', title: 'Morning', artist: 'Band', art: 'https://example.com/a.jpg' },
  { src: 'https://example.com/b.mp3', title: 'Evening', artist: 'Band', art: 'https://example.com/b.jpg' }
];
var index = 0;
var handlersReady = false;

function setupHandlers() {
  if (handlersReady || !('mediaSession' in navigator)) return;
  handlersReady = true;
  var ms = navigator.mediaSession;
  ms.setActionHandler('play', function () { audio.play(); });
  ms.setActionHandler('pause', function () { audio.pause(); });
  ms.setActionHandler('nexttrack', function () { playTrack(index + 1); });
  ms.setActionHandler('previoustrack', function () { playTrack(index - 1); });
  ms.setActionHandler('stop', function () { audio.pause(); audio.currentTime = 0; });
}

function playTrack(i) {
  index = (i + tracks.length) % tracks.length;
  var t = tracks[index];
  audio.src = t.src;
  audio.play();
  setupHandlers();   // on the first play, not at page load (see Notes)
  if ('mediaSession' in navigator && window.MediaMetadata) {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: t.title, artist: t.artist, album: '',
      artwork: [{ src: t.art, sizes: '512x512', type: 'image/jpeg' }]
    });
  }
}

document.getElementById('play-btn').addEventListener('click', function () { playTrack(0); });
```

Keep the play/pause button and the progress bar right:

```js
function publishState() {
  if (!('mediaSession' in navigator)) return;
  navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';   // set this FIRST
  if (audio.duration > 0 && navigator.mediaSession.setPositionState) {
    navigator.mediaSession.setPositionState({
      duration: audio.duration, position: audio.currentTime, playbackRate: 1
    });
  }
}
audio.addEventListener('play', publishState);
audio.addEventListener('pause', publishState);
audio.addEventListener('seeked', publishState);
```

A podcast player where the notification's Next / Previous buttons skip 30 seconds forward and 15 seconds back instead of changing episode:

```js
var audio = document.getElementById('player');
function skip(seconds) {
  var end = audio.duration || 0;
  audio.currentTime = Math.max(0, Math.min(end, audio.currentTime + seconds));
  if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && end > 0) {
    navigator.mediaSession.setPositionState({ duration: end, position: audio.currentTime });
  }
}
if ('mediaSession' in navigator) {
  navigator.mediaSession.setActionHandler('play', function () { audio.play(); });
  navigator.mediaSession.setActionHandler('pause', function () { audio.pause(); });
  navigator.mediaSession.setActionHandler('nexttrack', function () { skip(30); });
  navigator.mediaSession.setActionHandler('previoustrack', function () { skip(-15); });
  if (window.MediaMetadata) {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: 'Episode 12: Getting started', artist: 'My Podcast',
      artwork: [{ src: 'https://example.com/podcast-512.jpg', sizes: '512x512' }]
    });
  }
}
```

**Notes:**
- In the app, `navigator.mediaSession` is installed before the page's first script, so handlers and metadata can be set at any time — at the top of the page or when playback starts. It exists in the main page, not inside iframes.
- Supported actions: `play`, `pause`, `nexttrack`, `previoustrack`, `stop`. Seeking from the notification is not supported, so `seekto` never fires.
- Setting `playbackState` keeps the position from your last `setPositionState` call; call `setPositionState` again when the position really changes (a seek).
- Only the **last** artwork image is used. It must be a full `https://` address on the internet (the app downloads it, 4 s limit); a file inside your app ZIP does not show.
- `<audio>` / `<video>` elements are tracked for you: when the page handles a button neither with `setActionHandler` nor with its own `window.WebToApkOnMediaAction`, Play / Pause / Stop control the media element that is playing (else the one played last, else the first).
- To remove the notification, call `WebToApk.clearMediaNotification()` (setting `metadata = null` does not remove it).
- A page that defines `window.WebToApkOnMediaAction = function (action) { ... }` (actions `play`, `pause`, `next`, `prev`, `stop`) gets every button press there too — the app no longer overwrites it. Use one of the two, not both, or a press runs twice.

### `navigator.credentials.create / get — passkeys`

**Example**

Standard WebAuthn passkey calls. A normal Android WebView cannot do them; in the app they go to Android's passkey manager, so code and libraries written for Chrome (SimpleWebAuthn, Hanko, Corbado, Auth0) work unchanged.

**Returns:** a Promise of a `PublicKeyCredential` (with `.toJSON()`), exactly like Chrome; rejects with a `DOMException`. **Needs:** Android 9+ with Google Play services, a passkey server, your domain's `assetlinks.json` listing the app, and your server accepting the app's origin (see `AppMint.passkey`). No build switch.

Sign in with the standard API (options from your server, binary fields as ArrayBuffers):

```js
async function signIn() {
  if (!window.PublicKeyCredential) { alert('Passkeys are not supported here.'); return; }
  var ok = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  if (!ok) { alert('This phone cannot use passkeys.'); return; }

  try {
    var json = await (await fetch('https://your-domain.com/passkey/login-options')).json();
    var publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);  // provided in the app too
    var cred = await navigator.credentials.get({ publicKey: publicKey });
    await fetch('https://your-domain.com/passkey/login-verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(cred.toJSON())
    });
  } catch (err) {
    if (err.name !== 'NotAllowedError') alert('Passkey problem: ' + err.message);
  }
}
```

Register a passkey:

```js
async function register() {
  if (!window.PublicKeyCredential) return;
  try {
    var json = await (await fetch('https://your-domain.com/passkey/register-options')).json();
    var cred = await navigator.credentials.create({
      publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(json)
    });
    await fetch('https://your-domain.com/passkey/register-verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(cred.toJSON())
    });
  } catch (err) {
    if (err.name === 'InvalidStateError') alert('You already have a passkey here.');
    else if (err.name !== 'NotAllowedError') alert('Passkey problem: ' + err.message);
  }
}
```

Decide which sign-in buttons to show from what the phone supports (passkey autofill is off in the app, so keep a button):

```js
async function setupSignInButtons() {
  if (!window.PublicKeyCredential) return;
  var caps = typeof PublicKeyCredential.getClientCapabilities === 'function'
    ? await PublicKeyCredential.getClientCapabilities() : {};
  var platform = caps.passkeyPlatformAuthenticator ||
    await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  document.getElementById('passkey-button').hidden = !platform;
  if (caps.conditionalGet) startPasskeyAutofill();   // false in the app; true in some browsers
}
```

**Notes:** Passkey autofill in a text field (`mediation: 'conditional'`) is not available in the app — `isConditionalMediationAvailable()` answers `false` and the call rejects with `NotSupportedError`; start sign-in from a button. `signal` (AbortController) works. A page bundled in the app has no domain of its own: set `rp.id` / `rpId` to your domain. Calls without `publicKey` (passwords, OTP) go to the WebView as before.

### `window.print()`

**Example**

The standard print call, made to work inside the app: it opens Android's print screen, where the user prints on paper or saves a PDF.

**Returns:** nothing. **Needs:** nothing.

```js
document.getElementById('print-receipt').onclick = function () {
  window.print();
};
```

Hide the app's controls on paper with a print stylesheet:

```html
<style>
  @media print {
    nav, .toolbar, button { display: none !important; }
    body { background: #fff; color: #000; }
  }
</style>
```

Print a shipping label without the page around it — a same-origin `srcdoc` iframe prints only its own document:

```js
function esc(s) {
  return String(s).replace(/[&<>"]/g, function (c) {
    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
  });
}
function printLabel(name, address) {
  var frame = document.createElement('iframe');
  frame.style.cssText = 'position:fixed;width:0;height:0;border:0';
  frame.srcdoc = '<title>Label</title><style>body{font:18px sans-serif;margin:24px}</style>' +
                 '<b>' + esc(name) + '</b><br>' + esc(address).replace(/\n/g, '<br>');
  frame.onload = function () {
    frame.contentWindow.print();
    setTimeout(function () { frame.remove(); }, 1000);
  };
  document.body.appendChild(frame);
}
```

**Notes:** `window.print()` prints the main page as it is drawn now. A same-origin `<iframe>` — the hidden iframe a print library fills with a receipt and calls `iframe.contentWindow.print()` on, or an iframe page calling its own `window.print()` — prints THAT iframe's document (see `__printHtml`). A cross-origin iframe cannot be printed from the page. The replacement exists before the page's first script. `beforeprint` cannot be cancelled (in any browser).

### `window.close()`

**Example**

Closes the app's main screen. The shell replaces `window.close()` so a "Close" or "Exit" button in your page works inside the app.

**Returns:** nothing. **Needs:** nothing.

```js
document.getElementById('exit-btn').onclick = function () {
  saveDraft();          // anything unsaved — the screen closes at once
  window.close();       // in the app: WebToApk.closeWindow(); in a browser: normal behaviour
};
```

Hide the button in a browser, where `window.close()` usually does nothing on a tab the user opened:

```js
if (!window.WebToApk) document.getElementById('exit-btn').hidden = true;
```

Ask first, because the app closes the moment `window.close()` runs:

```js
document.getElementById('quit').addEventListener('click', function () {
  if (hasUnsavedChanges() && !confirm('You have unsaved changes. Close the app anyway?')) {
    return;
  }
  window.close();
});
```

**Notes:** The replacement is installed after each page finishes loading, so a call during the very first script run of the page uses the WebView's own `window.close()` (which does nothing). There is no exit confirmation on this path. A sign-in popup that calls `window.close()` is handled separately: it closes the popup, not the app.

### `<a download> and Blob downloads`

**Example**

Your normal web download code works in the app, the way it does in Chrome. A WebView ignores `blob:` and `data:` downloads on its own; the app catches them and saves the file to the phone's **Downloads** folder under **your** file name, then shows "Downloaded <name> · Open" and a "Download complete" notification.

**Returns:** nothing to the page. **Needs:** nothing to switch on. Web-address downloads (`https://…`) go to the Downloads folder too (a PDF or image first asks "View or Download"), and so do files packed inside the app (`<a href="manual.pdf" download>`); on Android 9 and older the app asks for storage permission first. Large files are fine: the file is streamed to the app in pieces.

`<a download>` with a Blob — the most common pattern:

```html
<button id="save">Download report</button>
<script>
document.getElementById('save').addEventListener('click', function () {
  const blob = new Blob(['Name,Total\nAsha,120\n'], { type: 'text/csv' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'Report_2026-08.csv';     // this is the name the user sees
  document.body.appendChild(a);
  a.click();                             // caught by the app, saved to Downloads
  a.remove();
});
</script>
```

A `data:` URL, FileSaver.js, and `navigator.msSaveBlob` also work:

```js
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'Drawing.png';
a.click();

if (typeof saveAs === 'function') saveAs(blob, 'Backup.json');   // FileSaver.js
if (navigator.msSaveBlob) navigator.msSaveBlob(blob, 'Backup.json');
```

`window.open` of a `blob:` or `data:` URL means "show it", not "save it": a PDF opens in the app's PDF viewer, other types in the phone's app for that type:

```js
const pdfUrl = URL.createObjectURL(pdfBlob);
const w = window.open(pdfUrl);    // never null in the app, so no "popup blocked" message
```

**Notes:** Without a name the file is saved as `download.<ext>`; a second file with the same name becomes `name (1).ext`. A `File` object brings its own name (`URL.createObjectURL(file)`). `<a href="/files/x" download="Name.pdf">` on your own site keeps `Name.pdf` (Chrome ignores the attribute on other sites' links, and so does the app). If no app on the phone can show a `window.open` file, it is saved to Downloads instead. To let the user choose the folder, use `showSaveFilePicker()`. To save without an `<a>` element, use `AppMint.saveBlob(blob, name)` or `AppMint.downloadFile(base64, name, mime)`.

### `<input type="file"> — camera, photos and files`

**Example**

A normal file input works in the app like in Chrome: it opens the photo picker, the camera, or the file picker, and your page gets ordinary `File` objects.

**Returns:** the input's `change` event with `input.files` (empty when the user cancels). **Needs:** photos and files need nothing. For `capture` (the camera) tick **Camera** in Step 4 (Access) when you build; the app asks for the camera permission the first time.

Photos — opens the Android photo picker (no permission prompt; the page gets only what the user chose):

```html
<input type="file" id="pics" accept="image/*" multiple>
<div id="preview"></div>
<script>
document.getElementById('pics').addEventListener('change', function (e) {
  const box = document.getElementById('preview');
  for (let i = 0; i < e.target.files.length; i++) {
    const img = document.createElement('img');
    img.src = URL.createObjectURL(e.target.files[i]);
    img.style.width = '100px';
    box.appendChild(img);
  }
});
</script>
```

Camera — `capture` skips the picker and opens the camera straight away:

```html
<input type="file" id="snap" accept="image/*" capture="environment">
<input type="file" id="clip" accept="video/*" capture="environment">
<script>
document.getElementById('snap').addEventListener('change', function (e) {
  const photo = e.target.files[0];
  if (!photo) return;                        // user closed the camera
  const form = new FormData();
  form.append('photo', photo);
  fetch('/api/upload', { method: 'POST', body: form });
});
</script>
```

Any file, or several types (extensions work too):

```html
<input type="file" accept=".pdf,.csv,application/json" multiple>
```

| You write | What opens |
|---|---|
| `accept="image/*"` / `"video/*"` / `"image/*,video/*"` | the photo picker (photos, videos, or both) |
| add `multiple` | the user can pick several |
| add `capture` (with **Camera** ticked) | the camera directly — photo for `image/*`, video for `video/*` |
| `accept="image/*,.pdf"` or no `accept` | the system file picker |

**Notes:** Without **Camera** ticked, `capture` is ignored and the picker opens. When **Native Folder Access (SAF)** is ticked, the app shows its own "Select Upload Source" menu instead: Take Photo / Record Video (if Camera is ticked), Choose Files, and Upload Folder (sends every file in a folder). With **Storage** ticked on Android 12 and older, choosing a non-photo file may ask for storage permission first. Phones without the photo picker get the file picker, which shows the same photos.

### `navigator.geolocation`

**Example**

The standard web location API works inside the app. The app shows Android's own location prompt and passes the answer to the page.

**Returns:** the normal W3C callbacks: a `GeolocationPosition` (`coords.latitude`, `coords.longitude`, `coords.accuracy`, …) or a `GeolocationPositionError` (`code` 1 = permission denied, 2 = unavailable, 3 = timeout). **Needs:** **GPS** (precise) and/or **Location** (approximate) in Step 4 (Access). When your code calls `navigator.geolocation`, `getCurrentPosition` or `watchPosition`, the build finds that call and turns location on for you; you can also tick it yourself.

**Get the position once.**

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

**Follow the user while they move** (a run tracker, a delivery map). Stop watching when the screen closes.

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

**Check the permission first, then ask for a position only when it can be answered:**

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

**Notes:** With **GPS** on, the app asks for precise and approximate location together; on Android 12+ the user may choose "Approximate", which still works (lower accuracy). With only **Location** on, only approximate location is asked for. If neither is on, every call fails with code 1 and no prompt. After the user allows it once, later calls are answered without a prompt. The phone's own location switch must also be on. Location is personal data: declare it in your Play Data safety form; background location is not provided.

### `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.**

```html
<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.

```js
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.

```js
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.

### `navigator.permissions.query({ name })`

**Example**

The standard Permissions API, answered from the phone's real Android permission state — so a page can show "Allow camera" only when it can still be asked, and "Turn it on in Settings" when it cannot.

**Returns:** a Promise of a `PermissionStatus` `{name, state, onchange}`; `state` is `'granted'`, `'denied'` or `'prompt'`, and `change` fires right after a permission dialog closes or when the user comes back from Settings. **Needs:** nothing to switch on.

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

**Ask only when the page can still ask, and send the user to Settings otherwise:**

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

**React when the user changes a permission in Settings, and combine it with a real request:**

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

**Notes:** `denied` also covers a capability the app was built without (its switch was off in the build, so Android can never grant it) and a "Don't ask again" refusal. `screen-wake-lock`, `clipboard-read` and `clipboard-write` are `'granted'` (the app provides them without a prompt). Other names (`midi`, `accelerometer`, …) are answered by the WebView as before. Event behind it: `appmint:permissions-changed`.

### `navigator.wakeLock.request('screen')`

**Example**

The standard Screen Wake Lock API: keeps the screen from dimming while a recipe, a map, a boarding pass or a workout timer is on screen.

**Returns:** a Promise of a `WakeLockSentinel` `{type:'screen', released, release(), onrelease}`. It rejects with `NotAllowedError` when the page is not visible. **Needs:** nothing to switch on, no permission.

```js
let wakeLock = null;

async function startCooking() {
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () => { wakeLock = null; });
  } catch (e) {
    console.log('Screen may dim:', e.name);
  }
}

async function stopCooking() {
  if (wakeLock) await wakeLock.release();
}

// The lock is dropped when the app goes to the background — take it again on return.
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && cookingModeOn && !wakeLock) startCooking();
});
```

Keep the screen on only while a workout timer is running, and let go when it ends:

```js
let lock = null;

async function startTimer(seconds) {
  if ('wakeLock' in navigator) {
    try { lock = await navigator.wakeLock.request('screen'); } catch (e) { lock = null; }
  }
  runCountdown(seconds, async () => {        // your timer; the callback runs at 0
    if (lock && !lock.released) await lock.release();
    lock = null;
    showDone();
  });
}
```

A "Keep screen on" switch that unticks itself when the lock is lost (for example, the app went to the background):

```js
const box = document.getElementById('keep-on');
let sentinel = null;

box.hidden = !('wakeLock' in navigator);
box.addEventListener('change', async () => {
  if (box.checked) {
    try {
      sentinel = await navigator.wakeLock.request('screen');
      sentinel.onrelease = () => { box.checked = false; sentinel = null; };
    } catch (e) {
      box.checked = false;                     // NotAllowedError: page not visible
    }
  } else if (sentinel) {
    await sentinel.release();
  }
});
```

**Notes:** as the spec says, every lock is released when the page is hidden (the app goes to the background, or navigates); each sentinel's `release` event fires. Several sentinels may be held at once — the screen stays on until the last is released. AI-built apps use `keepAwake(true/false)` from `@/lib/appmintNative`. Event behind it: `appmint:wakelock-lost`.

### `screen.orientation.lock(type) / unlock()`

**Example**

The standard Screen Orientation lock, which a plain WebView refuses: turns the app to landscape for a video or a game and back again.

**Returns:** `lock(type)` → a Promise that resolves once the orientation is requested; rejects `TypeError` for an unknown type and `NotSupportedError` on Android TV. `unlock()` returns to the orientation the app was built with. **Needs:** nothing.

```js
const player = document.getElementById('player');

document.getElementById('fullscreen').addEventListener('click', async () => {
  try {
    await screen.orientation.lock('landscape');       // either landscape, follows the sensor
  } catch (e) {
    console.log('Could not rotate:', e.name);
  }
  player.classList.add('wide');
});

document.getElementById('done').addEventListener('click', () => {
  screen.orientation.unlock();
  player.classList.remove('wide');
});

screen.orientation.addEventListener('change', () => console.log(screen.orientation.type));
```

Turn a game to landscape when it starts; on Android TV the screen is already landscape, so carry on:

```js
async function startGame() {
  try {
    await screen.orientation.lock('landscape');
  } catch (e) {
    if (e.name !== 'NotSupportedError') console.log('Could not rotate:', e.name);
  }
  runGame();
}

function quitGame() {
  screen.orientation.unlock();   // back to the orientation the app was built with
  showMenu();
}
```

Follow the shape of the video being played, and go back when it ends:

```js
const video = document.getElementById('clip');

video.addEventListener('play', () => {
  const wide = video.videoWidth >= video.videoHeight;
  screen.orientation.lock(wide ? 'landscape' : 'portrait').catch((e) => console.log(e.name));
});

video.addEventListener('ended', () => screen.orientation.unlock());
```

**Notes:** types: `any`, `natural`, `portrait`, `portrait-primary`, `portrait-secondary`, `landscape`, `landscape-primary`, `landscape-secondary`. Unlike Chrome, the page does not have to be in fullscreen first. AI-built apps use `lockOrientation()` from `@/lib/appmintNative`.

### `navigator.setAppBadge(count) / clearAppBadge()`

**Example**

The standard Badging API: a number on the app's launcher icon, like an unread count.

**Returns:** a Promise that resolves when the badge is set; `TypeError` for a negative or non-numeric count. **Needs:** nothing to switch on. It exists only on phones whose launcher shows counts (Samsung, Huawei, Xiaomi, OPPO, Sony, …); on Pixel-style launchers `'setAppBadge' in navigator` is `false`.

```js
function updateBadge(unread) {
  if (!('setAppBadge' in navigator)) return;      // this launcher cannot show a count
  if (unread > 0) navigator.setAppBadge(unread);
  else navigator.clearAppBadge();
}

updateBadge(messages.filter((m) => !m.read).length);
```

Clear the badge whenever the user is looking at the app, and handle a refused update:

```js
function setUnread(count) {
  if (!('setAppBadge' in navigator)) return;
  navigator.setAppBadge(count).catch(function (err) {
    console.warn('badge not updated:', err.name);   // NotAllowedError or TypeError
  });
}

document.addEventListener('visibilitychange', function () {
  if (!document.hidden && 'clearAppBadge' in navigator) {
    navigator.clearAppBadge().catch(function () {});
  }
});
```

Show a plain "something new" flag when a sync finds changes but has no exact count:

```js
async function afterSync(result) {
  if (!('setAppBadge' in navigator)) return;
  try {
    if (result.newCount > 0) await navigator.setAppBadge(result.newCount);
    else if (result.hasChanges) await navigator.setAppBadge();   // flag badge, shown as 1
    else await navigator.clearAppBadge();
  } catch (e) {
    console.warn('badge update failed', e);
  }
}
```

**Notes:** `setAppBadge()` with no number is the spec's "flag" badge, shown as 1 on a count launcher. The count survives an app restart. The same store backs `badge.set()` in `@/lib/appmintNative`, so both always agree.

### `navigator.contacts.select(properties, options) — Contact Picker`

**Example**

The standard Contact Picker API: the phone's own contact picker opens, and the page gets only the person the user chose — no contacts permission.

**Returns:** a Promise of an array: `[{ name: [string], tel: [string] }]`, or `[]` when the user closes the picker. **Needs:** nothing to switch on; call it from a tap (a user gesture is required, else `SecurityError`).

```js
document.getElementById('pick').addEventListener('click', async () => {
  if (!('contacts' in navigator)) { showManualEntry(); return; }
  const props = await navigator.contacts.getProperties();   // ['name', 'tel']
  const [person] = await navigator.contacts.select(props, { multiple: false });
  if (!person) return;                                       // closed without choosing
  document.getElementById('name').value = person.name[0] || '';
  document.getElementById('phone').value = person.tel[0] || '';
});
```

Fill a "Send to" box with only the number, and show a message for each way the picker can refuse:

```js
document.getElementById('choose-number').addEventListener('click', async () => {
  if (!('contacts' in navigator)) return;
  try {
    const [person] = await navigator.contacts.select(['tel']);
    if (person && person.tel.length) document.getElementById('to').value = person.tel[0];
  } catch (err) {
    if (err.name === 'InvalidStateError') showMessage('The contact picker is already open or not available.');
    else if (err.name === 'SecurityError') showMessage('Tap the button to choose a contact.');
    else showMessage('Could not pick a contact: ' + err.message);
  }
});
```

Build a guest list one tap at a time (one person per pick), skipping a number already on the list:

```js
const guests = [];

document.getElementById('add-guest').addEventListener('click', async () => {
  if (!('contacts' in navigator)) { showManualEntry(); return; }
  const [person] = await navigator.contacts.select(['name', 'tel']);
  if (!person || !person.tel.length) return;   // closed, or no number

  const number = person.tel[0];
  if (guests.some((g) => g.number === number)) { showMessage('Already invited'); return; }
  guests.push({ name: person.name[0] || number, number });
  renderGuests(guests);
});
```

**Notes:** the app supports `name` and `tel` — the number the user tapped. `email`, `address` and `icon` would need the full contacts permission, so they are not offered and asking for them rejects with `TypeError`. One person per pick (`multiple` is allowed but the Android picker returns one). Works in the top page only. For listing or searching all contacts use `WebToApk.listContacts` (needs the Contacts switch).

### `navigator.credentials.get({ otp: { transport: ['sms'] } }) — WebOTP`

**Example**

The standard WebOTP API: the login code from an SMS fills itself in. Android shows Google's "Allow this app to read this message?" sheet — no SMS permission.

**Returns:** a Promise of an `OTPCredential` `{type:'otp', code}`. Rejects `AbortError` when the user taps Deny or the signal aborts, `InvalidStateError` after five minutes without an SMS, `NotSupportedError` without Google Play services. **Needs:** nothing to switch on. The SMS must END with a line naming this page's host: `@<host> #<code>`.

```js
const input = document.querySelector('input[autocomplete="one-time-code"]');

if ('OTPCredential' in window) {
  const ac = new AbortController();
  document.querySelector('form').addEventListener('submit', () => ac.abort());
  navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })
    .then((otp) => { input.value = otp.code; input.form.requestSubmit(); })
    .catch((e) => console.log('Type the code instead:', e.name));
}
```

The SMS your server sends (last line is the binding):

```text
Your Acme code is 482913.

@acme.example #482913
```

After "Resend code", start a fresh request (it replaces the one still waiting, which rejects with AbortError):

```js
async function resendAndWait(phone) {
  await fetch('/api/send-code', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ phone })
  });
  if (!('OTPCredential' in window)) return;
  try {
    const otp = await navigator.credentials.get({ otp: { transport: ['sms'] } });
    await verifyCode(otp.code);
  } catch (e) {
    if (e.name === 'InvalidStateError') showMessage('No SMS yet. Tap Resend to try again.');
  }
}
```

**Notes:** the host must be the page's own host — for a website-based app that is your domain (the SMS Chrome already reads); for an offline/AI-built app it is `appassets.androidplatform.net`. A message for another host is ignored and the app keeps waiting. Top page only (codes bound to an embedded origin, `@top #code @iframe`, are not supported). Only one request waits at a time; a newer one aborts the older. Event behind it: `appmint:otp`.

### `showOpenFilePicker / showSaveFilePicker / showDirectoryPicker — File System Access`

**Example**

The standard File System Access API works inside the app, over Android's own document picker: open a file and save back INTO THAT FILE, create a file with a Save-as sheet, or get a whole folder to read, create and delete files in.

**Returns:** `showOpenFilePicker(options)` → Promise of an array of `FileSystemFileHandle`; `showSaveFilePicker(options)` → one `FileSystemFileHandle`; `showDirectoryPicker(options)` → one `FileSystemDirectoryHandle`. A cancelled picker rejects `AbortError`; no user gesture rejects `SecurityError`; a second picker while one is open rejects `NotAllowedError`. **Needs:** nothing to switch on and no permission — the user picks.

```js
let handle = null;

document.getElementById('open').addEventListener('click', async () => {
  try {
    [handle] = await showOpenFilePicker({
      types: [{ description: 'Text', accept: { 'text/plain': ['.txt', '.md'] } }]
    });
    const file = await handle.getFile();
    editor.value = await file.text();
    title.textContent = file.name;
  } catch (e) {
    if (e.name !== 'AbortError') alert('Could not open: ' + e.message);
  }
});

document.getElementById('save').addEventListener('click', async () => {
  if (!handle) handle = await showSaveFilePicker({ suggestedName: 'note.txt' });
  const w = await handle.createWritable();   // writes go to a copy…
  await w.write(editor.value);
  await w.close();                           // …that replaces the file only here
});

document.getElementById('export').addEventListener('click', async () => {
  const dir = await showDirectoryPicker({ mode: 'readwrite' });
  const out = await dir.getFileHandle('export.csv', { create: true });
  const w = await out.createWritable();
  await w.write(csvText);
  await w.close();
  for await (const [name, entry] of dir.entries()) console.log(entry.kind, name);
});
```

Pick several photos at once, starting in the Pictures folder:

```js
document.getElementById('add-photos').addEventListener('click', async () => {
  try {
    const handles = await showOpenFilePicker({
      multiple: true,
      startIn: 'pictures',
      types: [{ description: 'Images', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
    });
    for (const h of handles) {
      const img = document.createElement('img');
      img.src = URL.createObjectURL(await h.getFile());
      gallery.appendChild(img);
    }
  } catch (e) {
    if (e.name !== 'AbortError') alert('Could not open: ' + e.message);
  }
});
```

Add a line to the end of a log file without rewriting what is already there:

```js
async function appendLine(handle, line) {      // handle from showOpenFilePicker / showSaveFilePicker
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);
  await w.write(line + '\n');
  await w.close();
}
```

Clean a folder the user picked: delete old `.tmp` files and sub-folders named `cache`:

```js
document.getElementById('clean').addEventListener('click', async () => {
  const dir = await showDirectoryPicker({ mode: 'readwrite' });
  let removed = 0;
  for await (const entry of dir.values()) {
    if (entry.kind === 'file' && entry.name.endsWith('.tmp')) {
      await dir.removeEntry(entry.name);
      removed++;
    } else if (entry.kind === 'directory' && entry.name === 'cache') {
      await dir.removeEntry(entry.name, { recursive: true });
      removed++;
    }
  }
  alert(removed + ' item(s) removed');
});
```

**Notes:** `createWritable()` follows the spec's swap-file model — `abort()` (or the page going away) leaves the original untouched; `{ keepExistingData: true }` starts from the current content; `write({type:'seek'|'truncate'|'write', …})`, `seek()` and `truncate()` work. A file its app offers read-only answers `queryPermission({mode:'readwrite'})` with `'denied'` and `createWritable()` rejects `NoModificationAllowedError`. `getFile()` streams the document, so large files are fine. Handles last as long as the page — they cannot be stored in IndexedDB and reused after a restart. `types` become the picker's file filter. Only the top-level page has the pickers. Internal transports: `__fsPick` (answered with the `appmint:fs` event), `__fsFile`, `__fsList`, `__fsChild`, `__fsRemove`, `__fsWritableOpen`/`Write`/`Truncate`/`Close`/`Abort`. AI-built apps check `can('fileHandles')`.

### `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.

```js
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:

```js
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:

```js
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()`.

### `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).

```js
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):

```js
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):

```js
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:

```js
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()`.

### `navigator.getInstalledRelatedApps()`

**Example**

The standard related-apps API: tells the page which of its own Android apps (listed in its web app manifest) are installed — to hide an "Install our other app" banner, or deep-link into it.

**Returns:** Promise of an array of the manifest's `related_applications` entries that are installed: `{ platform: 'play', id, url?, version? }`. **Needs:** a `<link rel="manifest">` whose manifest lists the apps with `"platform": "play"` and `"id": "<package name>"`, bundled with the app so the build can declare them (Android only lets an app see packages it names).

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

Open the companion app when it is installed (the entry's `url` comes from your manifest), otherwise send the user to its store page:

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

List which of your apps are already on the phone on a "More from us" screen:

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

**Notes:** the build reads the manifest in your bundle and declares each listed package; an app not listed there cannot be seen and is never reported. Website-mode apps keep their manifest on the site, so nothing is declared and the list is empty. Internal transport: `__relatedAppsInstalled`.

### `webkitSpeechRecognition / SpeechRecognition — speech to text`

**Example**

The standard Web Speech API for dictation: the user talks, the page gets text. Chrome on Android has it and Android's WebView does not, so the app provides it over Android's own speech recogniser. Code written for Chrome works unchanged.

**Returns:** `start()` begins listening; `result` events carry `event.results[i][0].transcript` (and `.confidence`, `.isFinal`); `error` events carry `event.error` (`not-allowed`, `no-speech`, `audio-capture`, `network`, `aborted`, `language-not-supported`, `service-not-allowed`); `end` always comes last. **Needs:** the microphone. The build turns it on by itself when your code mentions `SpeechRecognition`, and Android asks the user the first time `start()` runs.

```html
<button id="mic">Dictate</button>
<textarea id="note"></textarea>
<script>
const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
const mic = document.getElementById('mic');
if (!Rec) {
  mic.hidden = true;                       // no speech recogniser on this phone
} else {
  mic.addEventListener('click', () => {
    const rec = new Rec();
    rec.lang = 'en-IN';                    // leave empty for the page / phone language
    rec.interimResults = true;             // live text while the user speaks
    rec.onresult = (e) => {
      let text = '';
      for (const r of e.results) text += r[0].transcript;
      document.getElementById('note').value = text;
    };
    rec.onerror = (e) => {
      if (e.error === 'not-allowed') alert('Allow the microphone to dictate.');
    };
    rec.onend = () => { mic.disabled = false; };
    mic.disabled = true;
    rec.start();
  });
}
</script>
```

**Continuous dictation that stops on request and reports each error:**

```js
const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
let rec = null;
function toggleDictation() {
  if (!Rec) return showMessage('Voice typing is not available on this phone.');
  if (rec) { rec.stop(); return; }                   // stop() delivers what was heard
  rec = new Rec();
  rec.continuous = true;
  rec.onresult = (e) => {
    for (let i = e.resultIndex; i < e.results.length; i++) {
      if (e.results[i].isFinal) appendToNote(e.results[i][0].transcript);
    }
  };
  rec.onerror = (e) => {
    const msg = { 'not-allowed': 'Allow the microphone first.', 'no-speech': 'Nothing was heard.',
                  'audio-capture': 'No microphone found.', 'network': 'Speech service unreachable.' }[e.error];
    if (msg) showMessage(msg);
  };
  rec.onend = () => { rec = null; };
  rec.start();
}
```

**Say a command and act on it, in the phone's language:**

```js
const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
function listenForCommand() {
  if (!Rec) return;
  const rec = new Rec();
  rec.lang = navigator.language;
  rec.onresult = (e) => {
    const said = e.results[0][0].transcript.toLowerCase();
    if (said.includes('next')) goNext();
    else if (said.includes('back')) goBack();
    else showMessage('Heard: ' + said);
  };
  rec.onerror = (e) => {
    if (e.error === 'language-not-supported') showMessage('This language is not supported here.');
  };
  rec.start();
}
```

**Notes:** `continuous = true` keeps listening across sentences until you call `stop()`; after three silent rounds it ends with `no-speech`, so a forgotten recogniser does not listen forever. `stop()` delivers what was heard; `abort()` discards it. Recognition stops when the app goes to the background, as it does when a Chrome tab is hidden. Only pages that belong to the app (its own files, or the creator's website) can use the microphone this way; any other site gets `not-allowed`. The API is missing when the phone has no speech recognition service, so check for it as above. Internal transports: `__srStart`, `__srStop`, `__srAbort`, `__srAvailable`; results arrive as `appmint:sr`. AI-built apps use `speech.start()` from `@/lib/appmintNative`.

### `video.requestPictureInPicture() — Picture-in-Picture`

**Example**

The standard Picture-in-Picture API: float a video in a small window while the user does something else. Chrome on Android has it and Android's WebView does not; in the app the video fills the floating window, as it does in Chrome.

**Returns:** a Promise for a `PictureInPictureWindow` (`width`, `height`, `resize` event). The video gets `enterpictureinpicture`, then `leavepictureinpicture` when the user closes or expands the window; `document.pictureInPictureElement` is the video meanwhile. Rejects `NotAllowedError` without a tap, `InvalidStateError` before the video's metadata has loaded or when it has `disablePictureInPicture`. **Needs:** Picture-in-picture switched on in the build (it is switched on by itself when your code calls `requestPictureInPicture`), Android 8 or newer.

```html
<video id="v" src="clip.mp4" controls playsinline></video>
<button id="pip">Pop out</button>
<script>
const v = document.getElementById('v');
const btn = document.getElementById('pip');
btn.hidden = !document.pictureInPictureEnabled;
btn.addEventListener('click', async () => {
  try {
    if (document.pictureInPictureElement) await document.exitPictureInPicture();
    else await v.requestPictureInPicture();
  } catch (e) {
    console.warn('Picture-in-Picture:', e.name);
  }
});
v.addEventListener('enterpictureinpicture', () => { btn.textContent = 'Back to app'; });
v.addEventListener('leavepictureinpicture', () => { btn.textContent = 'Pop out'; });
</script>
```

**Pop out automatically when the user leaves, and report an unsupported build:**

```js
const v = document.getElementById('v');
if (!document.pictureInPictureEnabled || v.disablePictureInPicture) {
  document.getElementById('pip').hidden = true;      // this build has no picture-in-picture
}
document.addEventListener('visibilitychange', async () => {
  if (document.hidden && !v.paused && !document.pictureInPictureElement) {
    try { await v.requestPictureInPicture(); }
    catch (e) { console.warn(e.name); }               // NotAllowedError without a tap
  }
});
```

**Wait for the metadata, and resize with the window:**

```js
async function popOut(video) {
  if (video.readyState < 1) await new Promise((r) => video.addEventListener('loadedmetadata', r, { once: true }));
  try {
    const pipWin = await video.requestPictureInPicture();
    pipWin.addEventListener('resize', () => console.log(pipWin.width, pipWin.height));
  } catch (e) {
    if (e.name === 'InvalidStateError') showMessage('This video cannot float.');
    else if (e.name === 'NotAllowedError') showMessage('Tap the button to pop out.');
  }
}
document.getElementById('pip').onclick = () => popOut(document.getElementById('v'));
```

**Notes:** Android puts the whole app into the floating window, so the video is first shown alone (full screen) and the window takes the video's shape; leaving the window puts the video back in the page. `document.exitPictureInPicture()` brings the app back to full screen. When the build has no picture-in-picture, `document.pictureInPictureEnabled` is `false`, as in a browser without it. The mode change reaches the page as `appmint:pip`. Internal transports: `__pipEnter`, `__pipExit`, `__pipAvailable`, `__pipPrepare`.

## Capacitor plugins

The shell hosts these Capacitor plugins. A hand-written page reaches them as `window.Capacitor.Plugins.<Name>`; an AI-built app uses the `@/lib/appmintNative` wrapper named in each example.

### `Capacitor.Plugins.Haptics`

**Example**

The official Capacitor Haptics plugin (`@capacitor/haptics`), built into the app: light/medium/heavy taps, success/warning/error feedback, selection ticks and plain vibration.

**Returns:** every method returns a Promise that resolves with nothing. **Needs:** turn on **Vibrate** in Step 4 (Access) when you build; the plugin cannot vibrate without that permission. `window.Capacitor` exists only inside the app, so feature-detect.

```js
function haptics() {
  return window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins.Haptics : null;
}

async function tap(style) {            // 'LIGHT' | 'MEDIUM' | 'HEAVY'
  var h = haptics();
  if (!h) return;
  try { await h.impact({ style: style }); } catch (e) { console.log('haptics failed', e); }
}

async function result(type) {          // 'SUCCESS' | 'WARNING' | 'ERROR'
  var h = haptics();
  if (h) { try { await h.notification({ type: type }); } catch (e) {} }
}

document.getElementById('add').addEventListener('click', function () { tap('LIGHT'); });
document.getElementById('pay').addEventListener('click', function () { result('SUCCESS'); });
```

Selection ticks for a picker or slider, and a plain vibration:

```js
var h = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins.Haptics : null;
var wheel = document.getElementById('wheel');

wheel.addEventListener('pointerdown', function () { if (h) h.selectionStart(); });
wheel.addEventListener('change', function () { if (h) h.selectionChanged(); });
wheel.addEventListener('pointerup', function () { if (h) h.selectionEnd(); });

document.getElementById('buzz').addEventListener('click', function () {
  if (h) h.vibrate({ duration: 300 });   // milliseconds
});
```

Buzz on an error and pair it with a visible message:

```js
var h = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics;

function rejectInput(field) {
  field.classList.add('invalid');
  if (h) h.notification({ type: 'ERROR' }).catch(function () {});
}
```

**Notes:** The strings are upper case (`'HEAVY'`, not `'heavy'`). In AI-built apps, `import { Haptics } from '@/lib/appmintNative'` gives the same names. For custom rhythms with strength per beat, use `Capacitor.Plugins.AppwrightHaptics`.

### `Capacitor.Plugins.Share`

**Example**

Opens the Android share sheet with a title, text and/or a link (`@capacitor/share` 8.0.1).

**Returns:** `share()` → Promise `{ activityType }` (the chosen app's component name, may be `''`); it REJECTS when the user closes the sheet. `canShare()` → Promise `{ value: boolean }`. **Needs:** nothing — every app hosts the plugin.

```js
async function shareLink() {
  var Share = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Share;
  if (!Share) { alert('Sharing is not available here'); return; }
  // Only pass the fields you have. A present but empty `url` is rejected ("Unsupported url").
  var opts = { title: 'My list', text: 'Milk, eggs, bread' };
  var link = 'https://example.com/list/42';
  if (link) opts.url = link;
  try {
    var r = await Share.share(opts);
    console.log('shared to', r.activityType);
  } catch (e) {
    console.log('share cancelled or failed:', e.message);   // "Share canceled" on dismiss
  }
}
```

**In an AI-built app** the wrapper has the same shape and also rejects on cancel:

```ts
import { Share, can } from '@/lib/appmintNative';

if (can('share') !== 'none') {
  try {
    await Share.share({ title: 'My list', text: 'Milk, eggs, bread', url: 'https://example.com/list/42' });
  } catch {
    // the user closed the sheet — not an error to show
  }
}
```

**Check `canShare()` first and hide the button when sharing is not available:**

```js
var Share = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Share;
var btn = document.getElementById('share');

btn.hidden = true;
if (Share) {
  Share.canShare().then(function (r) { btn.hidden = !r.value; });
}
```

**Notes:** `url` must be `http(s)://` or `file://`. `dialogTitle` sets the chooser heading. Sharing files needs `file://` paths the app owns; from a page, share files with `navigator.share({ files })` instead. Only one share can be open at a time ("Can't share while sharing is in progress").

### `Capacitor.Plugins.Clipboard`

**Example**

Copies text (or a link) to the system clipboard and reads it back (`@capacitor/clipboard` 8.0.1).

**Returns:** `write({ string?, url?, image?, label? })` → Promise `void`. `read()` → Promise `{ value, type }` (`type` is `'text/plain'` for text). **Needs:** nothing — every app hosts the plugin.

```js
async function copyCode(code) {
  var Clipboard = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Clipboard;
  if (!Clipboard) { alert('Copy is not available here'); return; }
  await Clipboard.write({ string: code, label: 'Invite code' });
}

async function pasteCode(input) {
  var Clipboard = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Clipboard;
  if (!Clipboard) return;
  try {
    var r = await Clipboard.read();
    input.value = r.value;
  } catch (e) {
    // An EMPTY clipboard is a rejection: "There is no data on the clipboard".
    if (/no data/i.test(e.message)) input.value = '';
    else alert('Could not read the clipboard: ' + e.message);
  }
}
```

**In an AI-built app** the Capacitor-shaped wrapper turns an empty clipboard into `''`:

```ts
import { Clipboard } from '@/lib/appmintNative';

await Clipboard.write({ string: 'ABC-123' });
const { value } = await Clipboard.read();   // '' when the clipboard is empty
```

Copy a link and confirm it with a tap of feedback:

```js
async function copyLink(url) {
  var P = window.Capacitor && window.Capacitor.Plugins;
  if (!P || !P.Clipboard) { alert('Copy is not available here'); return; }
  await P.Clipboard.write({ url: url });
  if (P.Haptics) P.Haptics.impact({ style: 'LIGHT' });
  copiedToast.hidden = false;
}
```

**Notes:** `write` needs one of `string`, `url` or `image` (a data URL), else it rejects "No data provided". Android 12+ shows its own "pasted from…" notice on read — that is the OS, not the app. The wrapper's `write` sends only `string || url`.

### `Capacitor.Plugins.Device`

**Example**

The official Capacitor Device plugin, built into the app: phone model, OS version, battery, a stable ID and the language.

**Returns:** Promises. `getInfo()` → `{ model, platform, operatingSystem, osVersion, androidSDKVersion, manufacturer, isVirtual, webViewVersion, … }`; `getId()` → `{ identifier }`; `getBatteryInfo()` → `{ batteryLevel (0–1), isCharging }`; `getLanguageCode()` → `{ value }`. **Needs:** nothing. `window.Capacitor` exists only inside the app.

**Show the phone and battery.**

```js
async function showDevice() {
  var cap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Device;
  if (!cap) { document.getElementById('out').textContent = navigator.userAgent; return; }
  try {
    var info = await cap.getInfo();
    var battery = await cap.getBatteryInfo();
    document.getElementById('out').textContent =
      info.manufacturer + ' ' + info.model + ' - ' + info.operatingSystem + ' ' + info.osVersion +
      ' - battery ' + Math.round(battery.batteryLevel * 100) + '%' + (battery.isCharging ? ' (charging)' : '');
  } catch (err) {
    document.getElementById('out').textContent = 'Could not read device info';
  }
}
```

**A stable ID and the language.**

```js
async function helloServer() {
  var cap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Device;
  if (!cap) return;
  var id = (await cap.getId()).identifier;
  var lang = (await cap.getLanguageCode()).value;     // e.g. "en"
  await fetch('https://api.example.com/hello?lang=' + encodeURIComponent(lang), {
    method: 'POST', body: JSON.stringify({ device: id })
  });
}
```

**In an AI-built app** use the wrapper, which also works on the web:

```ts
import { Device } from '@/lib/appmintNative';
const info = await Device.getInfo();
```

**Notes:** For the Android build ID, security patch, screen, memory and network, the shell's `WebToApk.getDeviceInfo()` gives more detail. Plugin ids are not hardware ids; they can change after a factory reset.

### `Capacitor.Plugins.App`

**Example**

App identity and lifecycle: name, package id, version, whether the app is in front, and pause/resume events (`@capacitor/app` 8.1.0).

**Returns:** `getInfo()` → `{ name, id, build, version }`; `getState()` → `{ isActive }`; `getLaunchUrl()` → `{ url }` or nothing; `getAppLanguage()` → `{ value }`; `exitApp()` / `minimizeApp()` → `void`. `addListener()` → Promise of a handle with `remove()`. **Needs:** nothing — every app hosts the plugin.

```js
var App = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.App;
if (App) {
  App.getInfo().then(function (i) {
    document.getElementById('about').textContent = i.name + ' ' + i.version + ' (' + i.build + ')';
  });
}
```

**Pause and resume** — save a draft when the user leaves, refresh when they come back:

```js
if (App) {
  App.addListener('appStateChange', function (s) {
    if (!s.isActive) saveDraft();      // went to the background
    else refreshInbox();               // back in front
  });
  App.addListener('pause', function () { console.log('paused'); });
  App.addListener('resume', function () { console.log('resumed'); });
}
```

**In an AI-built app:**

```ts
import { App } from '@/lib/appmintNative';

const info = await App.getInfo();   // { name, id, build, version }
App.exitApp();                      // closes the app on Android
```

**Notes:** The `backButton` event never fires: the shell keeps the back button (the plugin runs with `disableBackButtonHandler`). Handle back with `AppMint.setBackHandler(fn)` or the `onBackGesture` wrapper. For deep links use `WebToApk.getLaunchUrl()` / the `appmint:deep-link` event (or `launchUrl()` / `onDeepLink()` in AI-built apps). `minimizeApp()` is Android-only. Never make an Exit button the only way forward.

### `Capacitor.Plugins.Filesystem`

**Example**

The stock Capacitor Filesystem plugin: read and write files in the app's OWN storage (not a folder the user picked — for that use `WebToApkFS`).

**Returns:** every method returns a Promise and rejects on failure (for example reading a file that does not exist). `readFile` → `{ data }`, `writeFile` → `{ uri }`, `readdir` → `{ files: [{ name, type, size, mtime, uri }] }`, `mkdir` / `deleteFile` → nothing. **Needs:** nothing to switch on; `'DATA'` and `'CACHE'` need no permission.

A page has no `Directory` / `Encoding` imports, so pass the plain strings `'DATA'` (private app files, kept until uninstall) or `'CACHE'` (the system may clear it), and `'utf8'` for text:

```js
async function saveAndReadBack() {
  var Fs = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;
  if (!Fs) { console.log('Filesystem plugin not available (not inside the app)'); return; }

  try {
    await Fs.mkdir({ path: 'notes', directory: 'DATA', recursive: true });
    await Fs.writeFile({ path: 'notes/today.txt', data: 'Hello', directory: 'DATA', encoding: 'utf8', recursive: true });

    var r = await Fs.readFile({ path: 'notes/today.txt', directory: 'DATA', encoding: 'utf8' });
    console.log('text:', r.data);

    var dir = await Fs.readdir({ path: 'notes', directory: 'DATA' });
    dir.files.forEach(function (f) { console.log(f.type, f.name, f.size); });   // type: 'file' | 'directory'

    await Fs.deleteFile({ path: 'notes/today.txt', directory: 'DATA' });
  } catch (e) {
    console.log('File error:', e.message);   // e.g. the file does not exist
  }
}
```

Leave out `encoding` to write or read binary data as base64:

```js
var Fs = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;
var base64Data = btoa('raw bytes here');   // any base64 string, e.g. from a canvas or FileReader
if (Fs) {
  Fs.writeFile({ path: 'blobs/data.bin', data: base64Data, directory: 'CACHE', recursive: true })
    .then(function (w) { console.log('written to', w.uri); })
    .catch(function (e) { console.log('write failed:', e.message); });
}
```

In AI-built apps, the `@/lib/appmintNative` wrapper that uses this plugin is `fileTransfer` (it stages downloads and uploads in `'CACHE'`):

```ts
import { fileTransfer } from '@/lib/appmintNative';

const r = await fileTransfer.download('https://example.com/report.pdf', 'report.pdf', {
  onProgress: (p) => console.log(Math.round(p.fraction * 100) + '%'),
});
if (r.path) console.log('saved in the app at', r.path);   // on the web the browser saves it instead
```

**Notes:** `window.Capacitor` exists only inside the app — always feature-detect. Files in `'DATA'` are private to the app and deleted on uninstall; to put files where the user can see them, use `WebToApkFS` (a folder the user grants) or a download.

### `Capacitor.Plugins.Preferences`

**Example**

A small native key–value store (Android SharedPreferences) for strings (`@capacitor/preferences` 8.0.1).

**Returns:** `set({ key, value })`, `remove({ key })`, `clear()` → Promise `void`; `get({ key })` → `{ value }` (`null` when the key is missing); `keys()` → `{ keys: string[] }`. **Needs:** nothing — every app hosts the plugin.

```js
var Prefs = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Preferences;

async function saveTheme(name) {
  if (!Prefs) return false;          // not inside the app
  await Prefs.set({ key: 'theme', value: name });
  return true;
}

async function loadTheme() {
  if (!Prefs) return null;
  var r = await Prefs.get({ key: 'theme' });
  return r.value;            // null if never saved
}
```

Objects must be turned into a string first:

```js
async function saveUser() {
  if (!Prefs) return;
  await Prefs.set({ key: 'user', value: JSON.stringify({ id: 7, name: 'Asha' }) });
  var u = JSON.parse((await Prefs.get({ key: 'user' })).value || 'null');
  var all = (await Prefs.keys()).keys;
  await Prefs.remove({ key: 'user' });
}
```

List and clear the saved keys from a settings screen:

```js
async function resetSettings() {
  if (!Prefs) return;
  var k = (await Prefs.keys()).keys;
  console.log('clearing', k.length, 'saved values');
  await Prefs.clear();
}
```

**Notes:** `@/lib/appmintNative` has NO Preferences wrapper, on purpose: `localStorage` is already real and persistent in the app, and a second store would split one app's data across two places. AI-built apps should keep using `localStorage` (or the data runtime). Use Preferences in a hand-written page only if you want all of your settings there, and pick one store per value. `configure({ group })` changes the store name (default `CapacitorStorage`).

### `Capacitor.Plugins.StatusBar`

**Example**

Changes the status bar's icon style, colour and visibility (`@capacitor/status-bar` 8.0.2).

**Returns:** every method → Promise `void`, except `getInfo()` → `{ visible, style, color, overlays, height }`. **Needs:** nothing — every app hosts the plugin.

`style` names the BACKGROUND it suits: `'DARK'` = light icons (for a dark bar), `'LIGHT'` = dark icons, `'DEFAULT'` = follow the system.

```js
var StatusBar = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.StatusBar;
if (StatusBar) {
  StatusBar.setStyle({ style: 'DARK' });          // light icons
  StatusBar.hide();                               // e.g. while a video plays
  // later
  StatusBar.show();
}
```

**Colour the bar with the shell bridge, not the plugin.** On Android 15+ the plugin's `setBackgroundColor` does nothing (edge-to-edge ignores it). The shell paints the strip itself and takes the icon style in the same call:

```js
if (window.WebToApk && window.WebToApk.setStatusBarColor) {
  window.WebToApk.setStatusBarColor('#0B0F1A', 'light');   // dark bar, light icons
} else if (StatusBar) {
  StatusBar.setBackgroundColor({ color: '#0B0F1A' });      // hex only, e.g. #RRGGBB
}
```

**In an AI-built app** use `setStatusBar(color, contentStyle)`; it prefers the shell bridge and sets colour and icons in one call:

```ts
import { setStatusBar, StatusBar } from '@/lib/appmintNative';

setStatusBar('#0B0F1A', 'light');                       // dark bar, light icons
await StatusBar.setBackgroundColor({ color: '#FFFFFF' }); // colour only; icons picked from luminance
```

**Notes:** `setBackgroundColor` rejects a colour that is not a hex string. In fullscreen / kiosk builds the bars are hidden by the shell. In the Android app the wrapper's `StatusBar.setStyle()` alone changes nothing (the bridge needs a colour to paint) — pass the style to `setStatusBar()` together with a colour.

### `Capacitor.Plugins.Dialog`

**Example**

Shows a real Android system dialog: a message, a yes/no question, or a one-line text question (`@capacitor/dialog` 8.0.1).

**Returns:** `alert({ title?, message, buttonTitle? })` → Promise `void`. `confirm({ title?, message, okButtonTitle?, cancelButtonTitle? })` → `{ value: boolean }`. `prompt({ title?, message, okButtonTitle?, cancelButtonTitle?, inputPlaceholder?, inputText? })` → `{ value: string, cancelled: boolean }`. **Needs:** nothing — every app hosts the plugin.

```js
var Dialog = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Dialog;

async function deleteNote(id) {
  if (!Dialog) return;
  var r = await Dialog.confirm({
    title: 'Delete note?',
    message: 'This cannot be undone.',
    okButtonTitle: 'Delete',
    cancelButtonTitle: 'Keep'
  });
  if (r.value) removeNote(id);
}
```

**Alert and prompt:**

```js
async function renameFlow() {
  if (!Dialog) return;
  await Dialog.alert({ title: 'Saved', message: 'Your changes are stored.', buttonTitle: 'OK' });

  var p = await Dialog.prompt({ title: 'Rename', message: 'New name', inputText: 'Groceries', inputPlaceholder: 'List name' });
  if (!p.cancelled && p.value.trim()) renameList(p.value.trim());
}
```

**In an AI-built app** the wrapper never rejects: dismissal is `{ value: false }` / `{ cancelled: true }`, and on the web it uses the browser's own dialogs:

```ts
import { Dialog } from '@/lib/appmintNative';

const { value } = await Dialog.confirm({ title: 'Delete note?', message: 'This cannot be undone.' });
if (value) removeNote(id);
```

**Notes:** `message` is required — without it the plugin rejects "Please provide a message for the dialog". Dialogs are modal; use them only for decisions that must block.

### `Capacitor.Plugins.Toast`

**Example**

Shows a short Android system toast such as "Saved" or "Copied" (`@capacitor/toast` 8.0.1).

**Returns:** Promise `void`. **Needs:** nothing — every app hosts the plugin.

```js
var Toast = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Toast;

function saved() {
  if (Toast) Toast.show({ text: 'Saved', duration: 'short', position: 'bottom' });
}
```

`duration` is `'short'` (about 2 s) or `'long'` (about 3.5 s). `position` is `'top'`, `'center'` or `'bottom'`.

**In an AI-built app** use `toast()` or the Capacitor-shaped `Toast`; on the web they draw a snackbar in the app's colours:

```ts
import { Toast, toast } from '@/lib/appmintNative';

await Toast.show({ text: 'Added to list', duration: 'long' });
toast('Copied');   // short form, same result
```

**Feature-detect and show an in-page message when the plugin is missing:**

```js
function notify(message) {
  var Toast = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Toast;
  if (Toast) { Toast.show({ text: message, duration: 'short' }); return; }
  var el = document.getElementById('snackbar');   // a plain in-page message
  el.textContent = message;
  el.hidden = false;
  setTimeout(function () { el.hidden = true; }, 2000);
}
```

**Notes:** `text` is required ("Must provide text"). The system draws the toast in the phone maker's style, and newer Android versions ignore `position` for text toasts. A toast is easy to miss — never use it for an error the user must act on; use `Dialog.alert` for that.

### `Capacitor.Plugins.ActionSheet`

**Example**

Shows a native bottom sheet with a list of choices and tells you which one was tapped (`@capacitor/action-sheet` 8.1.1).

**Returns:** Promise `{ index, canceled }` — `index` is the tapped option; when the sheet is dismissed `canceled` is `true` and `index` is outside the list (`-1`). **Needs:** nothing — every app hosts the plugin.

Each option is `{ title, style? }` with `style` `'DEFAULT'`, `'DESTRUCTIVE'` or `'CANCEL'`.

```js
var ActionSheet = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ActionSheet;

async function photoMenu() {
  if (!ActionSheet) return;
  var opts = [
    { title: 'Share', style: 'DEFAULT' },
    { title: 'Edit', style: 'DEFAULT' },
    { title: 'Delete', style: 'DESTRUCTIVE' }
  ];
  var r = await ActionSheet.showActions({ title: 'Photo', message: 'Choose an action', options: opts });
  if (r.canceled || r.index < 0 || r.index >= opts.length) return;   // dismissed
  if (opts[r.index].title === 'Delete') deletePhoto();
}
```

**In an AI-built app** the Capacitor-shaped wrapper returns `{ index: -1 }` on dismiss; the short form `actionSheet()` returns `null`:

```ts
import { ActionSheet, actionSheet } from '@/lib/appmintNative';

const { index } = await ActionSheet.showActions({
  title: 'Photo',
  options: [{ title: 'Share' }, { title: 'Delete', destructive: true }],
});
if (index === 1) deletePhoto();

const i = await actionSheet('Sort by', [{ title: 'Name' }, { title: 'Date' }]);   // number | null
```

**Delete confirmation that also works in a plain browser:**

```js
async function confirmDelete(name) {
  const AS = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ActionSheet;
  if (!AS) return window.confirm('Delete ' + name + '?');
  const r = await AS.showActions({
    title: 'Delete ' + name + '?',
    options: [{ title: 'Delete', style: 'DESTRUCTIVE' }, { title: 'Keep it', style: 'CANCEL' }]
  });
  return !r.canceled && r.index === 0;
}
```

**Notes:** Always check the index bound — never treat any index as a real choice. The wrapper's option shape is `{ title, destructive? }`; it maps `destructive` to the plugin's `'DESTRUCTIVE'` style.

### `Capacitor.Plugins.Keyboard`

**Example**

Opens or closes the software keyboard and tells the page when it appears and how tall it is (`@capacitor/keyboard` 8.0.5).

**Returns:** `show()` / `hide()` → Promise `void`. `addListener(event, fn)` → Promise of a handle with `remove()`; show events pass `{ keyboardHeight }` in CSS pixels. Events: `keyboardWillShow`, `keyboardDidShow`, `keyboardWillHide`, `keyboardDidHide`. **Needs:** nothing — every app hosts the plugin.

```js
var Keyboard = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Keyboard;

if (Keyboard) {
  Keyboard.addListener('keyboardDidShow', function (info) {
    console.log('keyboard height', info.keyboardHeight);
    document.body.classList.add('typing');       // e.g. hide the bottom tab bar
  });
  Keyboard.addListener('keyboardDidHide', function () {
    document.body.classList.remove('typing');
  });
}

function onSend() {
  if (Keyboard) Keyboard.hide().catch(function () {});   // rejects if no field is focused
}
```

**In an AI-built app** use `onKeyboard()` for layout — it reports only the part of the keyboard the resized page does not already clear — and `hideKeyboard()` to close it:

```ts
import { onKeyboard, hideKeyboard, Keyboard } from '@/lib/appmintNative';

const off = onKeyboard((px) => { composer.style.paddingBottom = `${px}px`; });  // call off() on unmount
hideKeyboard();

const h = Keyboard.addListener('keyboardWillShow', ({ keyboardHeight }) => console.log(keyboardHeight));
h.remove();
```

Show the keyboard for a field after a button tap:

```js
var Keyboard = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Keyboard;

searchBtn.addEventListener('click', function () {
  searchInput.focus();
  if (Keyboard) Keyboard.show().catch(function () {});
});
```

**Notes:** The shell already resizes the page for the keyboard, so do NOT add the full `keyboardHeight` as padding — a bottom bar is already above the keys. `setAccessoryBarVisible`, `setStyle`, `setResizeMode` and `setScroll` are iOS-only. `hide()` rejects "Can't close keyboard, not currently focused" when nothing has focus.

### `Capacitor.Plugins.ScreenOrientation`

**Example**

Reads the screen orientation, pins it for one view (a game, a chart, a signature pad) and releases it (`@capacitor/screen-orientation` 8.0.1).

**Returns:** `orientation()` → Promise `{ type }` (e.g. `'portrait-primary'`). `lock()` / `unlock()` → Promise `void`. Event `screenOrientationChange` passes `{ type }`. **Needs:** nothing — every app hosts the plugin.

`orientation` values: `'portrait'`, `'landscape'`, `'portrait-primary'`, `'portrait-secondary'`, `'landscape-primary'`, `'landscape-secondary'`, `'natural'`, `'any'`.

```js
var SO = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ScreenOrientation;

async function openChart() {
  if (SO) await SO.lock({ orientation: 'landscape' });
  showChart();
}

async function closeChart() {
  if (SO) await SO.unlock();          // always release when leaving the view
  hideChart();
}

if (SO) {
  SO.orientation().then(function (o) { console.log('now', o.type); });
  SO.addListener('screenOrientationChange', function (o) { console.log('rotated to', o.type); });
}
```

**In an AI-built app:**

```ts
import { lockOrientation, ScreenOrientation } from '@/lib/appmintNative';

const ok = await lockOrientation('landscape');   // false when refused (always on desktop)
await lockOrientation('any');                    // release

await ScreenOrientation.lock({ orientation: 'portrait' });
await ScreenOrientation.unlock();
```

**Handle a refused lock and tell the user:**

```js
var SO = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ScreenOrientation;
var Toast = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Toast;

async function tryLandscape() {
  if (!SO) { document.getElementById('rotate-hint').hidden = false; return; }   // ask the user to rotate by hand
  try {
    await SO.lock({ orientation: 'landscape' });
  } catch (e) {
    if (Toast) Toast.show({ text: 'Could not rotate the screen', duration: 'short' });
  }
}
```

**Notes:** The wrappers only know `portrait` / `landscape` / `any`: `ScreenOrientation.lock({ orientation: 'landscape-secondary' })` locks to plain `landscape`. Give the user a way out of a locked view.

### `Capacitor.Plugins.Network`

**Example**

Tells the page whether the phone is online and on which kind of connection, and when that changes (`@capacitor/network` 8.0.1).

**Returns:** `getStatus()` → Promise `{ connected: boolean, connectionType: 'wifi' | 'cellular' | 'none' | 'unknown' }`. The `networkStatusChange` event passes the same shape. **Needs:** nothing — every app hosts the plugin.

```js
var Network = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Network;

function showBanner(s) {
  document.getElementById('offline').hidden = s.connected;
}

if (Network) {
  Network.getStatus().then(showBanner);
  Network.addListener('networkStatusChange', function (s) {
    showBanner(s);
    if (s.connected) retryQueuedWrites();
    if (s.connectionType === 'cellular') console.log('on mobile data');
  });
}
```

**In an AI-built app:**

```ts
import { Network, networkStatus, onNetworkChange } from '@/lib/appmintNative';

const { connected, connectionType } = await Network.getStatus();
const now = networkStatus();                        // sync: { connected, type }
const off = onNetworkChange((s) => setOnline(s.connected));   // call off() on unmount
```

Pause a large upload on mobile data:

```js
var Network = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Network;

async function canUploadVideo() {
  if (!Network) return navigator.onLine;
  var s = await Network.getStatus();
  if (!s.connected) return false;
  return s.connectionType === 'wifi' || confirm('Upload over mobile data?');
}
```

**Notes:** `connected` means the phone has a network, not that your server answers — still handle failed requests. The wrapper's `Network.getStatus()` and `networkStatus()` read `navigator.onLine` / `navigator.connection`, not the plugin, so their type can be `'unknown'` or a speed class like `'4g'`; `onNetworkChange()` does use the plugin's event in the app.

### `Capacitor.Plugins.AppLauncher`

**Example**

Opens another app by link (`whatsapp://send?text=…`, `tel:`, `geo:`, an https link) and asks whether one can handle it (`@capacitor/app-launcher` 8.0.1).

**Returns:** `canOpenUrl()` → Promise `{ value: boolean }`. `openUrl()` → Promise `{ completed: boolean }` (`false` when no app took the link). **Needs:** nothing — every app hosts the plugin.

```js
var AL = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppLauncher;

async function chatOnWhatsApp(text) {
  if (!AL) return;
  var url = 'whatsapp://send?text=' + encodeURIComponent(text);
  var can = await AL.canOpenUrl({ url: url });
  if (!can.value) { alert('WhatsApp is not installed'); return; }
  var r = await AL.openUrl({ url: url });
  if (!r.completed) alert('Could not open WhatsApp');
}
```

**In an AI-built app:**

```ts
import { appLauncher } from '@/lib/appmintNative';

if (await appLauncher.canOpen('tel:+15551234567')) await appLauncher.open('tel:+15551234567');
```

**Open a map link without asking first:**

```js
async function openMap(query) {
  const AL = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppLauncher;
  if (!AL) { window.open('https://maps.google.com/?q=' + encodeURIComponent(query)); return; }
  const r = await AL.openUrl({ url: 'geo:0,0?q=' + encodeURIComponent(query) });
  if (!r.completed) alert('No maps app could open this');
}
```

**Notes:** On Android 11+ an app can only "see" other apps it declares. Generated apps declare `https`, `geo`, `google.navigation`, `tel`, `mailto`, `sms`, `whatsapp`, `intent` and `upi` links, so `canOpenUrl` answers `false` for other schemes (for example `spotify:`) even when the app is installed. `openUrl` still works for them — call it and check `completed`. On Android `canOpenUrl` also accepts a package name (`'com.whatsapp'`), with the same visibility limit.

### `Capacitor.Plugins.Browser`

**Example**

Opens a web page in an in-app browser tab (Chrome Custom Tabs), so the user reads terms, docs or a sign-in page and comes back with one gesture (`@capacitor/browser` 8.0.3).

**Returns:** `open()` / `close()` → Promise `void`. Events: `browserFinished` (the user closed the tab) and `browserPageLoaded`. **Needs:** nothing — every app hosts the plugin (a browser that supports Custom Tabs, such as Chrome, must be installed).

```js
var Browser = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Browser;

async function openTerms() {
  if (!Browser) return;
  var h = await Browser.addListener('browserFinished', function () {
    console.log('user closed the terms');
    h.remove();
  });
  await Browser.open({ url: 'https://example.com/terms', toolbarColor: '#0B0F1A' });
}
```

**In an AI-built app** (a new tab on the web):

```ts
import { browser } from '@/lib/appmintNative';

const off = browser.onClosed(() => refreshAccount());
await browser.open('https://example.com/terms', { toolbarColor: '#0B0F1A' });
// call off() when you no longer care
```

Open a link, and close the tab when a sign-in return reaches the page:

```js
var Browser = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Browser;

async function openHelp() {
  if (!Browser) { window.open('https://example.com/help', '_blank'); return; }
  try {
    await Browser.open({ url: 'https://example.com/help' });
  } catch (e) {
    alert('Could not open the page: ' + e.message);
  }
}

window.addEventListener('appmint:deep-link', function () {
  if (Browser) Browser.close().catch(function () {});
});
```

**Notes:** `url` must not be empty. `toolbarColor` is a hex colour; an invalid one is ignored. `close()` also works on Android in this plugin version (it finishes the tab's host activity) — useful after a sign-in link returns to the app. The tab is a separate browser: it does not share the app's cookies or `localStorage`.

### `Capacitor.Plugins.Camera`

**Example**

The official Capacitor Camera plugin, built into the app: take a photo with the camera or choose photos from the gallery.

**Returns:** Promises. `getPhoto(opts)` → `{ dataUrl | base64String | webPath, format, saved }` (which field depends on `resultType`), and REJECTS when the user cancels; `pickImages(opts)` → `{ photos: [{ webPath, format }] }`; `checkPermissions()` / `requestPermissions({ permissions })` → `{ camera, photos }`. **Needs:** **Camera** in Step 4 (Access) — the build turns it on when your code calls `Camera.getPhoto(`. `window.Capacitor` exists only inside the app.

**Take a photo and show it.**

```js
async function takePhoto() {
  var cam = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Camera;
  if (!cam) { showMessage('Open the app to use the camera.'); return; }
  try {
    var perm = await cam.requestPermissions({ permissions: ['camera'] });
    if (perm.camera !== 'granted') { showMessage('Camera permission was refused.'); return; }

    var photo = await cam.getPhoto({
      source: 'CAMERA',          // 'CAMERA' | 'PHOTOS' | 'PROMPT'
      resultType: 'dataUrl',     // 'dataUrl' | 'base64' | 'uri'
      quality: 80,
      direction: 'REAR',         // 'REAR' | 'FRONT'
      correctOrientation: true,
      saveToGallery: false
    });
    document.getElementById('photo').src = photo.dataUrl;
  } catch (err) {
    // The user closed the camera (or no camera app). Nothing to do.
  }
}
```

**Choose up to 5 photos from the gallery.**

```js
async function choosePhotos() {
  var cam = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Camera;
  if (!cam) return;
  try {
    var result = await cam.pickImages({ quality: 80, limit: 5 });
    result.photos.forEach(function (p) {
      var img = document.createElement('img');
      img.src = p.webPath;       // a URL the page can load
      document.getElementById('gallery').appendChild(img);
    });
  } catch (err) { /* cancelled */ }
}
```

**In an AI-built app** use the wrapper, which also works on the web (and resolves `null` instead of throwing on cancel):

```ts
import { camera } from '@/lib/appmintNative';
const photo = await camera.take({ source: 'camera' });
```

**Notes:** If **Camera** was off at build time, the camera permission is refused with no prompt. An `<input type="file" accept="image/*" capture>` also opens the camera. Camera is sensitive: declare it in your Play Data safety form.

### `Capacitor.Plugins.Geolocation`

**Example**

The official Capacitor Geolocation plugin, built into the app: the phone's position once, or continuous updates.

**Returns:** Promises. `getCurrentPosition(opts)` → `{ timestamp, coords: { latitude, longitude, accuracy, altitude, speed, heading, … } }`; `watchPosition(opts, callback)` → the watch id (a string); `clearWatch({ id })`; `checkPermissions()` / `requestPermissions()` → `{ location, coarseLocation }` (`'granted' | 'denied' | 'prompt' | …`). **Needs:** **GPS** and/or **Location** in Step 4 (Access) — the build turns them on when your code calls `getCurrentPosition` or `watchPosition`. `window.Capacitor` exists only inside the app.

**Position once, asking first.**

```js
async function whereAmI() {
  var geo = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Geolocation;
  if (!geo) { showMessage('Open the app to use location.'); return; }
  try {
    var perm = await geo.checkPermissions();
    if (perm.location !== 'granted' && perm.coarseLocation !== 'granted') {
      perm = await geo.requestPermissions();
      if (perm.location !== 'granted' && perm.coarseLocation !== 'granted') {
        showMessage('Location is off for this app.');
        return;
      }
    }
    var pos = await geo.getCurrentPosition({ enableHighAccuracy: true, timeout: 15000 });
    showMessage(pos.coords.latitude.toFixed(5) + ', ' + pos.coords.longitude.toFixed(5));
  } catch (err) {
    showMessage('No location: ' + err.message);   // location off in settings, timeout, …
  }
}
```

**Continuous updates.** The callback gets `(position, error)`.

```js
var watchId = null;

async function startTracking() {
  var geo = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Geolocation;
  if (!geo) return;
  watchId = await geo.watchPosition({ enableHighAccuracy: true }, function (pos, err) {
    if (err || !pos) return;
    addPointToMap(pos.coords.latitude, pos.coords.longitude);
  });
}

async function stopTracking() {
  var geo = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Geolocation;
  if (geo && watchId) { await geo.clearWatch({ id: watchId }); watchId = null; }
}
```

**In an AI-built app** use the wrapper, which also works on the web:

```ts
import { Geolocation } from '@/lib/appmintNative';
const pos = await Geolocation.getCurrentPosition({ enableHighAccuracy: true });
```

**Notes:** Plain `navigator.geolocation` works in the app too. If the location switches were off at build time, the permission is refused with no prompt. On Android 12+ the user may allow only approximate location (`coarseLocation` granted). Location is personal data: declare it in your Play Data safety form.

### `Capacitor.Plugins.ScreenReader`

**Example**

Tells the page whether TalkBack is on, and speaks an announcement (`@capacitor/screen-reader` 8.0.1).

**Returns:** `isEnabled()` → Promise `{ value: boolean }`. `speak()` → Promise `void`. The `stateChange` event passes `{ value: boolean }` when TalkBack is switched on or off. **Needs:** nothing — every app hosts the plugin.

```js
var SR = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ScreenReader;

function setCalmMode(on) {
  document.documentElement.classList.toggle('no-motion', on);   // shorten animations
}

if (SR) {
  SR.isEnabled().then(function (r) { setCalmMode(r.value); });
  SR.addListener('stateChange', function (s) { setCalmMode(s.value); });
}

async function announceSaved() {
  if (SR) await SR.speak({ value: 'Order placed', language: 'en' });
}
```

**In an AI-built app** (`speak` uses text-to-speech on the web; `isEnabled()` is `false` there):

```ts
import { screenReader } from '@/lib/appmintNative';

const on = await screenReader.isEnabled();
const off = screenReader.onChange((enabled) => setCalmMode(enabled));
await screenReader.speak('Order placed', 'en');
```

**Feature-detect and keep the page usable without the plugin:**

```js
var SR = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ScreenReader;

async function say(message) {
  if (SR) { await SR.speak({ value: message }); return; }
  document.getElementById('live-region').textContent = message;   // an aria-live="polite" element
}
```

**Notes:** `language` defaults to `'en'`. Prefer normal accessible markup (labels, `aria-live` regions) for most announcements; use `speak` for short, important state changes. There is no web API to detect a screen reader, so `can('screenReader')` is `'none'` in a browser.

### `Capacitor.Plugins.TextZoom`

**Example**

Reads and changes the text size of the app's page, for an in-app "text size" control (`@capacitor/text-zoom` 8.0.1).

**Returns:** `get()` → Promise `{ value }` (current zoom, `1` = 100%). `getPreferred()` → `{ value }` (the size the user picked in Android settings). `set({ value })` → Promise `void`. **Needs:** nothing — every app hosts the plugin.

```js
var TZ = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.TextZoom;

async function biggerText() {
  if (!TZ) return;
  var now = (await TZ.get()).value;
  await TZ.set({ value: Math.min(2, now + 0.1) });
}

async function resetText() {
  if (!TZ) return;
  var pref = (await TZ.getPreferred()).value;   // e.g. 1.15 when the phone uses large text
  await TZ.set({ value: pref });
}
```

**In an AI-built app** (on the web it scales the root font size instead):

```ts
import { textZoom } from '@/lib/appmintNative';

const size = await textZoom.get();
await textZoom.set(size + 0.1);                     // clamped to 0.5–3
await textZoom.set(await textZoom.getPreferred());  // back to the system size
```

**A text size slider that remembers the choice:**

```js
var TZ = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.TextZoom;
var slider = document.getElementById('text-size');   // <input type="range" min="0.8" max="1.6" step="0.1">

if (TZ) {
  var saved = parseFloat(localStorage.getItem('textZoom'));
  if (saved) TZ.set({ value: saved });
  slider.addEventListener('change', function () {
    TZ.set({ value: parseFloat(slider.value) });
    localStorage.setItem('textZoom', slider.value);
  });
} else {
  slider.hidden = true;
}
```

**Notes:** The app already follows the system text size; this is for an extra in-app control. Test your layout at large sizes — fixed heights on text controls will clip.

### `Capacitor.Plugins.FileViewer`

**Example**

The official Capacitor File Viewer plugin: opens a document or media file in the phone's viewer app, from a web address, a local file path, or a file bundled in the app.

**Returns:** each method returns a `Promise<void>` that rejects with `{message, code}` when nothing can open the file. **Needs:** nothing to switch on. `window.Capacitor` exists only in pages the app itself serves — always feature-detect.

```js
async function openPdf(url) {
  const FV = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.FileViewer;
  if (!FV) { window.open(url, '_blank'); return; }          // a normal browser: open a tab
  try {
    await FV.openDocumentFromUrl({ url: url });
  } catch (e) {
    alert('No app on this phone can open this file.');
  }
}
```

A file on the phone, for example one written with `Capacitor.Plugins.Filesystem`:

```js
async function openSaved(name) {
  const P = window.Capacitor && window.Capacitor.Plugins;
  if (!P || !P.FileViewer || !P.Filesystem) return;
  const f = await P.Filesystem.getUri({ path: name, directory: 'CACHE' });
  try {
    await P.FileViewer.openDocumentFromLocalPath({ path: f.uri.replace(/^file:\/\//, '') });
  } catch (e) {
    alert('Cannot open ' + name + ': ' + e.message);
  }
}
```

`openDocumentFromResources({path})` opens a file packed in the APK's own assets folder. The same three methods also exist as `previewMediaContentFromUrl`, `previewMediaContentFromLocalPath` and `previewMediaContentFromResources` for video and audio.

Download a file and open it when it arrives:

```js
async function downloadAndOpen(url, name) {
  const P = window.Capacitor && window.Capacitor.Plugins;
  if (!P || !P.FileTransfer || !P.Filesystem || !P.FileViewer) return;
  const f = await P.Filesystem.getUri({ path: name, directory: 'CACHE' });
  const r = await P.FileTransfer.downloadFile({ url: url, path: f.uri });
  await P.FileViewer.openDocumentFromLocalPath({ path: r.path.replace(/^file:\/\//, '') });
}
```

**Notes:** In AI-built apps prefer `fileViewer.open(target)` from `@/lib/appmintNative`, which picks the right method and also works on the web. A local path is a plain path (no `file://`).

### `Capacitor.Plugins.FileTransfer`

**Example**

The official Capacitor File Transfer plugin: downloads a URL to a file on the phone, or uploads a file from the phone, natively, with progress events.

**Returns:** `downloadFile({url, path, headers?, progress?})` → `Promise<{path}>`; `uploadFile({url, path, fileKey?, mimeType?, method?, headers?, progress?})` → `Promise<{bytesSent, responseCode, response, headers}>`. Progress arrives as `'progress'` listener events `{type, url, bytes, contentLength, lengthComputable}`. Failures reject with `{message, code}`. **Needs:** nothing to switch on. `window.Capacitor` exists only in pages the app itself serves — always feature-detect.

Download into the app's cache with progress:

```js
async function download(url, name, onPct) {
  const P = window.Capacitor && window.Capacitor.Plugins;
  if (!P || !P.FileTransfer || !P.Filesystem) return null;          // not in the app
  const target = await P.Filesystem.getUri({ path: name, directory: 'CACHE' });
  const listener = await P.FileTransfer.addListener('progress', function (p) {
    if (p.url === url && p.lengthComputable) onPct(Math.round(p.bytes / p.contentLength * 100));
  });
  try {
    const r = await P.FileTransfer.downloadFile({ url: url, path: target.uri, progress: true });
    return r.path;
  } catch (e) {
    alert('Download failed: ' + e.message);
    return null;
  } finally {
    listener.remove();
  }
}
```

Upload a file that is already on the phone:

```js
const P = window.Capacitor && window.Capacitor.Plugins;
if (P && P.FileTransfer) {
  P.FileTransfer.uploadFile({
    url: 'https://example.com/api/upload', path: localFileUri,
    fileKey: 'file', mimeType: 'image/jpeg', headers: { Authorization: 'Bearer ' + token }
  }).then(function (r) { console.log('server said', r.responseCode, r.response); })
    .catch(function (e) { alert('Upload failed: ' + e.message); });
}
```

Report a failed transfer by its code:

```js
async function safeDownload(url, target) {
  const P = window.Capacitor && window.Capacitor.Plugins;
  if (!P || !P.FileTransfer) { alert('Downloads need the app'); return null; }
  try {
    const r = await P.FileTransfer.downloadFile({ url: url, path: target });
    return r.path;
  } catch (e) {
    console.log('transfer failed', e.code, e.message);
    alert('Could not download the file.');
    return null;
  }
}
```

**Notes:** The path must be a file path or `file://` URI, not a Blob — write a Blob to the cache with `Filesystem.writeFile` first. On Android 10 and older, a path in public storage asks for storage permission. In AI-built apps prefer `fileTransfer` from `@/lib/appmintNative`, which does all of this and also works on the web.

### `Capacitor.Plugins.Calendar`

**Example**

Adds, finds and deletes events in the phone's own calendar (Android CalendarContract), and opens the calendar app at a date. Vendored `@capacitor/calendar` (`template/plugins/calendar`).

**Returns:** `createEvent()` → `{ id }`; `findEvents()` → `{ events: [{ id, title, location?, notes?, startDate, endDate, isAllDay, calendarId, calendarName?, attendees? }] }`; `listCalendars()` → `{ calendars: [{ id, name, displayName?, isPrimary }] }`; `deleteEvent()`, `openCalendar()` → `void`. Dates are epoch milliseconds. **Needs:** turn on **Calendar (read & write events)** in the permissions step when you build (the build ticks it for you when it sees a calendar call — check it). The plugin asks the user for calendar access on first use.

```js
var Cal = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Calendar;

async function addDentist() {
  if (!Cal) return;
  var start = new Date('2026-10-02T10:00:00').getTime();
  try {
    var r = await Cal.createEvent({
      title: 'Dentist', startDate: start, endDate: start + 60 * 60 * 1000,
      location: 'Main St 5', notes: 'Bring the card', isAllDay: false,
      firstReminderMinutes: 30
    });
    console.log('event id', r.id);
  } catch (e) {
    // e.code: OS-PLUG-CLDR-0020 permission denied, -0001 bad arguments
    alert('Could not add the event: ' + e.message);
  }
}
```

**Read this week, delete one, open the calendar:**

```js
async function thisWeek() {
  if (!Cal) return;
  var now = Date.now();
  var found = await Cal.findEvents({ startDate: now, endDate: now + 7 * 864e5 });
  found.events.forEach(function (ev) { console.log(ev.title, new Date(ev.startDate)); });
  if (found.events.length) await Cal.deleteEvent({ id: found.events[0].id });
  await Cal.openCalendar({ date: now });
}
```

**In an AI-built app** (on the web `createEvent` downloads an `.ics` file; reading returns `[]`):

```ts
import { calendar } from '@/lib/appmintNative';

if ((await calendar.requestPermission()) === 'granted') {
  const id = await calendar.createEvent({ title: 'Dentist', start: new Date('2026-10-02T10:00'), end: new Date('2026-10-02T11:00') });
  const week = await calendar.listEvents({ from: Date.now(), to: Date.now() + 7 * 864e5 });
  if (id) await calendar.deleteEvent(id);
  await calendar.openCalendar(new Date());
}
```

**Notes:** Without the Calendar permission in the build, no dialog appears and every event call rejects (missing permission); a user who says no gets `OS-PLUG-CLDR-0020`. `recurrence: { frequency: 'daily' | 'weekly' | 'monthly' | 'yearly', interval?, count?, endDate? }` makes a repeating event. `createEventInteractively({ title, startDate, endDate })` opens the calendar app's own editor instead (no id comes back). The wrapper returns `null` / `[]` / `false` instead of throwing.

### `Capacitor.Plugins.KeepAwake`

**Example**

Stops the screen from dimming and locking while something must stay visible — a recipe, a route, a QR code, a timer. Vendored `@capacitor-community/keep-awake` (`template/plugins/keep-awake`).

**Returns:** `keepAwake()` / `allowSleep()` → Promise `void`. `isKeptAwake()` → `{ isKeptAwake: boolean }`. `isSupported()` → `{ isSupported: true }`. **Needs:** nothing — every app hosts the plugin; no permission.

```js
var KA = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake;

async function startCooking() {
  if (KA) await KA.keepAwake();
  showSteps();
}

async function stopCooking() {
  if (KA) await KA.allowSleep();      // always release when the view closes
}

if (KA) KA.isKeptAwake().then(function (r) { console.log('awake?', r.isKeptAwake); });
```

**In an AI-built app** (the Screen Wake Lock API on the web):

```ts
import { keepAwake } from '@/lib/appmintNative';

useEffect(() => {
  void keepAwake(true);
  return () => { void keepAwake(false); };
}, []);
```

Keep the screen on only while a timer runs:

```js
var KA = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake;

async function runTimer(seconds) {
  if (KA) await KA.keepAwake();
  await new Promise(function (done) { setTimeout(done, seconds * 1000); });
  if (KA) await KA.allowSleep();
  alert('Time is up');
}
```

**Notes:** The flag belongs to the app window: it only works while the app is in front, and it stays on until `allowSleep()` — release it on every exit from the screen. `keepAwake()` returns `false` when nothing could keep the screen on.

### `Capacitor.Plugins.ScreenBrightness`

**Example**

Sets the app window's screen brightness — full brightness for a boarding pass or QR code, then back. Vendored `@capacitor-community/screen-brightness` (`template/plugins/screen-brightness`).

**Returns:** `getBrightness()` → Promise `{ brightness }` (0–1, or `-1` when the app has not overridden it and the screen follows the system). `setBrightness({ brightness })` → Promise `void`. **Needs:** nothing — every app hosts the plugin; no permission (it changes only this app's window, not the system setting).

```js
var SB = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ScreenBrightness;

async function showTicket() {
  if (SB) await SB.setBrightness({ brightness: 1 });     // full brightness
  document.getElementById('qr').hidden = false;
}

async function hideTicket() {
  if (SB) await SB.setBrightness({ brightness: -1 });    // -1 = follow the system again
  document.getElementById('qr').hidden = true;
}
```

**In an AI-built app:**

```ts
import { brightness } from '@/lib/appmintNative';

await brightness.set(1);        // clamped to 0–1; false where not available (web)
// ... on leaving the screen
await brightness.restore();
const level = await brightness.get();   // -1 = following the system / not available
```

Read the current level first and restore exactly that:

```js
var SB = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.ScreenBrightness;
var before = -1;

async function boost() {
  if (!SB) return;
  before = (await SB.getBrightness()).brightness;
  await SB.setBrightness({ brightness: 1 });
}
async function undo() {
  if (SB) await SB.setBrightness({ brightness: before });
}
```

**Notes:** Always pass a number — `setBrightness({})` has no value to apply. Restore with `-1` when the view closes; the override also ends when the app closes. A web page cannot drive the backlight, so `can('brightness')` is `'none'` in a browser.

### `Capacitor.Plugins.InAppReview`

**Example**

Asks Google Play to show its own "rate this app" sheet inside the app. The `@capacitor-community/in-app-review` API, answered by the app's own review code (the same as `AppMint.requestReview()`).

**Returns:** a Promise that resolves once with `{result, openedStore, message?}`: `result` is `'shown'` (Play put its sheet up — it never says whether the user rated), `'not_from_play'` (a sideloaded or test install, where Play never shows the sheet), `'quota'` (Play chose not to show it now) or `'error'`. `{openStore: true}` opens the Play listing when the sheet did not show. Code that ignores the value keeps working. **Needs:** nothing to switch on; the app must be installed from Google Play for the sheet to appear.

```js
var Review = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.InAppReview;

async function afterThirdOrder() {
  if (!Review) return;
  var r = await Review.requestReview();
  console.log('review:', r.result);   // 'shown' | 'not_from_play' | 'quota' | 'error'
  // continue the app flow either way — you cannot know if the user rated
}
```

**In an AI-built app:**

```ts
import { askForReview } from '@/lib/appmintNative';

// after a success moment, not on launch
const r = await askForReview();   // r.result: 'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable' (web)
```

Ask once, after a success moment, and remember that you asked:

```js
var Review = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.InAppReview;

async function maybeAskForReview() {
  if (!Review || localStorage.getItem('review_asked')) return;
  localStorage.setItem('review_asked', '1');
  try { await Review.requestReview(); } catch (e) { console.log('review not shown:', e.message); }
}
```

**Notes:** Play limits how often the sheet appears and does not show it for every call — never tie a reward to it and never ask from a "Rate us" button expecting it to open (open the store listing for that). Ask after a success moment, not at startup. A sideloaded or debug install always answers `not_from_play`.

### `Capacitor.Plugins.AppUpdate`

**Example**

Checks Google Play for a newer version of the app and starts Play's in-app update. Vendored `@capawesome/capacitor-app-update` (`template/plugins/app-update`).

**Returns:** `getAppUpdateInfo()` → `{ currentVersionName, currentVersionCode, availableVersionCode, updateAvailability, updatePriority, immediateUpdateAllowed, flexibleUpdateAllowed, clientVersionStalenessDays?, installStatus }` (version codes are strings; `updateAvailability` `2` = an update is available). `performImmediateUpdate()` / `startFlexibleUpdate()` → `{ code }`: `0` OK, `1` cancelled, `2` failed, `3` not available, `4` not allowed, `5` call `getAppUpdateInfo()` first. `openAppStore()` → `void`. **Needs:** nothing to switch on; the app must be installed from Google Play.

```js
var AU = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppUpdate;

async function checkForUpdate() {
  if (!AU) return;
  try {
    var info = await AU.getAppUpdateInfo();          // must come first
    if (info.updateAvailability !== 2) return;       // up to date
    if (info.immediateUpdateAllowed) {
      var r = await AU.performImmediateUpdate();     // Play's full-screen update
      if (r.code === 1) console.log('user said not now');
    } else {
      await AU.openAppStore();                       // the Play listing
    }
  } catch (e) {
    console.log('Play update check failed:', e.message);  // e.g. no Play services
  }
}
```

**Flexible update** — download in the background, then restart when the user agrees:

```js
async function flexibleUpdate() {
  if (!AU) return;
  var info = await AU.getAppUpdateInfo();
  if (info.updateAvailability !== 2 || !info.flexibleUpdateAllowed) return;
  await AU.addListener('onFlexibleUpdateStateChange', function (s) {
    if (s.installStatus === 2) console.log(s.bytesDownloaded + ' / ' + s.totalBytesToDownload);
    if (s.installStatus === 11) showRestartButton();   // 11 = DOWNLOADED
  });
  await AU.startFlexibleUpdate();
}
function restartNow() { if (AU) AU.completeFlexibleUpdate(); }
```

**In an AI-built app:**

```ts
import { appUpdate } from '@/lib/appmintNative';

const info = await appUpdate.check();          // null on the web / without Play
if (info?.inPlace) await appUpdate.start();    // Android: immediate update
```

**Notes:** Use `updateAvailability === 2` as the only "update available" test. The wrapper's `check()` compares `currentVersionName` with `availableVersionCode`, which are never equal on Android, so its `updateAvailable` is not reliable there — test `inPlace` instead. `start()` resolves `true` even when the user cancels.

### `Capacitor.Plugins.Badge`

**Example**

The Badge plugin (capawesome) that sets the number on the app's launcher icon.

**Returns:** Promises: `set({ count })`, `clear()`, `increase()`, `decrease()` → `void`; `get()` → `{ count }`; `isSupported()` → `{ isSupported }`. (iOS also has `checkPermissions()` / `requestPermissions()`; the Android plugin does not.) **Needs:** nothing on Android.

**In an AI-built app** use the wrapper `badge` from `@/lib/appmintNative`:

```ts
import { badge } from '@/lib/appmintNative';
await badge.set(3);
await badge.clear();
```

**In a hand-written page:**

```js
async function setUnread(count) {
  const B = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Badge;
  if (!B) return false;                       // not inside the app
  try {
    const s = await B.isSupported();
    if (!s.isSupported) return false;         // this launcher shows no badges
    if (count > 0) await B.set({ count: count });
    else await B.clear();
    return true;
  } catch (e) {
    return false;
  }
}

window.addEventListener('appmint:push', function () {
  const inbox = JSON.parse(window.WebToApk ? WebToApk.getPushInbox() : '[]');
  setUnread(inbox.filter(function (m) { return !m.read; }).length);
});
```

**Count up and down as items arrive and are read:**

```js
const B = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Badge;
async function onNewMessage() { if (B) await B.increase(); }
async function onMessageRead() { if (B) await B.decrease(); }
async function showCurrent() {
  if (!B) return;
  const { count } = await B.get();
  console.log('badge shows', count);
}
```

**Notes:** on Android the look depends on the launcher (a number, a dot, or nothing). `navigator.setAppBadge(n)` in the app writes to the same badge.

### `Capacitor.Plugins.SpeechRecognition`

**Example**

Turns the user's speech into text with Android's speech recognizer (the community `@capacitor-community/speech-recognition` plugin, built into the app). Android WebView has no Web Speech recognition of its own, so this is the way to do dictation.

**Returns:** Promises. `available()` → `{ available }`. `checkPermissions()` / `requestPermissions()` → `{ speechRecognition: 'granted' | 'denied' | 'prompt' }`. `start({ partialResults: false })` → `{ status: 'success', matches: [...] }` after the user stops talking; it rejects on errors such as no speech or no match. **Needs:** turn on **Mic** in Step 4 (Access) when you build, and the user must allow the microphone.

One-shot dictation (the same way `@/lib/appmintNative` `speech` uses it):

```js
var SR = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins.SpeechRecognition : null;

async function dictate() {
  var out = document.getElementById('text');
  if (!SR || !(await SR.available()).available) { out.textContent = 'Voice input is not available on this phone.'; return; }

  var p = (await SR.checkPermissions()).speechRecognition;
  if (p !== 'granted') p = (await SR.requestPermissions()).speechRecognition;
  if (p !== 'granted') { out.textContent = 'Microphone permission was not given.'; return; }

  try {
    var r = await SR.start({ language: 'en-US', maxResults: 3, partialResults: false, popup: false });
    out.textContent = r.matches && r.matches.length ? r.matches[0] : '';
  } catch (e) {
    out.textContent = 'Did not catch that. Try again.';
  }
}

document.getElementById('mic').addEventListener('click', dictate);
```

Live text while the user speaks (`partialResults: true`), and the listening state:

```js
// Add the listeners ONCE, when the page starts.
if (SR) {
  SR.addListener('partialResults', function (d) {
    // Words as they come, and then the final text, all arrive here.
    if (d.matches && d.matches.length) document.getElementById('text').textContent = d.matches[0];
  });
  SR.addListener('listeningState', function (d) {
    // 'started' = the user began speaking, 'stopped' = the user stopped speaking
    document.body.classList.toggle('listening', d.status === 'started');
  });
}

document.getElementById('mic-live').addEventListener('click', async function () {
  if (!SR) return;
  try {
    await SR.start({ language: 'en-US', partialResults: true, popup: false });   // resolves at once, no matches
  } catch (e) {
    document.getElementById('text').textContent = 'Could not start: ' + e.message;
  }
});

document.getElementById('stop').addEventListener('click', function () { if (SR) SR.stop(); });
```

**Show the mic button only when dictation can work, and stop listening when the page is hidden:**

```js
var SR = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins.SpeechRecognition : null;
var mic = document.getElementById('mic');

mic.hidden = true;
if (SR) {
  SR.available().then(function (r) { mic.hidden = !r.available; });
}

document.addEventListener('visibilitychange', function () {
  if (document.hidden && SR) SR.stop();
});
```

**Notes:**
- In live mode (`partialResults: true`), the final text arrives **after** the `'stopped'` state, so do not remove the `partialResults` listener on `'stopped'`. An error after the start (no speech, no match) is not reported in this mode.
- `language` is a tag like `'en-US'` or `'ta-IN'` (default: the phone's language). `popup: true` shows Google's own listening dialog instead. `getSupportedLanguages()` → `{ languages }` and `isListening()` → `{ listening }` also exist.
- `start()` rejects with `'Missing permission'` if you did not ask first. Listening stops by itself after a pause in speech.

### `Capacitor.Plugins.PrivacyScreen`

**Example**

The official `@capacitor/privacy-screen` plugin: blocks screenshots and screen recording and hides the app's content in the recent-apps view. Baked so imported Capacitor projects that call it work.

**Returns:** `enable({android})` → `{success}`; `disable()` → `{success}`; `isEnabled()` → `{enabled}`. **Needs:** nothing to switch on.

```js
const Privacy = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.PrivacyScreen;

async function openVault() {
  if (Privacy) await Privacy.enable({ android: { privacyModeOnActivityHidden: 'dim' } });
  showVault();
}
async function closeVault() {
  if (Privacy) await Privacy.disable();
}
```

Check the current state before toggling:

```js
async function toggleProtection() {
  if (!Privacy) return;
  var s = await Privacy.isEnabled();
  if (s.enabled) await Privacy.disable();
  else await Privacy.enable();
}
```

Protect the screen only while a sensitive view is open:

```js
async function showCardNumber() {
  if (Privacy) await Privacy.enable({ android: { privacyModeOnActivityHidden: 'splash' } });
  cardBox.hidden = false;
}
async function hideCardNumber() {
  cardBox.hidden = true;
  if (Privacy) await Privacy.disable();
}
```

**Notes:** `privacyModeOnActivityHidden`: `'none'`, `'dim'` or `'splash'`. Code written for the app itself should use `WebToApk.setSecureScreen(true)` / `privacyScreen.enable()` from `@/lib/appmintNative`, which follows the page (released when the app leaves it); mixing both, the last call wins.

### `Capacitor.Plugins.TextToSpeech`

**Example**

The community `@capacitor-community/text-to-speech` plugin over the phone's text-to-speech engine. Baked so imported Capacitor projects that call it work.

**Returns:** `speak({text, lang, rate, pitch, volume, voice})` → Promise that resolves when the sentence has been spoken; `stop()`; `getSupportedLanguages()` → `{languages}`; `getSupportedVoices()` → `{voices}`; `isLanguageSupported({lang})` → `{supported}`. **Needs:** nothing to switch on.

```js
const Tts = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.TextToSpeech;

async function readAloud(text) {
  if (!Tts) { speechSynthesis.speak(new SpeechSynthesisUtterance(text)); return; }
  await Tts.speak({ text: text, lang: 'en-US', rate: 1.0, pitch: 1.0, volume: 1.0 });
}
document.getElementById('stop').onclick = () => Tts && Tts.stop();
```

**Check the language before speaking and show a message when it is not installed:**

```js
const Tts = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.TextToSpeech;

async function speakIn(lang, text) {
  if (!Tts) return false;
  const { supported } = await Tts.isLanguageSupported({ lang: lang });
  if (!supported) { showMessage('This phone has no ' + lang + ' voice installed.'); return false; }
  await Tts.speak({ text: text, lang: lang });
  return true;
}
```

**Read a paragraph aloud with a stop button, and handle a speak error:**

```js
const Tts = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.TextToSpeech;

async function readParagraph(el, btn) {
  if (!Tts) return;
  btn.textContent = 'Stop';
  btn.onclick = () => Tts.stop();
  try {
    await Tts.speak({ text: el.textContent, lang: 'en-US', rate: 0.9 });   // resolves when finished
  } catch (e) {
    console.warn('could not speak:', e.message);
  } finally {
    btn.textContent = 'Listen';
    btn.onclick = () => readParagraph(el, btn);
  }
}
```

**Notes:** `window.speechSynthesis` works in the app too (the shell's own engine) and is what AI-built apps use through `speak()`; this plugin is the Capacitor-shaped path.

### `Capacitor.Plugins.AppwrightHaptics`

**Example**

Appwright's own plugin for rich vibration patterns: a list of beats, each with its own start time, length and strength.

**Returns:** `play({ events })` resolves `{ ok: true }` or `{ ok: false, error }` (`'unsupported'` = no vibrator, `'empty'` = no events, or the system's message). It never rejects. `isSupported()` resolves `{ value: true/false }`. **Needs:** turn on **Vibrate** in Step 4 (Access) when you build. `window.Capacitor` exists only inside the app.

Each event is `{ at, duration, intensity, sharpness }`: `at` = start in ms, `duration` = length in ms (under 20 becomes a 30 ms tap), `intensity` = 0–1 (default 0.8). `sharpness` is used on iOS only.

```js
var AH = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins.AppwrightHaptics : null;

async function heartbeat() {
  if (!AH) return false;
  var r = await AH.play({
    events: [
      { at: 0,   intensity: 0.8 },
      { at: 140, intensity: 0.5 },
      { at: 700, intensity: 0.8 },
      { at: 840, intensity: 0.5 }
    ]
  });
  if (!r.ok) console.log('pattern not played:', r.error);
  return r.ok;
}

document.getElementById('like').addEventListener('click', heartbeat);
```

Hide the button on phones with no vibrator:

```js
(async function () {
  var btn = document.getElementById('feel-it');
  var ok = false;
  if (AH) { try { ok = (await AH.isSupported()).value; } catch (e) {} }
  btn.hidden = !ok;
})();
```

Play a quick double tap on a successful action, and report when it cannot play:

```js
async function successBuzz() {
  const AH = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightHaptics;
  if (!AH) return;
  const r = await AH.play({ events: [{ at: 0, duration: 40, intensity: 1 }, { at: 120, duration: 40, intensity: 0.6 }] });
  if (!r.ok && r.error === 'unsupported') console.log('this phone has no vibrator');
}
```

**Notes:** Strength works only on phones with amplitude control; other phones keep the timing at one fixed strength. Without the Vibrate permission, `play` answers `{ ok: false, error: '...' }`. In AI-built apps use `hapticPlay` from `@/lib/appmintNative`, which also has named patterns.

### `Capacitor.Plugins.AppwrightDocScanner`

**Example**

Opens the Google ML Kit document scanner: the user photographs one or more pages (or imports them), the edges are found and straightened, and the pages come back as JPEG images. Appwright's own plugin (`AppwrightDocScannerPlugin.kt`).

**Returns:** `scan()` → Promise that always RESOLVES: `{ ok: true, pages: string[] (JPEG data URLs), pdfDataUrl?, pdfPath?, pdfError? }` on success, `{ ok: false, cancelled: true }` when the user backs out, `{ ok: false, error }` when the scanner cannot start. `isSupported()` → `{ value: boolean }` (Google Play services present). **Needs:** nothing to switch on and no camera permission (the scanner runs inside Play services, which downloads its model on first use).

```js
var Scanner = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightDocScanner;

async function scanReceipt() {
  if (!Scanner) { alert('Scanning is not available here'); return; }
  var s = await Scanner.isSupported();
  if (!s.value) { alert('This phone has no Google Play services'); return; }
  var r = await Scanner.scan({ pageLimit: 1, pdf: false, quality: 0.8 });
  if (r.cancelled) return;
  if (!r.ok) { alert('Scanner error: ' + r.error); return; }
  document.getElementById('preview').src = r.pages[0];
}
```

`pageLimit` `0` = no limit. `quality` is 0.1–1 for the JPEG. With `pdf: true` the scanner also writes a PDF: `pdfDataUrl` is it as a `data:application/pdf;base64,…` URL the page can download, upload or show, and `pdfPath` is its copy in the app's cache folder. If the PDF could not be read, the pages still return and `pdfError` says why.

**In an AI-built app** (`null` on cancel, error, or the web):

```ts
import { docScanner, can } from '@/lib/appmintNative';

if (can('docScanner') !== 'none') {
  const res = await docScanner.scan({ pageLimit: 5, pdf: true });
  if (res) setPages(res.pages);          // res.pdfPath when pdf: true
}
```

**Scan several pages and offer the PDF:**

```js
async function scanContract() {
  const Scanner = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightDocScanner;
  if (!Scanner) return;
  const r = await Scanner.scan({ pageLimit: 0, pdf: true, quality: 0.9 });
  if (!r.ok) { if (!r.cancelled) alert('Scanner error: ' + r.error); return; }
  if (r.pdfDataUrl) {
    const a = document.createElement('a');
    a.href = r.pdfDataUrl; a.download = 'contract.pdf'; a.click();
  } else if (r.pdfError) console.warn('PDF not made:', r.pdfError);
  console.log(r.pages.length + ' pages scanned');
}
```

**Notes:** Pages are full data URLs and can be large — shrink them before saving many in `localStorage`. Use `pdfDataUrl`, not `pdfPath` — a device file path is not a URL the page can load. A page that cannot be read is skipped; the rest still return.

### `Capacitor.Plugins.AppwrightSystemPalette`

**Example**

Returns the phone's Material You colours (the palette Android 12+ builds from the wallpaper), so the app can match the system apps. Appwright's own plugin (`AppwrightSystemPalettePlugin.kt`).

**Returns:** Promise `{ available: false }` below Android 12, else `{ available: true, accent1, accent2, accent3, neutral1, neutral2 }` — each an object from tone to hex colour, with tones `'0'`, `'10'`, `'50'`, `'100'` … `'900'`, `'1000'` (e.g. `accent1['600'] === '#3F5F90'`). **Needs:** nothing — every app hosts the plugin.

```js
var Palette = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightSystemPalette;

async function useWallpaperColours() {
  if (!Palette) return;
  var p = await Palette.get();
  if (!p.available) return;                 // keep the app's own colours
  var root = document.documentElement.style;
  root.setProperty('--accent', p.accent1['600']);
  root.setProperty('--surface', p.neutral1['50']);
}
useWallpaperColours();
```

**In an AI-built app:**

```ts
import { systemPalette } from '@/lib/appmintNative';

const p = await systemPalette();
if (p.available && p.accent1) document.documentElement.style.setProperty('--accent', p.accent1['600']);
```

Pick light or dark tones to match the system theme, and read the palette again when the user comes back from changing the wallpaper:

```js
var Palette = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightSystemPalette;
var darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

async function applyPalette() {
  if (!Palette) return;
  var p = await Palette.get();
  if (!p.available) return;
  var dark = darkQuery.matches;
  var s = document.documentElement.style;
  s.setProperty('--accent', p.accent1[dark ? '200' : '600']);
  s.setProperty('--on-accent', p.accent1[dark ? '800' : '0']);
  s.setProperty('--bg', p.neutral1[dark ? '900' : '10']);
  s.setProperty('--text', p.neutral1[dark ? '100' : '900']);
}

darkQuery.addEventListener('change', applyPalette);
document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'visible') applyPalette();
});
applyPalette();
```

**Notes:** Check text contrast before you apply palette colours — a wallpaper tone is not guaranteed to read well on your background. iOS and browsers expose no wallpaper colours: the answer is `{ available: false }`. The colours change when the user changes the wallpaper; call `get()` again on resume.

### `Capacitor.Plugins.AppwrightLiveProgress`

**Example**

Appwright's own Android plugin for a sticky, silent, live-updating status notification (progress bar, spinner or running clock) that opens a route when tapped.

**Returns:** `start(opts)` / `update(opts)` → `Promise<{ ok: true, id }>` or `{ ok: false, error }` with nothing posted — `'permission'` when notifications are not allowed (Android 13+: not granted yet, or switched off for the app), `'channel_disabled'` when the user turned off the "Live status" category; `end({ id })` → `Promise<void>`. **Needs:** Android and the notification permission (Android 13+). Not on iOS.

**In an AI-built app** use the wrapper `liveProgress` from `@/lib/appmintNative`:

```ts
import { liveProgress } from '@/lib/appmintNative';
await liveProgress.start({ id: 'order', title: 'Order #42', text: 'Being prepared', indeterminate: true });
await liveProgress.update({ id: 'order', title: 'Order #42', text: 'On the way', progress: 0.6, route: '/orders/42' });
await liveProgress.end('order');
```

**In a hand-written page:**

```js
async function showDelivery(step, total) {
  const P = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightLiveProgress;
  if (!P) return;                                   // not in the Android app
  const r = await P.update({
    id: 'delivery',
    title: 'Your delivery',
    text: 'Stop ' + step + ' of ' + total,
    progress: step / total,                         // 0–1; or indeterminate: true
    route: '/track'                                 // opened when the card is tapped
  });
  if (!r.ok && r.error === 'permission') {
    await Notification.requestPermission();         // then try again
  }
}

async function deliveryDone() {
  const P = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightLiveProgress;
  if (P) await P.end({ id: 'delivery' });
}
```

A workout timer: `startedAt` makes the card count up on its own, so it is posted once and ended when the workout stops:

```js
const Live = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightLiveProgress;
let workoutStart = 0;

async function startWorkout(name) {
  workoutStart = Date.now();
  if (!Live) return;
  const r = await Live.start({ id: 'workout', title: name, text: 'Workout in progress', startedAt: workoutStart, route: '/workout' });
  if (!r.ok && r.error === 'channel_disabled') {
    showHint('Turn on "Live status" in this app\'s notification settings to see the timer.');
  }
}

async function stopWorkout() {
  if (Live) await Live.end({ id: 'workout' });
  saveWorkout({ durationMs: Date.now() - workoutStart });
}
```

**Options:** `id` (default `'live'`), `title`, `text`, `progress` (0–1, omit for no bar), `indeterminate`, `startedAt` (epoch ms, shows a running clock), `route`.

**Notes:** `start` and `update` do the same thing (post or replace the card with that id). The card cannot be swiped away — always call `end`.

### `Capacitor.Plugins.AppwrightWidget`

**Example**

Fills the app's home-screen widget: one card with a title, a big value, up to six lines, your colours and a tap route. The card is saved on the phone and shown even after a reboot. Appwright's own plugin (`AppwrightWidgetPlugin.kt`).

**Returns:** `set({ title?, value?, lines?, route?, bg?, ink?, accent? })` → Promise `{ ok: true, placed }` (`placed` = widgets currently on the home screen). `clear()` → `void`. `placedCount()` → `{ value }`. `requestPin()` → `{ value: boolean }` (`true` when the launcher's "add widget" sheet was opened). **Needs:** nothing — every app declares the widget.

```js
var Widget = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightWidget;

async function updateWidget(tasks) {
  if (!Widget) return;
  await Widget.set({
    title: 'Today',
    value: tasks.length + ' tasks',
    lines: tasks.slice(0, 6).map(function (t) { return t.title; }),
    route: '/today',                 // opened when the widget is tapped
    bg: '#0B0F1A', ink: '#FFFFFF', accent: '#22D3EE'
  });
}
```

**Offer to place it** (only when none is placed yet):

```js
async function offerWidget() {
  if (!Widget) return;
  var n = (await Widget.placedCount()).value;
  if (n > 0) return;
  var r = await Widget.requestPin();
  if (!r.value) alert('Long-press the home screen, tap Widgets and pick this app.');
}
```

**In an AI-built app:**

```ts
import { widget } from '@/lib/appmintNative';

await widget.set({ title: 'Today', value: '3 tasks', lines: ['Gym', 'Call mum'], route: '/today' });
if ((await widget.placedCount()) === 0) await widget.requestPin();
```

**Notes:** Colours are hex strings; empty ones use a dark card with a cyan value. The tap opens the app at `route` as a deep link (read it with `WebToApk.getLaunchUrl()` or the `appmint:deep-link` event). Before any `set()` the widget says "Nothing to show yet"; `clear()` returns it to that state. Android only — iOS has no widget target, so `can('widget')` is `'none'` there.

### `Capacitor.Plugins.Torch`

**Example**

The phone's flashlight, with the `@capawesome/capacitor-torch` API. Appwright's own plugin (`TorchPlugin.kt`).

**Returns:** `enable()` / `disable()` / `toggle()` → Promise (rejects `CAMERA_IN_USE` while the app's own camera stream is open, `UNAVAILABLE` without a flash); `isEnabled()` → `{enabled}`; `isAvailable()` → `{available}`. **Needs:** nothing to switch on and no permission.

```js
const Torch = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Torch;

async function setupLightButton() {
  if (!Torch || !(await Torch.isAvailable()).available) { lightBtn.hidden = true; return; }
  lightBtn.onclick = async () => {
    try {
      await Torch.toggle();
      lightBtn.classList.toggle('on', (await Torch.isEnabled()).enabled);
    } catch (e) {
      alert(e.message);
    }
  };
}
```

Flash an SOS signal from a button, and switch the light off if the camera takes the flash midway:

```js
const Torch = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Torch;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

sosBtn.onclick = async () => {
  if (!Torch || !(await Torch.isAvailable()).available) return;
  const flashes = [200, 200, 200, 600, 600, 600, 200, 200, 200];   // S O S, in ms
  try {
    for (const ms of flashes) {
      await Torch.enable();  await wait(ms);
      await Torch.disable(); await wait(200);
    }
  } catch (e) {
    await Torch.disable().catch(() => {});
    alert(e.code === 'CAMERA_IN_USE' ? 'Close the camera first.' : e.message);
  }
};
```

Keep a light button in step with the system, since the user can switch the flashlight from the quick-settings tile while the app is in the background:

```js
const Torch = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Torch;

async function syncLightButton() {
  if (!Torch) return;
  const { enabled } = await Torch.isEnabled();
  lightBtn.classList.toggle('on', enabled);
  lightBtn.setAttribute('aria-pressed', String(enabled));
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') syncLightButton();
});
syncLightButton();
```

**Notes:** the light follows the system state (turning it off from the quick-settings tile reads back as off) and is switched off when the app closes. While a `getUserMedia` stream is open, light it through the track: `track.applyConstraints({ advanced: [{ torch: true }] })`. AI-built apps use `torch` from `@/lib/appmintNative`.

### `Capacitor.Plugins.SplashScreen`

**Example**

The `@capacitor/splash-screen` API over the app's own branded launch splash, so imported Capacitor / AI Studio projects that call `SplashScreen.hide()` once their first screen is ready work in the Android app.

**Returns:** `hide()` → Promise (the splash goes once the creator's minimum splash time has passed); `show({showDuration, autoHide})` → Promise (the splash comes back over the page and, with `autoHide` — the default — goes after `showDuration` ms, default 3000). **Needs:** nothing to switch on.

```js
const Splash = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SplashScreen;

async function boot() {
  await loadFirstScreen();
  if (Splash) await Splash.hide();     // otherwise the shell hides it at first paint
}
boot();
```

Cover a slow account switch with the splash, kept up until the new data is on screen:

```js
const Splash = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SplashScreen;

async function switchAccount(accountId) {
  if (Splash) await Splash.show({ autoHide: false });   // stays until hide()
  try {
    await loadAccount(accountId);
    renderHome();
  } finally {
    if (Splash) await Splash.hide();
  }
}
```

Show the branded splash for a moment after signing out; it goes by itself after `showDuration`:

```js
const Splash = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SplashScreen;

async function signOut() {
  await clearSession();
  showSignInScreen();
  if (Splash) {
    try {
      await Splash.show({ showDuration: 1200 });
    } catch (e) {
      console.warn('splash not shown:', e.code);
    }
  }
}
```

**Notes:** without a `hide()` call the splash still goes at the first paint, like every app. AI-built apps use `splash.hold()` / `splash.ready()` from `@/lib/appmintNative`.

### `Capacitor.Plugins.AppwrightGallery`

**Example**

Saves a photo or a video into the phone's gallery (Pictures/<album> or Movies/<album>). Appwright's own plugin (`AppwrightGalleryPlugin.kt`).

**Returns:** Promise of `{ uri }` of the saved item. Rejects with a `code`: `bad-source`, `download-failed`, `permission-denied` (Android 7–9, storage refused) or `failed`. **Needs:** nothing to switch on; no permission on Android 10+ (Android 7–9 asks for storage the first time).

```js
const Gallery = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightGallery;

async function saveCard(canvas) {
  const dataUrl = canvas.toDataURL('image/png');
  if (!Gallery) { const a = document.createElement('a'); a.href = dataUrl; a.download = 'card.png'; a.click(); return; }
  try {
    await Gallery.save({ source: dataUrl, fileName: 'birthday-card.png', album: 'Cards' });
    toast('Saved to your gallery');
  } catch (e) {
    toast('Could not save: ' + e.message);
  }
}
```

Save a video from your server into its own album, with a message for each failure code:

```js
const Gallery = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightGallery;

async function saveClip(url) {
  if (!Gallery) { window.open(url, '_blank'); return; }
  try {
    await Gallery.save({ source: url, fileName: 'highlight.mp4', album: 'Highlights', kind: 'video' });
    toast('Video saved to Movies/Highlights');
  } catch (e) {
    if (e.code === 'download-failed') toast('Could not download the video. Check your connection.');
    else if (e.code === 'permission-denied') toast('Allow storage access to save videos.');
    else toast('Could not save: ' + e.message);
  }
}
```

Save a photo the user edited as a Blob (for example from `canvas.toBlob`) by turning it into a `data:` URL first:

```js
const Gallery = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AppwrightGallery;

function blobToDataUrl(blob) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(r.result);
    r.onerror = () => reject(r.error);
    r.readAsDataURL(blob);
  });
}

async function saveEditedPhoto(canvas) {
  if (!Gallery) return;
  const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.92));
  const { uri } = await Gallery.save({ source: await blobToDataUrl(blob), fileName: 'edit-' + Date.now() + '.jpg', album: 'My Edits' });
  console.log('saved as', uri);
}
```

**Notes:** `source` is a `data:` URL, an http(s) URL or a `content://`/`file://` URI; `kind` (`'image'`/`'video'`) is read from the MIME type when omitted. AI-built apps use `saveToGallery()` from `@/lib/appmintNative`.

## `@/lib/appmintNative` (AI-built apps)

Typed wrappers every AI-built project ships. They pick the native path in the app and a web path (or report `'none'`) in a browser, so one call works everywhere.

### `appmintNative.isNativeApp`

**Example**

Tells you whether the code is running inside the installed Android app (the shell bridge `window.WebToApk` is present).

**Returns:** `boolean` right away. `false` in a browser AND in the iOS app (iOS has no shell bridge). **Needs:** nothing.

**Show an "Android app" badge, and a download link in the browser.**

```ts
import { isNativeApp, platform } from '@/lib/appmintNative';

export function appBadgeText(): string {
  if (isNativeApp()) return 'Android app';
  if (platform() === 'ios') return 'iPhone app';
  return 'Get the app on Google Play';
}
```

**Tag your analytics events with where they came from.**

```ts
import { isNativeApp, platform } from '@/lib/appmintNative';

export function analyticsSource(): string {
  return isNativeApp() ? `app-${platform()}` : 'browser';   // 'app-android' or 'browser'
}
```

**Hide the "Get the app" banner inside the app.**

```ts
import { isNativeApp } from '@/lib/appmintNative';

export function InstallBanner() {
  if (isNativeApp()) return null;        // already installed: no banner
  return <a href="https://play.google.com/store/apps/details?id=com.example.app">Get the app</a>;
}
```

**Notes:** Use `platform()` when you need to tell Android, iOS and the web apart — `isNativeApp()` only answers "is the Android shell here". To decide whether a feature can be shown, use `can('<capability>')` instead: it knows which platform has which feature.

### `appmintNative.platform`

**Example**

Tells you which surface the code runs on: the Android app, the iOS app, or a web browser.

**Returns:** `'android' | 'ios' | 'web'` right away. It asks Capacitor first (`Capacitor.getPlatform()`), then checks for the Android shell bridge, else `'web'`. **Needs:** nothing.

**Pick the right store link and copy.**

```ts
import { platform } from '@/lib/appmintNative';

const p = platform();

const rateLink =
  p === 'android' ? 'https://play.google.com/store/apps/details?id=com.example.app' :
  p === 'ios'     ? 'https://apps.apple.com/app/id0000000000' :
                    '/feedback';

const backHint = p === 'ios' ? 'Swipe from the left edge to go back' : 'Press Back to go back';
```

**Show app-only controls.**

```ts
import { platform } from '@/lib/appmintNative';

export function InstallBanner() {
  // In the installed app there is nothing to install.
  if (platform() === 'android') return null;
  return <a href="https://play.google.com/store/apps/details?id=com.example.app">Get the Android app</a>;
}
```

**Tag analytics events with the surface.**

```ts
import { platform } from '@/lib/appmintNative';

export function track(event: string, data: Record<string, unknown> = {}) {
  return fetch('/api/track', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event, surface: platform(), ...data }),
  });
}
```

**Notes:** `platform()` never answers `'web'` inside either installed app. Do not use it to decide whether a feature exists — some features differ by build settings too; `can('<capability>')` answers that.

### `appmintNative.can`

**Example**

Tells you where a feature will really run on the current surface, so the app only draws controls that work.

**Returns:** a string, synchronously: `'native'` (the app's own implementation — the shell bridge or a Capacitor plugin baked into the app), `'web'` (a real web standard in this browser) or `'none'` (not available here — do not render the control). **Needs:** nothing.

Hide a control that cannot work on this surface:

```ts
import { can, share } from '@/lib/appmintNative';

export function ShareButton({ url }: { url: string }) {
  if (can('share') === 'none') return null;   // never ship a dead button
  return <button onClick={() => void share({ title: 'Have a look', url })}>Share</button>;
}
```

Change the wording by surface:

```ts
import { can } from '@/lib/appmintNative';

const selling = can('store') !== 'none';       // 'none' on the web and in the preview
const lockLabel = can('unlock') === 'native' ? 'Unlock with fingerprint' : 'Unlock with a passkey';
```

**Capability names:** `haptic` `share` `clipboardWrite` `clipboardRead` `speak` `fullscreen` `notify` `deviceInfo` · `filePick` `fileSave` `unlock` · `contactPick` `contactList` `contactAdd` · `smsCompose` `smsSend` `smsList` `callLog` · `toast` `dialog` `actionSheet` `keyboardInsets` `orientationLock` `network` `statusBar` · `swipe` `backGesture` · `folderAccess` `secureKeys` `mediaControls` `cast` `bluetooth` · `shortcuts` · `appLaunch` `browser` `camera` `photos` `location` `screenReader` `textZoom` `privacyScreen` `fileViewer` `fileTransfer` `calendarRead` `calendarWrite` `backgroundSync` `motion` · `keepAwake` `brightness` `review` `appUpdate` `badge` `speech` `hapticPatterns` `docScanner` `systemPalette` `liveProgress` `widget` `nfc` `pip` `shareTarget` `notificationActions` · `barcodeScan` `codeScanner` `passkeys` · `ads` `store`.

Show a hint instead of a dead control, and turn a feature into a plain yes/no with `has()`:

```ts
import { can, has, keepAwake } from '@/lib/appmintNative';

export function KeepScreenOn({ on }: { on: boolean }) {
  if (!has('keepAwake')) return <p>Keep-awake is not available on this device.</p>;
  return <label><input type="checkbox" checked={on} onChange={(e) => void keepAwake(e.target.checked)} /> Keep screen on</label>;
}

export const scansInApp = can('codeScanner') === 'native';
```

**Notes:**
- A Capacitor plugin baked into the app always answers `'native'`; otherwise the shell bridge is checked, then the web standard.
- On iOS `smsSend`, `smsList`, `callLog` and `backGesture` are always `'none'`.
- `toast`, `actionSheet`, `dialog`, `statusBar` and `swipe` have a real web version, so they are not `'none'` in a browser.
- `contactList`, `contactAdd`, `smsSend`, `smsList`, `callLog`, `shortcuts`, `shareTarget`, `widget`, `ads` and `store` are `'none'` on the web.
- Some features exist only when the build switched them on (folder access, secure keys, media controls, SMS, contacts). Ask `can()`, not `isNativeApp()`.
- An unknown name returns `'none'`. For a plain yes/no use `has(cap)`.

### `appmintNative.has`

**Example**

The yes/no form of `can()`: true when the feature will do something real on this surface.

**Returns:** `boolean`, synchronously — exactly `can(cap) !== 'none'`. **Needs:** nothing.

```ts
import { has, copy, toast } from '@/lib/appmintNative';

export function CopyCode({ code }: { code: string }) {
  if (!has('clipboardWrite')) return <code>{code}</code>;   // no button where it cannot copy
  return (
    <button onClick={async () => { if (await copy(code)) toast('Copied'); }}>
      Copy {code}
    </button>
  );
}
```

Gate a whole feature:

```ts
import { has, scanCode } from '@/lib/appmintNative';

export async function onScanTap(): Promise<string | null> {
  if (!has('codeScanner')) return null;     // better: do not render the Scan button at all
  const hit = await scanCode({ formats: ['qr_code'] });
  return hit ? hit.value : null;
}
```

Pick a label by where it runs: `has()` decides whether to draw the control, `can()` tells you which kind it is:

```ts
import { can, has } from '@/lib/appmintNative';

export function shareLabel(): string | null {
  if (!has('share')) return null;                      // nothing to share with: draw no button
  return can('share') === 'native' ? 'Share…' : 'Share link';
}
```

**Notes:** It takes the same capability names as `can()` (see that entry for the full list). Use `can()` when you need to know WHERE it runs (`'native'` or `'web'`), for example to change a label.

### `appmintNative.haptic`

**Example**

A short, native-feeling vibration for taps and results. Call it on every meaningful tap.

**Returns:** `void` (fire and forget). `kind`: `'light'` (default), `'medium'`, `'heavy'`, `'selection'`, `'success'`, `'warning'`, `'error'`. **Needs:** **Vibrate** in Step 4 (Access); AI builds switch it on for you when the code calls `haptic`.

```ts
import { haptic } from '@/lib/appmintNative';

export function SaveButton({ onSave }: { onSave: () => Promise<boolean> }) {
  return (
    <button
      onClick={async () => {
        haptic();                          // light tap on press
        const ok = await onSave();
        haptic(ok ? 'success' : 'error');  // result feedback
      }}
    >
      Save
    </button>
  );
}
```

For tabs, toggles and pickers use `'selection'`; for a delete confirm use `'heavy'`:

```ts
import { haptic } from '@/lib/appmintNative';

function onTabChange(tab: string) { haptic('selection'); setTab(tab); }
function onConfirmDelete() { haptic('heavy'); deleteItem(); }
```

A like button that ticks lightly on and firmer off:

```ts
import { haptic } from '@/lib/appmintNative';

export function LikeButton({ liked, onToggle }: { liked: boolean; onToggle: () => void }) {
  return (
    <button aria-pressed={liked} onClick={() => { haptic(liked ? 'light' : 'medium'); onToggle(); }}>
      {liked ? 'Liked' : 'Like'}
    </button>
  );
}
```

**Notes:** In the Android and iOS apps this uses the Capacitor Haptics plugin (`impact`, `notification`, `selectionChanged`). On the web it uses `navigator.vibrate` where the browser has it, and does nothing elsewhere; it never throws. For longer rhythms use `hapticPlay`.

### `appmintNative.click`

**Example**

Plays the app's tap sound once, for your own buttons.

**Returns:** `void`. **Needs:** **Tap sound** switched on in Step 2 (Display) when you build. On the web, and in apps built without Tap sound, it does nothing.

```ts
import { click, haptic } from '@/lib/appmintNative';

export function KeypadKey({ label, onPress }: { label: string; onPress: () => void }) {
  return (
    <button
      onClick={() => {
        click();     // sound
        haptic();    // feel
        onPress();
      }}
    >
      {label}
    </button>
  );
}
```

Let the user switch the sound off in your own settings:

```ts
import { click } from '@/lib/appmintNative';

export function useTapSound(enabled: boolean) {
  return () => { if (enabled) click(); };   // a no-op on the web and in apps built without Tap Sound
}
```

Play it from a list row together with a toast:

```ts
import { click, toast } from '@/lib/appmintNative';

export function Row({ name, onPick }: { name: string; onPick: () => void }) {
  return <li onClick={() => { click(); onPick(); toast(`${name} selected`); }}>{name}</li>;
}
```

**Notes:** With **Follow phone setting** the click is silent when the user turned off "Touch sounds" on the phone. With **Always play on every tap**, every tap already clicks, so extra `click()` calls are not needed. Android only (the shell's `WebToApk.playClick`).

### `appmintNative.musicVolume`

**Example**

Reads the level of the app's looping background music, 0 to 100.

**Returns:** `number`, synchronously: `0`–`100`, or `-1` when there is no music (on the web, or an app built without a music file). **Needs:** a file chosen under **Background Music** in Step 2 (Display).

Draw a music slider only when there is music:

```ts
import { useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function MusicSetting() {
  const [level, setLevel] = useState(() => musicVolume());
  if (level < 0) return null;                  // no music in this app: no control

  return (
    <label>
      Music
      <input
        type="range" min={0} max={100} value={level}
        onChange={(e) => {
          const v = Number(e.target.value);
          setLevel(v);
          setMusicVolume(v);
        }}
      />
    </label>
  );
}
```

Keep the level in step with a "Mute music" switch, remembering the volume to come back to.

```ts
import { useRef, useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function MuteMusicSwitch() {
  const before = useRef(Math.max(musicVolume(), 50));
  const [muted, setMuted] = useState(() => musicVolume() === 0);
  if (musicVolume() < 0) return null;            // no music in this build

  const toggle = () => {
    if (muted) setMusicVolume(before.current);
    else { before.current = musicVolume(); setMusicVolume(0); }
    setMuted(!muted);
  };
  return <button onClick={toggle}>{muted ? 'Unmute music' : 'Mute music'}</button>;
}
```

Quieten the music while a video plays, then restore it.

```ts
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function duckMusicDuring(video: HTMLVideoElement) {
  const level = musicVolume();
  if (level < 0) return;                         // nothing to duck
  video.addEventListener('play', () => setMusicVolume(0));
  video.addEventListener('pause', () => setMusicVolume(level));
  video.addEventListener('ended', () => setMusicVolume(level));
}
```

**Notes:** Android only; the iOS app and the web answer `-1`. A level set with `setMusicVolume` is kept for the next launch; until the page sets one, it is the build level.

### `appmintNative.setMusicVolume`

**Example**

Sets the level of the app's looping background music, 0 to 100 (rounded and clamped for you).

**Returns:** `void`. **Needs:** a file chosen under **Background Music** in Step 2 (Display). Does nothing when `musicVolume()` is `-1`.

The app keeps the level for the next launch, so a mute toggle is all you need:

```ts
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export const hasMusic = () => musicVolume() >= 0;
export const toggleMute = () => setMusicVolume(musicVolume() > 0 ? 0 : 40);   // remembered by the app
```

A volume slider that appears only when the app ships music:

```ts
import { useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

export function MusicSlider() {
  const [level, setLevel] = useState(musicVolume());
  if (level < 0) return null;                       // no music in this app: draw no control
  return (
    <input type="range" min={0} max={100} value={level}
      onChange={(e) => { const v = Number(e.target.value); setLevel(v); setMusicVolume(v); }} />
  );
}
```

Quieten the music while a video plays and put it back afterwards:

```ts
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';

let before = -1;
export function duckMusic() { before = musicVolume(); if (before > 0) setMusicVolume(10); }
export function restoreMusic() { if (before >= 0) setMusicVolume(before); before = -1; }
```

**Notes:** `0` makes the music silent but it keeps playing. The level is saved on the phone (until the user clears the app's data). Android only; on the web and in the iOS app nothing happens.

### `appmintNative.share`

**Example**

Opens the system share sheet in the app, and the Web Share API in a browser.

**Returns:** `Promise<boolean>` — `true` when the sheet opened; `false` when the user dismissed it or sharing is not available. It never rejects. **Needs:** nothing.

```ts
import { can, share, toast } from '@/lib/appmintNative';

export function ShareRecipe({ recipe }: { recipe: { title: string; url: string } }) {
  if (can('share') === 'none') return null;
  return (
    <button
      onClick={async () => {
        const ok = await share({ title: recipe.title, text: 'Try this recipe', url: recipe.url });
        if (ok) toast('Shared');           // false = the user closed the sheet; say nothing
      }}
    >
      Share
    </button>
  );
}
```

Text only (no link) is fine too:

```ts
import { share } from '@/lib/appmintNative';

await share({ text: 'My score today: 42' });
```

The Capacitor-shaped form `Share.share(opts)` calls the same code but REJECTS when the user cancels (Capacitor's contract) and resolves `{ activityType: '' }` otherwise; `Share.canShare()` resolves `{ value: can('share') !== 'none' }`:

```ts
import { Share } from '@/lib/appmintNative';

if ((await Share.canShare()).value) {
  try {
    await Share.share({ title: 'Invite', url: 'https://example.com/join' });
  } catch {
    /* cancelled or unavailable — never report success here */
  }
}
```

**Notes:**
- Pass only the fields you have. In the app, text and links go through the Capacitor Share plugin.
- `files` works in the Android app (the files go to the share sheet as real attachments; a `url` is added to the text) and on the web where the browser can share files (`navigator.canShare({ files })`). Keep files small — they are passed to the app in memory.
- On the web, call it from a tap; browsers refuse a share that no user action started.

### `appmintNative.copy`

**Example**

Puts text on the system clipboard.

**Returns:** `Promise<boolean>` — `true` when the text was copied, `false` when the clipboard is not available or the browser refused. **Needs:** nothing.

```ts
import { can, copy, haptic, toast } from '@/lib/appmintNative';

export function CopyLink({ link }: { link: string }) {
  if (can('clipboardWrite') === 'none') return null;
  return (
    <button
      onClick={async () => {
        if (await copy(link)) { haptic('success'); toast('Link copied'); }
        else toast('Could not copy the link');
      }}
    >
      Copy link
    </button>
  );
}
```

The Capacitor-shaped form does the same thing:

```ts
import { Clipboard } from '@/lib/appmintNative';

await Clipboard.write({ string: 'ABC-123' });
```

Report both outcomes, with a haptic only on success:

```ts
import { copy, haptic, toast } from '@/lib/appmintNative';

export async function copyCoupon(code: string) {
  const ok = await copy(code);
  if (ok) haptic('success');
  toast(ok ? `Copied ${code}` : 'Copy failed, press and hold to copy');
  return ok;
}
```

**Notes:** In the app the Capacitor Clipboard plugin writes the text (a plugin error rejects the promise instead of returning `false`). In a browser it uses `navigator.clipboard.writeText`, which needs an https page and usually a user tap.

### `appmintNative.paste`

**Example**

Reads the text on the system clipboard.

**Returns:** `Promise<string>` — the clipboard text, or `''` when the clipboard is empty. It REJECTS when this browser cannot read the clipboard at all, or when the user denies permission. **Needs:** nothing.

```ts
import { can, paste, toast } from '@/lib/appmintNative';

export function PasteCode({ onCode }: { onCode: (c: string) => void }) {
  if (can('clipboardRead') === 'none') return null;   // e.g. Firefox and Safari
  return (
    <button
      onClick={async () => {
        try {
          const text = (await paste()).trim();
          if (!text) { toast('The clipboard is empty'); return; }
          onCode(text);
        } catch {
          toast('Clipboard access was blocked');       // denied permission, not "empty"
        }
      }}
    >
      Paste code
    </button>
  );
}
```

The Capacitor-shaped form:

```ts
import { Clipboard } from '@/lib/appmintNative';

const { value } = await Clipboard.read();
```

Paste into a text box with a message for each outcome, and pair it with `copy`:

```ts
import { copy, paste, toast } from '@/lib/appmintNative';

export async function pasteInto(setValue: (s: string) => void) {
  try {
    const text = await paste();
    if (text) setValue(text);
    else toast('Nothing to paste');
  } catch {
    toast('Allow clipboard access to paste');
  }
}

export async function shoutClipboard() {
  const text = await paste().catch(() => '');
  if (text && await copy(text.toUpperCase())) toast('Converted and copied');
}
```

**Notes:** In the app the Clipboard plugin reads the text; an empty clipboard gives `''`, not an error. In a browser it is `navigator.clipboard.readText()` (Chromium only), which asks the user for permission — call it from a tap.

### `appmintNative.speak`

**Example**

Reads text aloud with the phone's text-to-speech engine.

**Returns:** `void` — it starts speaking (or queues after the current speech) and gives no "finished" signal. `opts`: `{ lang?: string; rate?: number; pitch?: number }` (defaults: engine language, 1, 1). **Needs:** nothing.

```ts
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function ReadAloud({ text }: { text: string }) {
  return (
    <div>
      <button onClick={() => speak(text, { lang: 'en-IN', rate: 0.9 })}>Read aloud</button>
      <button onClick={() => stopSpeaking()}>Stop</button>
    </div>
  );
}
```

Different languages and speeds:

```ts
import { speak } from '@/lib/appmintNative';

speak('வணக்கம்', { lang: 'ta-IN' });          // Tamil
speak('Slow and clear', { rate: 0.7 });       // rate 0.1..4, 1 = normal
speak('High voice', { pitch: 1.5 });          // pitch 0.1..4, 1 = normal
```

Speak a short confirmation after an action, and tell the user in text too.

```ts
import { speak, toast } from '@/lib/appmintNative';

export function announceSaved(title: string) {
  toast('Saved');
  speak(`${title} saved`, { rate: 1.1 });
}
```

Read a list one item after another; `speak()` queues, so call it in a loop and stop everything with `stopSpeaking()` when the screen closes.

```tsx
import { useEffect } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function ReadHeadlines({ items }: { items: string[] }) {
  useEffect(() => () => stopSpeaking(), []);   // leaving the screen stops the voice
  return <button onClick={() => items.forEach((t) => speak(t))}>Read all</button>;
}
```

**Notes:** Android: the app's native engine. iOS: the TextToSpeech plugin. Web: `speechSynthesis`. Several `speak()` calls play one after another. If you need to know when speech ends (to highlight words or move on), use `window.speechSynthesis` with an utterance's `onend` directly; it works in the Android app too.

### `appmintNative.stopSpeaking`

**Example**

Stops text-to-speech now and drops anything still waiting to be spoken.

**Returns:** `void`. **Needs:** nothing.

```ts
import { useEffect } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function StoryPage({ story }: { story: string }) {
  // Stop reading when the user leaves the page.
  useEffect(() => () => stopSpeaking(), []);

  return (
    <article>
      <p>{story}</p>
      <button onClick={() => { stopSpeaking(); speak(story); }}>Read from the start</button>
      <button onClick={stopSpeaking}>Stop</button>
    </article>
  );
}
```

Read a long text aloud with the language and speed the user picked, and stop it from one button:

```ts
import { useState } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';

export function ReadAloud({ text, lang }: { text: string; lang: string }) {
  const [rate, setRate] = useState(1);
  return (
    <div>
      <input type="range" min={0.5} max={2} step={0.1} value={rate} onChange={(e) => setRate(Number(e.target.value))} />
      <button onClick={() => { stopSpeaking(); speak(text, { lang, rate }); }}>Read aloud</button>
      <button onClick={() => stopSpeaking()}>Stop</button>
    </div>
  );
}
```

Cut the voice off when the user starts typing a reply:

```ts
import { stopSpeaking } from '@/lib/appmintNative';

export function ReplyBox({ onSend }: { onSend: (text: string) => void }) {
  return <textarea onFocus={() => stopSpeaking()} onBlur={(e) => onSend(e.currentTarget.value)} />;
}
```

**Notes:** Safe to call when nothing is speaking. Android: the native engine's stop. iOS: the TextToSpeech plugin's `stop`. Web: `speechSynthesis.cancel()`.

### `appmintNative.fullscreen`

**Example**

Hides the system bars (in the app) or enters browser fullscreen (on the web), for a video, a game or a reading view.

**Returns:** `enter()` and `exit()` return `Promise<void>` and never reject; `isOn()` returns `boolean` synchronously. **Needs:** nothing.

```ts
import { useState } from 'react';
import { can, fullscreen } from '@/lib/appmintNative';

export function FullscreenToggle() {
  const [on, setOn] = useState(fullscreen.isOn());
  if (can('fullscreen') === 'none') return null;
  return (
    <button
      onClick={async () => {
        if (fullscreen.isOn()) await fullscreen.exit(); else await fullscreen.enter();
        setOn(fullscreen.isOn());
      }}
    >
      {on ? 'Exit full screen' : 'Full screen'}
    </button>
  );
}
```

Leave fullscreen when the view closes:

```ts
import { useEffect } from 'react';
import { fullscreen } from '@/lib/appmintNative';

export function useFullscreenWhileOpen() {
  useEffect(() => {
    void fullscreen.enter();
    return () => { void fullscreen.exit(); };
  }, []);
}
```

Hide the system bars for a reading view and follow the state from a button.

```ts
import { useEffect, useState } from 'react';
import { fullscreen, can } from '@/lib/appmintNative';

export function useReaderMode() {
  const [on, setOn] = useState(false);
  useEffect(() => () => { void fullscreen.exit(); }, []);        // always leave on unmount
  const toggle = async () => {
    if (can('fullscreen') === 'none') return;
    if (fullscreen.isOn()) await fullscreen.exit(); else await fullscreen.enter();
    setOn(fullscreen.isOn());                                   // a refused web request leaves it off
  };
  return { on, toggle };
}
```

**Notes:**
- In the app this is the shell's own fullscreen (status and navigation bars hide); it is always available to the page.
- On the web the Fullscreen API works only from a user tap. A refused request is ignored quietly, so read `isOn()` afterwards instead of assuming.

### `appmintNative.navStart`

**Example**

`navStart()` shows the app's thin top loading bar for a screen change the app cannot see by itself; `navEnd()` ends it.

**Returns:** nothing (both are synchronous). **Needs:** nothing. On the web (and on iOS, where there is no Android shell bar) both do nothing.

Use them only for a wait where nothing in the DOM changes until data arrives, and always pair them with `try/finally`:

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function openOrder(id: string, render: (order: unknown) => void) {
  navStart();
  try {
    const res = await fetch(`/api/orders/${id}`);
    render(await res.json());
  } finally {
    navEnd();                // always, also when the fetch failed
  }
}
```

Wrap a submit that takes a while, so the user sees the bar while the server works.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function submitForm(data: FormData): Promise<boolean> {
  navStart();
  try {
    const res = await fetch('/api/submit', { method: 'POST', body: data });
    return res.ok;
  } catch {
    return false;            // the bar still ends in finally
  } finally {
    navEnd();
  }
}
```

Make a small helper that wraps any promise.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function withNavBar<T>(work: Promise<T>): Promise<T> {
  navStart();
  try { return await work; } finally { navEnd(); }
}

// usage inside a click handler:
// const items = await withNavBar(loadItems(categoryId));
```

**Notes:** They call `WebToApk.navStarted()` / `navSettled()`. The app already drives the bar when your app swaps screens in the DOM, so most route changes need nothing. Nothing is drawn for a change shorter than about 200 ms, and a bar that is never ended stops by itself after 2.5 seconds. Keep rendering your own spinner or skeleton; the bar is extra.

### `appmintNative.navEnd`

**Example**

Documented together with `appmintNative.navStart` — the example there shows this one too.

`navStart()` shows the app's thin top loading bar for a screen change the app cannot see by itself; `navEnd()` ends it.

**Returns:** nothing (both are synchronous). **Needs:** nothing. On the web (and on iOS, where there is no Android shell bar) both do nothing.

Use them only for a wait where nothing in the DOM changes until data arrives, and always pair them with `try/finally`:

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function openOrder(id: string, render: (order: unknown) => void) {
  navStart();
  try {
    const res = await fetch(`/api/orders/${id}`);
    render(await res.json());
  } finally {
    navEnd();                // always, also when the fetch failed
  }
}
```

Wrap a submit that takes a while, so the user sees the bar while the server works.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function submitForm(data: FormData): Promise<boolean> {
  navStart();
  try {
    const res = await fetch('/api/submit', { method: 'POST', body: data });
    return res.ok;
  } catch {
    return false;            // the bar still ends in finally
  } finally {
    navEnd();
  }
}
```

Make a small helper that wraps any promise.

```ts
import { navStart, navEnd } from '@/lib/appmintNative';

export async function withNavBar<T>(work: Promise<T>): Promise<T> {
  navStart();
  try { return await work; } finally { navEnd(); }
}

// usage inside a click handler:
// const items = await withNavBar(loadItems(categoryId));
```

**Notes:** They call `WebToApk.navStarted()` / `navSettled()`. The app already drives the bar when your app swaps screens in the DOM, so most route changes need nothing. Nothing is drawn for a change shorter than about 200 ms, and a bar that is never ended stops by itself after 2.5 seconds. Keep rendering your own spinner or skeleton; the bar is extra.

### `appmintNative.deviceInfo`

**Example**

Tells you synchronously where the app runs (`platform`) and the language, plus the Android shell's full device report inside the Android app.

**Returns:** `DeviceInfo` right away: always `{ platform: 'android' | 'ios' | 'web', language }`. In the Android app it also fills `model`, `manufacturer`, `osVersion` (e.g. `"14"`) and `appVersion` (e.g. `"1.4.0"`), and carries every key of `WebToApk.getDeviceInfo()`: `android`, `hardware`, `screen`, `app`, `runtime`, `installId`. On iOS and the web it adds `osVersion` (the user agent). **Needs:** nothing.

**Branch on the platform during render.**

```ts
import { deviceInfo } from '@/lib/appmintNative';

const info = deviceInfo();
const isAndroidApp = info.platform === 'android';
const lang = info.language;                         // e.g. "en-IN"
```

**Read Android details.** The common fields are at the top; the rest of the shell's report stays in its nested groups.

```ts
import { deviceInfo } from '@/lib/appmintNative';

const info: any = deviceInfo();
if (info.platform === 'android') {
  console.log(`${info.manufacturer} ${info.model}`);        // "OPPO CPH2269"
  console.log(`Android ${info.osVersion}, app ${info.appVersion}`);
  console.log(`Battery ${info.runtime?.batteryLevel}%`);
}
```

**Pick the right control from the platform.**

```tsx
import { deviceInfo } from '@/lib/appmintNative';

export function ShareHint() {
  const { platform, language } = deviceInfo();
  return platform === 'android'
    ? <p>Tap Share to send this from your phone.</p>
    : <p>Copy the link to share it ({language}).</p>;
}
```

**Notes:** `model` and `appVersion` are filled in the Android app only; on iOS call `Device.getInfo()` (async), which returns `model` and `osVersion` there. On the web `osVersion` is the browser's user-agent string, not an OS version. It throws if the shell's answer is not valid JSON (a real defect, never expected).

### `appmintNative.installId`

**Example**

Gives a stable random ID for this install: the shell's own ID in the Android app, a random ID saved in `localStorage` on the web.

**Returns:** `string` right away. **Needs:** nothing.

**Key a free trial to the device.**

```ts
import { installId } from '@/lib/appmintNative';

export async function claimTrial(): Promise<boolean> {
  let id: string;
  try {
    id = installId();
  } catch {
    return false;          // web with storage blocked (private mode): no stable id exists
  }
  const r = await fetch('https://api.example.com/trial', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ device: id }),
  });
  return r.ok;
}
```

**Count this install once in your own analytics.**

```ts
import { installId } from '@/lib/appmintNative';

export function reportFirstOpen() {
  let id: string;
  try { id = installId(); } catch { return; }          // web with storage blocked: no stable id
  if (localStorage.getItem('first-open-sent')) return;
  navigator.sendBeacon('https://api.example.com/first-open', JSON.stringify({ install: id }));
  localStorage.setItem('first-open-sent', '1');
}
```

**Show a short support code that you can look up later, with a copy button.**

```ts
import { installId, copy, toast } from '@/lib/appmintNative';

export function SupportCode() {
  let code = '';
  try { code = installId().slice(0, 8); } catch { /* storage blocked: show nothing */ }
  if (!code) return null;
  return <button onClick={async () => { if (await copy(code)) toast('Copied'); }}>Support code {code}</button>;
}
```

**Notes:** It stays the same across app updates and changes on uninstall or "clear data". It is not a hardware ID. It throws on the web when `localStorage` is blocked, on purpose — a shared blank ID would mix users up. On iOS the WebView's `localStorage` can be cleared by the system; `Device.getId()` gives the more stable Capacitor ID there.

### `appmintNative.notify`

**Example**

Shows one notification right now, on Android, iOS and the web, from an AI-built app.

**Returns:** `Promise<boolean>` — `true` when posted, `false` when the user refused the notification permission, notifications are switched off for the app, or they are not available here. It asks for the permission itself the first time (Android 13+, iOS, the web). **Needs:** Android: **Notifications** turned on in the Permissions step when you build.

```ts
import { notify, can } from '@/lib/appmintNative';

export async function onOrderReady(orderId: string) {
  if (can('notify') === 'none') return;          // hide the feature instead
  const ok = await notify({
    title: 'Order ready',
    body: `Order #${orderId} is ready to collect`,
    icon: 'https://example.com/img/order.png',    // shown beside the text
    tag: `order-${orderId}`,                     // same tag replaces the old one
  });
  if (!ok) setBanner('Turn on notifications to get order alerts.');
}
```

**Ask at a good moment:** the first `notify()` shows the permission dialog. To ask from your own "Turn on alerts" button instead — before anything is posted — use the standard API, which the app also supports:

```ts
async function enableAlerts() {
  if (typeof Notification === 'undefined') return false;
  const perm = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission();
  return perm === 'granted';
}
```

Open the right screen when the user taps the notification, combining `notify` with `onNotificationClick`.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { notify, onNotificationClick } from '@/lib/appmintNative';

export async function announceMessage(id: string, from: string) {
  await notify({ title: `New message from ${from}`, tag: `msg-${id}`, data: { messageId: id } });
}

export function NotificationRouter() {
  const navigate = useNavigate();
  useEffect(() => onNotificationClick((e) => {
    const { messageId } = (e.data ?? {}) as { messageId?: string };
    if (messageId) navigate(`/messages/${messageId}`);
  }), [navigate]);
  return null;
}
```

**Notes:** Android uses the shell's notification bridge (`icon` becomes the large icon; use an `https://` or `data:` image); iOS uses the LocalNotifications plugin; the web uses the Notification API. Once the user has refused for good, `notify()` resolves `false` without asking again — point them to the phone's settings. For buttons use `notifyWithActions`; for a sticky progress card use `liveProgress`.

### `appmintNative.getNotificationState`

**Example**

Whether notifications are on, and if not whether the app can still ask or only Settings can fix it.

**Returns:** `Promise<'granted' | 'prompt' | 'blocked' | 'unavailable'>`. On the web, `Notification.permission` mapped the same way (`default` → `'prompt'`, `denied` → `'blocked'`); `'unavailable'` where there are no notifications at all. **Needs:** nothing.

```ts
import { getNotificationState } from '@/lib/appmintNative';

export async function shouldShowNotifBanner(): Promise<boolean> {
  const state = await getNotificationState();
  return state === 'prompt' || state === 'blocked';
}
```

**Notes:** check again when the app comes back to the foreground (`visibilitychange`) — the user may have changed it in Settings.

### `appmintNative.enableNotifications`

**Example**

Brings a user who denied or skipped notifications back, from a button they tapped: the permission dialog while it can still be shown, otherwise (Android app) this app's notification settings.

**Returns:** `Promise<{ state, changed, openedSettings }>` — `state` is `'granted' | 'prompt' | 'blocked' | 'unavailable'`, `changed` is true when it differs from before the call, `openedSettings` is true when the settings screen was used. Never rejects. On the web it asks through `Notification.requestPermission()`. **Needs:** notifications in the build (they are on whenever the app sends or receives notifications).

```ts
import { enableNotifications, getNotificationState } from '@/lib/appmintNative';

export async function TurnOnNotificationsBanner(el: HTMLElement) {
  const state = await getNotificationState();
  el.hidden = !(state === 'prompt' || state === 'blocked');
  el.querySelector('button')!.addEventListener('click', async () => {
    const r = await enableNotifications();
    el.hidden = r.state === 'granted';
  });
}
```

**Notes:** show the banner only for `'prompt'` and `'blocked'`; hide it for `'granted'` and `'unavailable'`. Ask from a tap, never on launch.

### `appmintNative.openNotificationSettings`

**Example**

Opens this app's own notification settings screen (Android app).

**Returns:** `Promise<boolean>` — true when the screen opened; false on the web and wherever there is no such screen. Check `can('notificationSettings')` before drawing the control. **Needs:** nothing.

```ts
import { can, openNotificationSettings } from '@/lib/appmintNative';

export function NotificationSettingsRow(row: HTMLElement) {
  row.hidden = can('notificationSettings') === 'none';
  row.addEventListener('click', () => { void openNotificationSettings(); });
}
```

**Notes:** for a one-tap "Turn on notifications" use `enableNotifications()`, which opens this screen only when the permission dialog can no longer be shown.

### `appmintNative.pushInbox`

**Example**

Returns the Appwright Push messages this install received (newest first, up to 50), for an in-app notifications screen.

**Returns:** `PushMessage[]` synchronously: `{ id, title, body, imageUrl, link, receivedAt, read }`. An empty array on the web, on iOS, and when Appwright Push is off. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { useEffect, useState } from 'react';
import { pushInbox, markPushRead, type PushMessage } from '@/lib/appmintNative';

export function useInbox() {
  const [items, setItems] = useState<PushMessage[]>(() => pushInbox());
  useEffect(() => {
    const refresh = () => setItems(pushInbox());
    // detail: { type: 'received', message } or { type: 'revoked', id }
    window.addEventListener('appmint:push', refresh);
    return () => window.removeEventListener('appmint:push', refresh);
  }, []);
  const unread = items.filter((m) => !m.read).length;
  const open = (m: PushMessage) => {
    markPushRead(m.id);
    setItems(pushInbox());
  };
  return { items, unread, open };
}
```

Show an unread badge count that follows new arrivals:

```ts
import { useEffect, useState } from 'react';
import { pushInbox } from '@/lib/appmintNative';

export function UnreadBadge() {
  const [n, setN] = useState(() => pushInbox().filter((m) => !m.read).length);
  useEffect(() => {
    const recount = () => setN(pushInbox().filter((m) => !m.read).length);
    window.addEventListener('appmint:push', recount);
    return () => window.removeEventListener('appmint:push', recount);
  }, []);
  return n > 0 ? <span aria-label={`${n} unread`}>{n}</span> : null;
}
```

Render the list, with an empty state for the web and for builds without Appwright Push, and a "Mark all read" button:

```ts
import { useState } from 'react';
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function NotificationsScreen() {
  const [items, setItems] = useState(() => pushInbox());
  if (items.length === 0) return <p>No notifications yet.</p>;
  return (
    <section>
      <button onClick={() => { markPushRead(); setItems(pushInbox()); }}>Mark all read</button>
      <ul>
        {items.map((m) => (
          <li key={m.id} style={{ fontWeight: m.read ? 400 : 700 }}>
            {m.title} <small>{new Date(m.receivedAt).toLocaleString()}</small>
            <p>{m.body}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

**Notes:** the inbox is the saved truth — read it on mount; the `appmint:push` event only fires while the app is on screen. Dismissed messages are left out.

### `appmintNative.markPushRead`

**Example**

Marks one Appwright Push inbox message as read, or every message when called with no argument.

**Returns:** `boolean` synchronously — `true` if something changed. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function openMessage(id: string) {
  markPushRead(id);
  return pushInbox().find((m) => m.id === id);
}

export function markAllRead() {
  markPushRead();          // no id = all messages
  return pushInbox();
}
```

Count the unread messages after marking one, for a badge in your own header.

```ts
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function openAndCount(id: string) {
  const changed = markPushRead(id);          // false on the web or when nothing changed
  const unread = pushInbox().filter((m) => !m.read).length;
  return { changed, unread };
}
```

Mark everything read when the user opens the inbox screen and refresh when a new message arrives.

```ts
import { useEffect, useState } from 'react';
import { pushInbox, markPushRead } from '@/lib/appmintNative';

export function useInbox() {
  const [items, setItems] = useState(() => pushInbox());
  useEffect(() => {
    markPushRead();
    const refresh = () => setItems(pushInbox());
    window.addEventListener('appmint:push', refresh);
    return () => window.removeEventListener('appmint:push', refresh);
  }, []);
  return items;
}
```

**Notes:** read state lives only on this phone; the tray notification is not removed.

### `appmintNative.dismissPushMessage`

**Example**

Hides one message from the Appwright Push inbox (swipe-away). Undo it with `restorePushMessage(id)`.

**Returns:** `boolean` synchronously — `true` if it was hidden. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { dismissPushMessage, restorePushMessage, pushInbox } from '@/lib/appmintNative';

export function swipeAway(id: string, setItems: (v: ReturnType<typeof pushInbox>) => void,
                          showUndo: (onUndo: () => void) => void) {
  if (!dismissPushMessage(id)) return;
  setItems(pushInbox());
  showUndo(() => {                         // e.g. a 5-second "Undo" snackbar
    restorePushMessage(id);
    setItems(pushInbox());
  });
}
```

Show the inbox with a swipe handler and an undo state, so a swipe is never final.

```tsx
import { useState } from 'react';
import { pushInbox, dismissPushMessage, restorePushMessage } from '@/lib/appmintNative';

export function Inbox() {
  const [items, setItems] = useState(pushInbox());
  const [undoId, setUndoId] = useState<string | null>(null);

  const swipe = (id: string) => {
    if (!dismissPushMessage(id)) return;     // false on the web or when Appwright Push is off
    setItems(pushInbox());
    setUndoId(id);
  };
  const undo = () => {
    if (undoId && restorePushMessage(undoId)) setItems(pushInbox());
    setUndoId(null);
  };

  return (
    <ul>
      {items.map((m) => <li key={m.id} onClick={() => swipe(m.id)}>{m.title}</li>)}
      {undoId && <button onClick={undo}>Undo</button>}
    </ul>
  );
}
```

Hide every read message at once, and report how many were hidden.

```ts
import { pushInbox, dismissPushMessage, toast } from '@/lib/appmintNative';

export function tidyReadMessages(): number {
  const hidden = pushInbox().filter((m) => m.read && dismissPushMessage(m.id)).length;
  toast(hidden ? `${hidden} message(s) hidden` : 'Nothing to hide');
  return hidden;
}
```

**Notes:** only this phone's inbox changes; the tray notification and your send history stay.

### `appmintNative.restorePushMessage`

**Example**

Undoes `dismissPushMessage(id)` — the message returns to the Appwright Push inbox in its old place.

**Returns:** `boolean` synchronously — `true` if it was restored; `false` if the id is unknown or was not dismissed (always `false` on the web and iOS). **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { dismissPushMessage, restorePushMessage, pushInbox } from '@/lib/appmintNative';

let lastDismissed: string | null = null;

export function dismiss(id: string) {
  if (dismissPushMessage(id)) lastDismissed = id;
  return pushInbox();
}

export function undo() {
  if (lastDismissed) restorePushMessage(lastDismissed);
  lastDismissed = null;
  return pushInbox();
}
```

Offer an Undo right after a swipe-away, in a small React row:

```ts
import { useState } from 'react';
import { dismissPushMessage, restorePushMessage } from '@/lib/appmintNative';

export function SwipeRow({ id, onChange }: { id: string; onChange: () => void }) {
  const [undoable, setUndoable] = useState(false);
  return undoable ? (
    <button onClick={() => { if (restorePushMessage(id)) { setUndoable(false); onChange(); } }}>
      Undo
    </button>
  ) : (
    <button onClick={() => { if (dismissPushMessage(id)) { setUndoable(true); onChange(); } }}>
      Dismiss
    </button>
  );
}
```

Check the answer, because an unknown or never-dismissed id gives `false`:

```ts
import { restorePushMessage, pushInbox } from '@/lib/appmintNative';

export function undoOrReport(id: string): string {
  if (!restorePushMessage(id)) return 'Nothing to restore';
  return `Back in your inbox (${pushInbox().length} messages)`;
}
```

**Notes:** messages removed with `clearPushInbox()` or retracted by you cannot be restored.

### `appmintNative.clearPushInbox`

**Example**

Empties the Appwright Push inbox on this phone. Permanent — there is no undo.

**Returns:** `boolean` synchronously — `true` when cleared; always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { clearPushInbox, pushInbox } from '@/lib/appmintNative';

export async function onClearAll(confirmDelete: () => Promise<boolean>) {
  if (!(await confirmDelete())) return pushInbox();
  clearPushInbox();
  return pushInbox();   // []
}
```

Show Clear only when there is something to clear, and report the result:

```ts
import { clearPushInbox, pushInbox, toast } from '@/lib/appmintNative';

export function InboxToolbar({ onChange }: { onChange: () => void }) {
  const count = pushInbox().length;
  if (count === 0) return null;
  return (
    <button
      onClick={() => {
        // false on the web, or in an app built without Appwright Push
        if (clearPushInbox()) { toast('Inbox cleared'); onChange(); }
        else toast('Could not clear the inbox here');
      }}
    >
      Clear all ({count})
    </button>
  );
}
```

Mark everything read instead of deleting it, which loses nothing:

```ts
import { markPushRead, pushInbox } from '@/lib/appmintNative';

export function markAllRead(): number {
  const unread = pushInbox().filter((m) => !m.read).length;
  markPushRead();   // no argument = every message
  return unread;
}
```

**Notes:** tray notifications and your send history in Appwright are not touched; new messages still arrive.

### `appmintNative.setPushPresentation`

**Example**

Chooses who draws an Appwright Push message you sent with an in-app style (banner, pop-up, bottom sheet or full screen) while the app is open: the app shell (`'auto'`, the default) or your own code (`'none'`).

**Returns:** `boolean` synchronously — `true` when the mode was saved. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { useEffect } from 'react';
import { setPushPresentation, pushInbox } from '@/lib/appmintNative';

// Option A — keep the shell's banner, and never draw a second one on top of it.
export function usePushBanner(show: (title: string) => void) {
  useEffect(() => {
    const onPush = (e: Event) => {
      const d = (e as CustomEvent).detail;
      // { type: 'received', message, presented } — presented = the shell already showed it
      if (d.type === 'received' && !d.presented) show(d.message.title);
    };
    window.addEventListener('appmint:push', onPush);
    return () => window.removeEventListener('appmint:push', onPush);
  }, [show]);
}

// Option B — the app draws every message itself.
export function takeOverPushBanners() {
  setPushPresentation('none');   // saved on the phone; 'auto' gives it back to the shell
  return pushInbox();
}
```

Check the answer, since it is `false` on the web or when Appwright Push is off:

```ts
import { setPushPresentation } from '@/lib/appmintNative';

export function useOwnBanners(on: boolean): boolean {
  const saved = setPushPresentation(on ? 'none' : 'auto');
  if (!saved) console.info('Push banners are not controllable here');
  return saved;
}
```

Draw your own banner for each message and keep the inbox in step:

```ts
import { useEffect, useState } from 'react';
import { setPushPresentation, markPushRead, pushInbox, type PushMessage } from '@/lib/appmintNative';

export function useCustomBanner() {
  const [msg, setMsg] = useState<PushMessage | null>(null);
  useEffect(() => {
    setPushPresentation('none');                  // the shell draws nothing; the page does
    const onPush = (e: Event) => {
      const d = (e as CustomEvent).detail;
      if (d.type === 'received') setMsg(d.message);
      if (d.type === 'revoked') setMsg((m) => (m?.id === d.id ? null : m));
    };
    window.addEventListener('appmint:push', onPush);
    return () => { window.removeEventListener('appmint:push', onPush); setPushPresentation('auto'); };
  }, []);
  return { msg, unread: pushInbox().filter((m) => !m.read).length, dismiss: () => { if (msg) markPushRead(msg.id); setMsg(null); } };
}
```

**Notes:** the inbox and the `appmint:push` event work the same in both modes. Setting `'none'` also closes a message the shell is showing right now.

### `appmintNative.setPushUser`

**Example**

Tells Appwright Push which of your users is signed in on this install, so the send center can send a message to that one user. Pass your own stable account id after sign-in and `''` on sign-out.

**Returns:** `boolean` synchronously — `true` when the id was saved; `false` for an id with anything other than letters, digits and `_ @ . : + -` (up to 128). Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build; sending to a user reaches apps built from 30 Sep 2026 on.

```ts
import { useEffect } from 'react';
import { setPushUser, getPushUser } from '@/lib/appmintNative';
import { supabase } from '@/lib/supabase';

// Keeps Appwright Push in step with the signed-in account: the auth user id, never the email.
export function usePushUser() {
  useEffect(() => {
    const { data } = supabase.auth.onAuthStateChange((_event, session) => {
      const id = session?.user.id ?? '';
      if (getPushUser() !== id) setPushUser(id);
    });
    return () => data.subscription.unsubscribe();
  }, []);
}
```

Handle a refused id: `setPushUser` answers `false` for an email address or any id with characters outside the allowed set, so send a stable id instead.

```ts
import { setPushUser } from '@/lib/appmintNative';

export function linkPushToAccount(account: { id: string; email: string }) {
  const ok = setPushUser(account.id);   // the id, never the email
  if (!ok) console.warn('Push user id not saved (web, Push off, or invalid characters)');
  return ok;
}
```

Clear the id when the user signs out, in a plain click handler.

```tsx
import { setPushUser, getPushUser } from '@/lib/appmintNative';

export function SignOutButton({ signOut }: { signOut: () => Promise<void> }) {
  return (
    <button
      onClick={async () => {
        await signOut();
        if (getPushUser()) setPushUser('');   // this install stops receiving "send to this user" messages
      }}
    >
      Sign out
    </button>
  );
}
```

Combine it with a tag so one sign-in step identifies the user and their plan together.

```ts
import { setPushUser, setPushTag } from '@/lib/appmintNative';

export function onSignedIn(userId: string, plan: 'free' | 'pro') {
  setPushUser(userId);
  setPushTag('plan', plan);
}
```

**Notes:** the id belongs to the install, so sign-out must clear it. It does not clear tags — call `setPushTags` with `''` values for user-specific ones. Android only: on iOS and the web it does nothing.

### `appmintNative.getPushUser`

**Example**

Reads the user id this install last gave Appwright Push with `setPushUser`.

**Returns:** `string` synchronously — the saved id, or `''` when signed out, never set, on the web or on iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { getPushUser, setPushUser } from '@/lib/appmintNative';

// On app start: the session expired while the app was closed, so stop personal messages here.
export function syncPushUser(currentUserId: string | null) {
  const want = currentUserId ?? '';
  if (getPushUser() !== want) setPushUser(want);
}
```

Call `setPushUser` with the account id after sign-in and with `''` on sign-out; it returns `false` when the id is refused or Appwright Push is off:

```ts
import { setPushUser } from '@/lib/appmintNative';

export function onSignedIn(userId: string) {
  if (!setPushUser(userId)) console.warn('push user id not saved');   // ids only: no email or phone number
}

export function onSignedOut() {
  setPushUser('');
}
```

Read the saved id in a React hook, together with a tag that belongs to that user:

```ts
import { useEffect, useState } from 'react';
import { getPushUser, setPushTag } from '@/lib/appmintNative';

export function usePushUser(plan: string) {
  const [id] = useState(getPushUser);
  useEffect(() => {
    if (id) setPushTag('plan', plan);     // only tag installs that belong to a signed-in user
  }, [id, plan]);
  return id;
}
```

**Notes:** this is what the phone holds; the send center sees a change after the install's next check-in, a few seconds later when online. Android only: on iOS and the web it does nothing.

### `appmintNative.setPushTag`

**Example**

Sets one Appwright Push tag on this install. The send center can send to everyone with a tag, and a tag automation can send a message a set time after the tag is set. `''` removes the tag.

**Returns:** `boolean` synchronously — `true` when saved; `false` when the key is not 1–32 letters, digits or `_`, the value is over 128 characters or the install would pass 50 tags. Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build; tag targeting and automations reach apps built from 30 Sep 2026 on.

```ts
import { useEffect } from 'react';
import { setPushTag } from '@/lib/appmintNative';

// Abandoned cart: a tag automation on "cart" (say 60 minutes) reminds only phones whose cart is
// still open when the time is up. Setting 'open' again does not restart the clock.
export function useCartPushTag(itemCount: number) {
  const hasItems = itemCount > 0;
  useEffect(() => {
    setPushTag('cart', hasItems ? 'open' : '');
  }, [hasItems]);
}

export function onCheckoutComplete() {
  setPushTag('cart', '');
}
```

Check the result: `false` means the tag was refused (bad key, value over 128 characters, more than 50 tags) or this is the web, so keep your own record of the state.

```ts
import { setPushTag } from '@/lib/appmintNative';

export function markNewsletterChoice(subscribed: boolean) {
  const saved = setPushTag('newsletter', subscribed ? 'yes' : '');
  if (!saved) console.warn('Push tag not saved (web, Push off, or invalid key/value)');
  return saved;
}
```

Tag a phone once it reaches a milestone, so the send center can target everyone at that level.

```ts
import { useEffect } from 'react';
import { setPushTag } from '@/lib/appmintNative';

export function useLevelTag(level: number) {
  useEffect(() => {
    if (level >= 10) setPushTag('veteran', 'yes');
    setPushTag('level', String(level));
  }, [level]);
}
```

**Notes:** tags belong to the install, not the signed-in user. Never put private data in a tag. Android only: on iOS and the web it does nothing.

### `appmintNative.setPushTags`

**Example**

Sets several Appwright Push tags in one change; a `''` value removes that tag, tags you do not name are kept.

**Returns:** `boolean` synchronously — `true` when saved; `false` when any key or value breaks the `setPushTag` rules (then nothing is saved). Always `false` on the web and iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build; tag targeting and automations reach apps built from 30 Sep 2026 on.

```ts
import { setPushTags, getPushTags, setPushUser } from '@/lib/appmintNative';

export function tagProfile(profile: { plan: 'free' | 'pro'; level: number; team?: string }) {
  return setPushTags({
    plan: profile.plan,
    level: String(profile.level),   // values are text
    team: profile.team ?? '',       // '' removes the tag
  });
}

// Sign-out: clear every tag and the user id.
export function signOutOfPush() {
  const cleared = Object.fromEntries(Object.keys(getPushTags()).map((k) => [k, '']));
  setPushTags(cleared);
  setPushUser('');
}
```

Tag a phone with its chosen language and topics when the user saves their settings, and check that every tag was accepted.

```ts
import { setPushTags } from '@/lib/appmintNative';

export function savePreferences(prefs: { lang: string; topics: string[] }) {
  const ok = setPushTags({
    lang: prefs.lang,
    topics: prefs.topics.join(',').slice(0, 128),   // a value is at most 128 characters
    onboarded: 'yes',
  });
  if (!ok) console.warn('Push tags not saved: nothing was changed');
  return ok;
}
```

Keep tags in step with the signed-in account in a React effect, removing the ones that no longer apply.

```ts
import { useEffect } from 'react';
import { setPushTags, setPushUser } from '@/lib/appmintNative';

export function usePushProfile(user: { id: string; plan: 'free' | 'pro'; trialEnds?: string } | null) {
  useEffect(() => {
    if (!user) return;
    setPushUser(user.id);
    setPushTags({ plan: user.plan, trial: user.plan === 'pro' ? '' : (user.trialEnds ?? '') });
  }, [user?.id, user?.plan, user?.trialEnds]);
}
```

**Notes:** all of one call is saved or none of it. Android only: on iOS and the web it does nothing.

### `appmintNative.getPushTags`

**Example**

Reads this install's Appwright Push tags as an object.

**Returns:** `Record<string, string>` synchronously — e.g. `{ plan: 'pro', cart: 'open' }`; `{}` when there are none, on the web and on iOS. **Needs:** Android: **Appwright Push** turned on in the Integrate step when you build.

```ts
import { getPushTags, setPushTag } from '@/lib/appmintNative';

// Every setPushTag makes the install check in, so skip the calls that change nothing.
export function ensurePushTag(key: string, value: string) {
  if ((getPushTags()[key] ?? '') !== value) setPushTag(key, value);
}

ensurePushTag('plan', 'pro');
```

Set several tags in one change with `setPushTags` when a screen changes more than one, and empty a value to remove its tag:

```ts
import { setPushTags } from '@/lib/appmintNative';

// Checkout finished: the cart tag goes away and the plan tag is set, all saved or none.
export function onCheckoutDone(plan: string) {
  const saved = setPushTags({ cart: '', plan });
  if (!saved) console.warn('push tags not saved (web, Appwright Push off, or a refused key or value)');
}
```

Read the tags back to show a hint only when this install has a tag:

```ts
import { getPushTags } from '@/lib/appmintNative';

export function OffersHint() {
  const tags = getPushTags();                       // {} on the web
  if (Object.keys(tags).length === 0) return null;
  return <p>Offers for the {tags.plan ?? 'standard'} plan are on.</p>;
}
```

**Notes:** this is what the phone holds; the send center sees it after the install's next check-in. Android only: on iOS and the web it does nothing.

### `appmintNative.pickFile`

**Example**

Lets the user choose one file and read it as text. In the app it opens Android's document picker; on the web it opens a normal file input.

**Returns:** `Promise<PickedFile | null>` — `{ name, mime, size, text(): Promise<string> }`, or `null` only when the user cancels. It rejects with an `Error` whose `code` is `'no-picker'` (the phone has no document picker app), `'disabled'` or `'failed'` when no picker could open. `text()` reads the whole file and rejects with the bridge's code (for example `'not-found'`) when it cannot. **Needs:** nothing. With **Native Folder Access (SAF)** ticked in Step 4 (Access) the app uses Android's document picker; without it the app uses the page's own file chooser.

```ts
import { pickFile } from '@/lib/appmintNative';

async function importCsv() {
  let f;
  try {
    f = await pickFile('text/csv');              // default '*/*' = any file
  } catch (e) {
    alert(`Could not open the file picker (${(e as { code?: string }).code ?? 'error'}).`);
    return;
  }
  if (!f) return;                                // the user cancelled
  if (f.size > 2_000_000) { alert('Please choose a file under 2 MB.'); return; }
  try {
    setRows(parseCsv(await f.text()));
  } catch (e) {
    alert(`Could not read ${f.name}: ${String(e)}`);
  }
}
```

Import a JSON backup and handle each way `pickFile` can fail:

```ts
import { pickFile, toast } from '@/lib/appmintNative';

export async function restoreBackup(apply: (data: unknown) => void) {
  try {
    const f = await pickFile('application/json');
    if (!f) return;                                    // cancelled
    apply(JSON.parse(await f.text()));
    toast(`Restored ${f.name}`);
  } catch (e) {
    const code = (e as { code?: string }).code;
    if (code === 'no-picker') toast('This phone has no file picker app');
    else if (e instanceof SyntaxError) toast('That file is not a valid backup');
    else toast('Could not open the file');
  }
}
```

Choose a text file and put it in an editor, next to the file's details:

```ts
import { useState } from 'react';
import { pickFile } from '@/lib/appmintNative';

export function OpenText() {
  const [doc, setDoc] = useState<{ name: string; kb: number; text: string } | null>(null);
  return (
    <div>
      <button onClick={async () => {
        const f = await pickFile('text/*');
        if (f) setDoc({ name: f.name, kb: Math.ceil(f.size / 1024), text: await f.text() });
      }}>Open a text file</button>
      {doc && <><h3>{doc.name} ({doc.kb} KB)</h3><textarea defaultValue={doc.text} /></>}
    </div>
  );
}
```

**Notes:** In the app `text()` reads the file in 4 MB pieces until the end and decodes it as UTF-8, so large files are read in full — keep your own size limit (as above) for what the screen can handle. On the web `text()` is the browser's own `File.text()`. For binary files or writing back, hand-written pages use `WebToApkFS`.

### `appmintNative.saveFile`

**Example**

Saves text or a Blob as a file the user keeps, with the name you choose. In the app it opens Android's "Save as…" sheet with that name; in a browser it is a normal download.

**Returns:** (sync) `boolean` — `true` when the download was started, `false` when there is no `document` or creating it threw. It does not tell you whether the user saved or cancelled the sheet. **Needs:** nothing.

```ts
import { saveFile } from '@/lib/appmintNative';

function exportScores(rows: { name: string; score: number }[]) {
  const csv = ['name,score', ...rows.map((r) => `${r.name},${r.score}`)].join('\n');
  if (!saveFile('Scores.csv', csv, 'text/csv')) alert('Could not start the download.');
}
```

A Blob keeps its own type (the third argument is ignored):

```ts
import { saveFile } from '@/lib/appmintNative';

const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
saveFile('Backup.json', blob);
```

Save the result of a fetch, and say so when it could not start:

```ts
import { saveFile, toast } from '@/lib/appmintNative';

export async function downloadInvoice(id: string) {
  const res = await fetch(`/api/invoices/${id}.pdf`);
  if (!res.ok) { toast('Could not get the invoice'); return; }
  const pdf = await res.blob();                          // the Blob keeps its own type
  if (!saveFile(`Invoice-${id}.pdf`, pdf)) toast('Could not start the download');
}
```

**Notes:** It is a plain `<a download>` under the hood, which the app turns into the "Save as…" sheet (see `downloads`). Always pass a name with an extension; without one the file is saved as `download`. The default MIME type for a string is `text/plain`.

### `appmintNative.unlock`

**Example**

Asks the phone's owner to prove they are present — fingerprint, face, or the device PIN/passcode — and tells you whether they did.

**Returns:** `Promise<boolean>` — `true` when unlocked; `false` when cancelled, failed, locked out, or nothing can check on this device. It never throws. **Needs:** Android: **Fingerprint** ticked when you build. iOS: Face ID / Touch ID. Web: the browser's platform authenticator (WebAuthn).

```ts
import { unlock, can } from '@/lib/appmintNative';

export async function openPrivateNotes(show: () => void) {
  if (can('unlock') === 'none') {
    alert('This device has no fingerprint, face or PIN check.');
    return;
  }
  const ok = await unlock('Unlock notes', 'Use your fingerprint or PIN');
  if (ok) show();
}
```

Hide the button where there is nothing to unlock with:

```ts
import { can } from '@/lib/appmintNative';

const showLockButton = can('unlock') !== 'none';   // 'native' | 'web' | 'none'
```

Guard a delete with a fresh unlock, then confirm with a haptic and a toast:

```ts
import { unlock, haptic, toast } from '@/lib/appmintNative';

export async function deleteAccountData(wipe: () => Promise<void>) {
  const ok = await unlock('Confirm delete', 'This removes your saved data');
  if (!ok) { haptic('warning'); toast('Not unlocked, nothing was deleted'); return; }
  await wipe();
  haptic('success');
  toast('Data deleted');
}
```

**Notes:** Android and iOS both allow the device PIN/passcode as the second way in. On the web, the first call enrols the browser's platform authenticator and counts as unlocked. This proves the device owner is present; it is NOT a login — never use it as the only guard on data that lives on a server. On Android 11 and newer the prompt opens on any phone with a screen lock — a PIN-only phone gets it too, and unlocks with the PIN; on Android 10 and older a fingerprint must be saved first, otherwise it answers `false` without a prompt.

### `appmintNative.pickContact`

**Example**

Opens the system contact picker so the user chooses one person: Android's picker in the app, iOS's picker through Capacitor, the Contact Picker API in Chrome on Android.

**Returns:** `Promise<Contact | null>` — `null` when the user cancels or access is refused. **Needs:** nothing on Android (no permission); iOS asks for contacts access.

**Pick a person and fill a form.** The contact has the same shape everywhere: `{ id?, name?, phones?: string[], emails?: string[], photoUri? }`.

```ts
import { pickContact, can } from '@/lib/appmintNative';

export async function onPickClick(setName: (s: string) => void, setPhone: (s: string) => void) {
  if (can('contactPick') === 'none') return;   // hide the button instead
  try {
    const c = await pickContact();
    if (!c) return;                            // cancelled
    setName(c.name ?? '');
    setPhone(c.phones?.[0] ?? '');
  } catch {
    // only a 5-minute timeout rejects
  }
}
```

A "Choose a contact" button that shows the pick, and a plain number box where the picker does not exist:

```ts
import { useState } from 'react';
import { can, pickContact, type Contact } from '@/lib/appmintNative';

export function InvitePicker() {
  const [who, setWho] = useState<Contact | null>(null);
  if (can('contactPick') === 'none') return <input placeholder="Phone number" />;   // type it by hand
  return (
    <div>
      <button onClick={async () => setWho((await pickContact()) ?? who)}>Choose a contact</button>
      {who && <p>{who.name} {who.phones?.[0]}</p>}
    </div>
  );
}
```

Hand the chosen number to the texting app, with a message when the contact has none:

```ts
import { pickContact, toast } from '@/lib/appmintNative';

export async function textAFriend(message: string) {
  const c = await pickContact();
  if (!c) return;                              // cancelled
  const phone = c.phones?.[0];
  if (!phone) { toast(`${c.name ?? 'That contact'} has no phone number`); return; }
  window.location.href = `sms:${encodeURIComponent(phone)}?body=${encodeURIComponent(message)}`;
}
```

**Notes:** On Android only the tapped number comes back, never emails, and `photoUri` is a `content://` address of the contact photo. `can('contactPick')` is `'none'` in browsers without the Contact Picker API (desktop, Safari) — do not render the button there. On iOS the contacts permission sheet appears first; a refusal resolves `null`.

### `appmintNative.listContacts`

**Example**

Reads the address book, a page at a time. App only: the shell's bridge on Android, the Contacts plugin on iOS.

**Returns:** `Promise<Contact[]>`. An empty array when there are no contacts, when access is refused, or when Contacts was not enabled at build time. **Needs:** Android: the **Contacts** switch in Step 4 (Access) — the build turns it on when your code calls this. iOS: the user allows access on the first call.

**A contact list screen.** Render it only where it can work. Each contact is `{ id?, name?, phones?: string[], emails?: string[], photoUri? }` on both stores.

```ts
import { listContacts, can, type Contact } from '@/lib/appmintNative';

type Row = { id: string; name: string; phone: string };

function toRow(c: Contact): Row {
  return { id: c.id ?? '', name: c.name ?? '', phone: c.phones?.[0] ?? '' };
}

export async function loadPage(page: number): Promise<Row[] | null> {
  if (can('contactList') === 'none') return null;   // web: show "open the app" instead
  try {
    const contacts = await listContacts(100, page * 100);
    return contacts.map(toRow);
  } catch {
    return [];                                        // 2-minute timeout
  }
}
```

**Search the whole address book** by reading it page by page into one list.

```ts
import { listContacts, can, type Contact } from '@/lib/appmintNative';

export async function findContacts(query: string): Promise<Contact[] | null> {
  if (can('contactList') === 'none') return null;
  const all: Contact[] = [];
  for (let offset = 0; ; offset += 200) {
    const page = await listContacts(200, offset).catch(() => []);
    all.push(...page);
    if (page.length < 200) break;
  }
  const q = query.trim().toLowerCase();
  return all.filter((c) => (c.name ?? '').toLowerCase().includes(q));
}
```

**Pair it with `sendSms`** to message the first number of a contact.

```ts
import { sendSms, type Contact } from '@/lib/appmintNative';

export async function invite(c: Contact): Promise<boolean> {
  const phone = c.phones?.[0];
  if (!phone) return false;
  return sendSms(phone, `Hi ${c.name ?? ''}, join me on the app!`);
}
```

**Notes:** An empty result cannot tell "no contacts" from "permission refused" — show a short hint ("Allow contacts in Settings") when the list is empty. Android sorts A to Z by name; `limit` is clamped to 2000. For a single person use `pickContact()`, which needs no permission. Contacts is personal data: declare it in your Play Data safety form and privacy policy.

### `appmintNative.searchContacts`

**Example**

Finds contacts that match a search word. App only.

**Returns:** `Promise<Contact[]>` — empty when nothing matches, access is refused, or Contacts is off. **Needs:** the same as `listContacts`: Android's **Contacts** switch (turned on by the build when your code calls this), iOS's contacts permission.

**Search box with a small delay while typing.**

```ts
import { searchContacts, can } from '@/lib/appmintNative';

let timer: ReturnType<typeof setTimeout> | undefined;
let latest = 0;

export function onSearchInput(text: string, show: (names: string[]) => void) {
  if (can('contactList') === 'none') return;
  clearTimeout(timer);
  timer = setTimeout(async () => {
    const mine = ++latest;
    const found = await searchContacts(text, 20).catch(() => []);
    if (mine !== latest) return;                     // a newer search already started
    show(found.map((c) => c.name ?? ''));
  }, 250);
}
```

Show a clear state when Contacts is not available and when nothing matches:

```ts
import { useState } from 'react';
import { can, searchContacts, type Contact } from '@/lib/appmintNative';

export function ContactPicker({ onPick }: { onPick: (c: Contact) => void }) {
  const [rows, setRows] = useState<Contact[] | null>(null);
  if (can('contactList') === 'none') return <p>Choosing a contact works only in the Android app.</p>;
  return (
    <div>
      <input placeholder="Search contacts" onChange={async (e) => setRows(await searchContacts(e.target.value, 10))} />
      {rows && rows.length === 0 && <p>No contacts match, or access was not allowed.</p>}
      {rows?.map((c) => <button key={c.id ?? c.name} onClick={() => onPick(c)}>{c.name} {c.phones?.[0]}</button>)}
    </div>
  );
}
```

Look up a person by name, then pre-fill a text message to them:

```ts
import { searchContacts, composeSms } from '@/lib/appmintNative';

export async function textPerson(name: string, message: string): Promise<boolean> {
  const [first] = await searchContacts(name, 1);
  const number = first?.phones?.[0];
  return number ? composeSms(number, message) : false;   // the user presses send
}
```

**Notes:** On Android the match is done by the phone's contacts search on names. On iOS the whole book is read once and matched on name, phone and email. Each contact is `{ id?, name?, phones?: string[], emails?: string[], photoUri? }` on both stores. Contacts is personal data: declare it in your Play Data safety form and privacy policy.

### `appmintNative.addContact`

**Example**

Saves a new contact into the phone's address book. App only: the shell's bridge on Android (saved directly, no Contacts screen opens), the Contacts plugin on iOS.

**Returns:** `Promise<boolean>` — `true` once saved, `false` when the user refused the contacts permission or the save failed. It rejects with an `Error` whose `code` is `'not_enabled'` (the build did not switch on writing contacts) or `'foreign_origin'`, and after a 5-minute wait with no answer. **Needs:** Android: the **Edit Contacts** switch in Step 4 (Access) — the build turns it on when your code calls this — and the user's permission on first use. iOS: the user allows contacts access.

**Save a customer from a form.**

```ts
import { addContact, can } from '@/lib/appmintNative';

export async function saveCustomer(name: string, phone: string, email: string): Promise<string> {
  if (can('contactAdd') === 'none') return 'Saving contacts works in the app only.';
  try {
    const ok = await addContact({ name, phones: [phone], emails: email ? [email] : [] });
    return ok ? 'Saved to contacts' : 'Allow contacts access to save it';
  } catch (e) {
    return (e as { code?: string }).code === 'not_enabled'
      ? 'Saving contacts is not switched on in this app.'
      : 'No answer from the phone. Try again.';     // 5-minute timeout
  }
}
```

**Skip a contact that is already saved.** `searchContacts` reads the address book, so check before you add:

```ts
import { addContact, searchContacts, can } from '@/lib/appmintNative';

export async function addIfNew(name: string, phone: string): Promise<'added' | 'exists' | 'refused' | 'app_only'> {
  if (can('contactAdd') === 'none') return 'app_only';
  const found = await searchContacts(name, 5);
  if (found.some((c) => c.phones?.includes(phone))) return 'exists';
  return (await addContact({ name, phones: [phone] })) ? 'added' : 'refused';
}
```

**Tell a build mistake from a refusal.** A rejection with `code` `'foreign_origin'` or `'not_enabled'` is a build problem, `false` is the user's choice:

```tsx
import { useState } from 'react';
import { addContact } from '@/lib/appmintNative';

export function SaveContactButton({ name, phone }: { name: string; phone: string }) {
  const [msg, setMsg] = useState('');
  async function onTap() {
    try {
      setMsg((await addContact({ name, phones: [phone] })) ? 'Saved.' : 'Contacts access was declined.');
    } catch (e) {
      const code = (e as { code?: string }).code;
      setMsg(code === 'foreign_origin' || code === 'not_enabled' ? 'This build cannot save contacts.' : 'Try again.');
    }
  }
  return <button onClick={onTap}>{msg || 'Save to contacts'}</button>;
}
```

**Notes:** It saves at once — no contact editor opens — so confirm with the user in your own screen first. Android saves the phone numbers as mobile numbers and the contact on the device (no account). Writing contacts is sensitive: declare it in your Play Data safety form and privacy policy.

### `appmintNative.composeSms`

**Example**

Opens the messaging app with the number and text filled in; the user presses send. Works in the Android app (shell bridge) and on the web (an `sms:` link).

**Returns:** `boolean` right away — `true` when the messaging app was asked to open. **Needs:** no permission.

**A "Text the shop" button.**

```ts
import { composeSms, can } from '@/lib/appmintNative';

export function setUpTextButton(button: HTMLButtonElement) {
  if (can('smsCompose') === 'none') { button.hidden = true; return; }
  button.addEventListener('click', () => {
    const opened = composeSms('+911234567890', 'Hi, is my order ready?');
    if (!opened) alert('Could not open the messaging app.');
  });
}
```

**A reusable hook that hides the button when there is no number.**

```ts
import { composeSms, can } from '@/lib/appmintNative';

export function useTextShop(phone: string) {
  const available = can('smsCompose') !== 'none' && phone.trim() !== '';
  return {
    available,
    send: (orderId: string) => composeSms(phone, `Hi, about order ${orderId}.`),
  };
}
```

**Invite a friend; the user presses send.**

```ts
import { composeSms, toast } from '@/lib/appmintNative';

export function inviteFriend(number: string, link: string) {
  if (!composeSms(number, `Join me: ${link}`)) toast('Could not open the messaging app');
}
```

**Notes:** Prefer this over `sendSms()` for anything the user writes or approves — nothing leaves the phone without the user pressing send, and it is the only SMS option that exists on iOS. On the web it navigates to `sms:…?body=…`. A `composeSms(` call does not switch on **SMS (read & send)** when you build, so it adds no Play-restricted permission.

### `appmintNative.sendSms`

**Example**

Sends an SMS directly, with no confirmation screen. Android app only.

**Returns:** `Promise<boolean>` — `true` when the phone reports the message as sent, `false` when it failed, when SMS was not enabled, or when the user refused permission. **Needs:** the **SMS (read & send)** switch in Step 4 (Access) — the build turns it on when your code calls this — and the user's permission on first use.

**Send an alert and tell the user what happened.**

```ts
import { sendSms, can } from '@/lib/appmintNative';

export async function sendAlert(to: string, text: string): Promise<string> {
  if (can('smsSend') === 'none') return 'Sending SMS works only in the Android app.';
  try {
    const ok = await sendSms(to, text);
    return ok ? 'Alert sent' : 'The SMS could not be sent';
  } catch {
    return 'No answer from the phone after 3 minutes';
  }
}
```

Ask for a confirmation first, since the message goes out with no screen to cancel it:

```ts
import { sendSms, composeSms, can, toast } from '@/lib/appmintNative';

export async function sendCheckIn(to: string) {
  if (can('smsSend') === 'none') { composeSms(to, 'I am safe'); return; }   // the user presses send
  if (!window.confirm('Send "I am safe" to ' + to + '? This may cost money.')) return;
  toast((await sendSms(to, 'I am safe')) ? 'Sent' : 'Could not send. Check the SMS permission.');
}
```

Send the same alert to several people, one at a time, and count what went out:

```ts
import { sendSms } from '@/lib/appmintNative';

export async function alertAll(numbers: string[], text: string) {
  let sent = 0;
  for (const n of numbers) {
    if (await sendSms(n, text)) sent += 1;      // waits for the phone's answer for each one
  }
  return { sent, failed: numbers.length - sent };
}
```

**Notes:** The wrapper resolves on the FIRST answer, which is `sent` (the message left the phone), not `delivered`. For delivery reports use the raw `WebToApk.sendSms` with the `appmint:sms` event. `can('smsSend')` is `'none'` on the web and on iOS (Apple has no API for it). Each SMS may cost the user money. SMS is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, so prefer `composeSms()` when the user can press send.

### `appmintNative.listSms`

**Example**

Reads SMS messages from the inbox, the sent box or the drafts, newest first. Android app only.

**Returns:** `Promise<SmsMessage[]>` — each `{ id, address, body, date, read, box }`. Empty when SMS is off, permission is refused, or the box is empty. **Needs:** the **SMS (read & send)** switch in Step 4 (Access) — the build turns it on when your code calls this — and the user's permission on first use.

**Show the latest 20 messages from the inbox.**

```ts
import { listSms, can, type SmsMessage } from '@/lib/appmintNative';

export async function latestInbox(): Promise<SmsMessage[] | null> {
  if (can('smsList') === 'none') return null;          // web and iOS: hide the screen
  try {
    const msgs = await listSms('inbox', 20, 0);
    return msgs.map((m) => ({ ...m, body: (m.body ?? '').slice(0, 120) }));
  } catch {
    return [];                                          // 2-minute timeout
  }
}
```

**Unread messages from one sender**, newest first.

```ts
import { listSms, can } from '@/lib/appmintNative';

export async function unreadFrom(address: string) {
  if (can('smsList') === 'none') return null;
  const msgs = await listSms('inbox', 200, 0).catch(() => []);
  return msgs.filter((m) => m.address === address && !m.read);
}
```

**Read the sent box and the drafts** in one go, and sort them together by date.

```ts
import { listSms, type SmsMessage } from '@/lib/appmintNative';

export async function outgoing(): Promise<SmsMessage[]> {
  const [sent, drafts] = await Promise.all([
    listSms('sent', 50, 0).catch(() => []),
    listSms('draft', 50, 0).catch(() => []),
  ]);
  return [...sent, ...drafts].sort((a, b) => (b.date ?? 0) - (a.date ?? 0));
}
```

**Notes:** An empty list cannot tell "no messages" from "permission refused". `can('smsList')` is `'none'` on the web and iOS. New incoming messages are not part of this wrapper; hand-written pages get them from the `appmint:sms-received` event (see `listSms` for the raw bridge). SMS is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, normally only granted to default SMS apps.

### `appmintNative.listCallLog`

**Example**

Reads the call history, newest first. Android app only.

**Returns:** `Promise<CallLogEntry[]>` — each `{ number, name, type, date, duration }`; `type` is `incoming`, `outgoing`, `missed`, `voicemail`, `rejected`, `blocked` or `other`, `date` is epoch milliseconds, `duration` is seconds. Empty when Call log is off or permission is refused. **Needs:** the **Call log (read)** switch in Step 4 (Access) — the build turns it on when your code calls this — and the user's permission on first use.

**Missed calls from the last 7 days.**

```ts
import { listCallLog, can } from '@/lib/appmintNative';

export async function missedThisWeek() {
  if (can('callLog') === 'none') return null;          // web and iOS: not possible
  const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
  try {
    const calls = await listCallLog(500, 0, since);   // a number here; the wrapper converts it
    return calls.filter((c) => c.type === 'missed');
  } catch {
    return [];                                          // 2-minute timeout
  }
}
```

**Total talk time per contact** from outgoing and incoming calls, longest first.

```ts
import { listCallLog, can } from '@/lib/appmintNative';

export async function talkTimeByPerson() {
  if (can('callLog') === 'none') return null;
  const calls = await listCallLog(1000, 0).catch(() => []);
  const totals = new Map<string, number>();
  for (const c of calls) {
    if (c.type !== 'incoming' && c.type !== 'outgoing') continue;
    const who = c.name || c.number || 'Unknown';
    totals.set(who, (totals.get(who) ?? 0) + (c.duration ?? 0));   // seconds
  }
  return [...totals].sort((a, b) => b[1] - a[1]);
}
```

**A screen that explains the empty list**, since "no calls" and "permission refused" look the same.

```ts
import { listCallLog, can } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

export function useCalls() {
  const [calls, setCalls] = useState<Awaited<ReturnType<typeof listCallLog>> | null>(null);
  const supported = can('callLog') !== 'none';
  useEffect(() => {
    if (supported) void listCallLog(50).then(setCalls).catch(() => setCalls([]));
  }, [supported]);
  const hint = !supported ? 'Open the app on your phone to see calls'
    : calls && calls.length === 0 ? 'No calls found. Allow call log access in Settings if you expected some.' : '';
  return { calls: calls ?? [], hint };
}
```

**Notes:** `limit` is clamped to 2000 by the app. An empty list cannot tell "no calls" from "permission refused". `can('callLog')` is `'none'` on the web and on iOS (Apple offers no call-history API). Call log is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, or the app is rejected.

### `appmintNative.toast`

**Example**

Shows a brief message that needs no answer — "Copied", "Saved", "Added to list".

**Returns:** nothing (fire and forget). **Needs:** nothing.

```ts
import { toast, haptic } from '@/lib/appmintNative';

export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  return (
    <button
      onClick={async () => {
        await onSave();
        haptic('success');
        toast('Saved');                              // 'short' ≈ 2 s
      }}
    >
      Save
    </button>
  );
}
```

A longer message stays about 3.5 seconds:

```ts
import { toast } from '@/lib/appmintNative';

toast('Your export will be ready in a minute', { duration: 'long' });
```

The Capacitor-shaped form `Toast.show({ text, duration? })` does the same and resolves `Promise<void>`:

```ts
import { Toast } from '@/lib/appmintNative';

await Toast.show({ text: 'Added to list' });
```

For a message the user MUST see, use a dialog instead:

```ts
import { Dialog } from '@/lib/appmintNative';

await Dialog.alert({ title: 'Payment failed', message: 'Your card was declined.' });
```

**Notes:**
- In the Android app the Capacitor Toast plugin shows it at the bottom of the screen.
- Everywhere else it is a small snackbar drawn over the page in the app's own colours (`--ink` on `--bg`), above the safe-area inset, with `role="status"` so screen readers announce it.
- Only one is shown at a time; a new toast replaces the old one.
- A toast is easy to miss by design. Never use it for an error the user has to act on.

### `appmintNative.actionSheet`

**Example**

Shows a "choose one of these" bottom sheet — the native control for a row's actions menu.

**Returns:** `Promise<number | null>` — the index of the chosen option, or `null` when the user dismissed the sheet (backdrop tap, Escape, the system cancel). Dismissal is normal, not an error. **Needs:** nothing.

```ts
import { actionSheet, haptic } from '@/lib/appmintNative';

export async function onNoteMenu(note: { id: string }, api: {
  edit(id: string): void; share(id: string): void; remove(id: string): void;
}) {
  const i = await actionSheet('Note', [
    { title: 'Edit' },
    { title: 'Share' },
    { title: 'Delete', destructive: true },
  ]);
  if (i === null) return;                 // dismissed
  if (i === 0) api.edit(note.id);
  if (i === 1) api.share(note.id);
  if (i === 2) { haptic('warning'); api.remove(note.id); }
}
```

Open it from a visible button, and from a long press on the row:

```ts
import { useEffect, useRef } from 'react';
import { actionSheet, longPress } from '@/lib/appmintNative';

export function Row({ label }: { label: string }) {
  const ref = useRef<HTMLLIElement>(null);
  const openMenu = () => actionSheet(label, [{ title: 'Rename' }, { title: 'Delete', destructive: true }]);
  useEffect(() => (ref.current ? longPress(ref.current, () => void openMenu()) : undefined), []);
  return <li ref={ref}>{label} <button aria-label="More actions" onClick={() => void openMenu()}>⋯</button></li>;
}
```

The Capacitor-shaped form `ActionSheet.showActions({ title?, options })` takes the same options and resolves `{ index }`, with `-1` for a dismissal:

```ts
import { ActionSheet } from '@/lib/appmintNative';

const { index } = await ActionSheet.showActions({
  title: 'Photo',
  options: [{ title: 'Save' }, { title: 'Delete', destructive: true }],
});
if (index === 1) console.log('delete');   // -1 = dismissed; never test with `if (index)`
```

**Notes:**
- In the Android app the Capacitor ActionSheet plugin draws the system sheet; `destructive` uses its destructive style.
- Elsewhere it is a real sheet in the app's palette (`--surface`, `--ink`, `--danger`), with backdrop, Escape, focus moved into the sheet and a selection haptic.
- An empty `options` list resolves `null` at once. For any content other than a list of actions, use `sheet()`.

### `appmintNative.onKeyboard`

**Example**

Tells you how many pixels of the page the on-screen keyboard covers, every time that changes, so a bottom bar can ride above it.

**Returns:** an unsubscribe function — call it on unmount. The callback gets a number in CSS pixels: the extra bottom padding you need, `0` when the keyboard is closed. **Needs:** nothing.

```ts
import { useEffect, useState } from 'react';
import { onKeyboard } from '@/lib/appmintNative';

export function Composer() {
  const [kb, setKb] = useState(0);
  useEffect(() => onKeyboard(setKb), []);          // the returned function unsubscribes

  return (
    <form
      className="fixed inset-x-0 bottom-0 flex gap-2 p-3"
      style={{ paddingBottom: `calc(var(--safe-bottom) + 12px + ${kb}px)` }}
    >
      <input className="flex-1" enterKeyHint="send" aria-label="Message" />
      <button type="submit">Send</button>
    </form>
  );
}
```

Keep a chat list scrolled to the newest message while the keyboard opens:

```ts
import { useEffect, useRef } from 'react';
import { onKeyboard } from '@/lib/appmintNative';

export function MessageList({ children }: { children: React.ReactNode }) {
  const endRef = useRef<HTMLDivElement>(null);
  useEffect(() => onKeyboard((inset) => {
    if (inset > 0) endRef.current?.scrollIntoView({ block: 'end' });   // keyboard just opened
  }), []);
  return <div className="overflow-y-auto">{children}<div ref={endRef} /></div>;
}
```

A reusable hook, with a safe-area gap that disappears while the keyboard is open:

```ts
import { useEffect, useState } from 'react';
import { onKeyboard } from '@/lib/appmintNative';

export function useKeyboardInset(): number {
  const [inset, setInset] = useState(0);        // 0 until the keyboard first opens
  useEffect(() => onKeyboard(setInset), []);
  return inset;
}

// In a component: the home-indicator gap is only needed when the keyboard is closed.
// const kb = useKeyboardInset();
// <footer style={{ paddingBottom: kb > 0 ? kb : 'var(--safe-bottom)' }}>...</footer>
```

**Notes:**
- In the app it listens to the Capacitor Keyboard plugin and reports only the part of the keyboard the layout did not already absorb. The shell usually resizes the page for the keyboard, so the value is often `0` there — that is correct; do not add the raw keyboard height yourself.
- In a fullscreen or kiosk app (page not resized) it reports the whole keyboard height.
- On the web it uses `visualViewport`, calls the callback once right away, and treats under 80 px as browser chrome (reported as `0`).
- In the app the first call comes when the keyboard first opens, so start your state at `0`.

### `appmintNative.hideKeyboard`

**Example**

Closes the on-screen keyboard — for example when a form is submitted or a sheet opens over the field.

**Returns:** nothing. **Needs:** nothing.

```ts
import { hideKeyboard, toast } from '@/lib/appmintNative';

export function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const q = new FormData(e.currentTarget).get('q') as string;
        hideKeyboard();                       // show the results, not the keyboard
        onSearch(q);
        if (!q) toast('Type something to search');
      }}
    >
      <input name="q" type="search" enterKeyHint="search" aria-label="Search" />
    </form>
  );
}
```

Close the keyboard when a sheet opens over the field, so the sheet is not hidden behind it:

```ts
import { useState } from 'react';
import { hideKeyboard } from '@/lib/appmintNative';

export function useSheet() {
  const [open, setOpen] = useState(false);
  return {
    open,
    show: () => { hideKeyboard(); setOpen(true); },
    hide: () => setOpen(false),
  };
}
```

Close it when the user taps anywhere that is not a text field:

```ts
import type { ReactNode } from 'react';
import { hideKeyboard } from '@/lib/appmintNative';

export function Screen({ children }: { children: ReactNode }) {
  return (
    <div onPointerDown={(e) => {
      if (!(e.target as HTMLElement).closest('input, textarea')) hideKeyboard();
    }}>
      {children}
    </div>
  );
}
```

**Notes:** In the app it asks the Capacitor Keyboard plugin to hide the keyboard. On the web it blurs the focused field, which is what closes the keyboard in a browser. It is safe to call when no keyboard is open.

### `appmintNative.lockOrientation`

**Example**

Pins the screen one way up for a view that only makes sense that way — a game board, a wide chart, a signature pad. `'any'` releases the lock.

**Returns:** `Promise<boolean>` — `true` when the lock (or release) was applied, `false` when the platform refused. It never rejects. **Needs:** nothing.

Lock while the view is open, release on leave:

```ts
import { useEffect } from 'react';
import { can, lockOrientation } from '@/lib/appmintNative';

export function ChartScreen() {
  useEffect(() => {
    if (can('orientationLock') === 'none') return;
    void lockOrientation('landscape');
    return () => { void lockOrientation('any'); };   // always give the rotation back
  }, []);
  return <div className="h-full w-full">{/* the chart */}</div>;
}
```

Check the answer when the view depends on it:

```ts
import { lockOrientation, toast } from '@/lib/appmintNative';

const ok = await lockOrientation('portrait');
if (!ok) toast('Turn your phone upright for the best view');
```

Release the lock when the screen goes away, as a hook shared by several screens.

```ts
import { useEffect } from 'react';
import { lockOrientation, type OrientationLock } from '@/lib/appmintNative';

export function useOrientation(mode: OrientationLock) {
  useEffect(() => {
    void lockOrientation(mode);
    return () => { void lockOrientation('any'); };
  }, [mode]);
}

// useOrientation('landscape') inside the game screen
```

**Notes:**
- In the app the Capacitor ScreenOrientation plugin applies the lock.
- On the web it uses `screen.orientation.lock()`. Most browsers refuse it outside fullscreen and desktop browsers always refuse it, so expect `false` there.
- Always give the user a way out of a locked view.

### `appmintNative.networkStatus`

**Example**

Says, right now, whether the device is online.

**Returns:** `{ connected, type }`, synchronously. `type` is `'wifi'`, `'cellular'`, `'none'` (offline) or `'unknown'`. **Needs:** nothing.

Cheap enough to call during render:

```ts
import { networkStatus } from '@/lib/appmintNative';

export function SyncButton({ onSync }: { onSync: () => void }) {
  const { connected } = networkStatus();
  return (
    <button disabled={!connected} onClick={onSync}>
      {connected ? 'Sync now' : 'Offline — will sync later'}
    </button>
  );
}
```

Read the start value, then follow changes:

```ts
import { useEffect, useState } from 'react';
import { networkStatus, onNetworkChange } from '@/lib/appmintNative';

export function useOnline(): boolean {
  const [online, setOnline] = useState(() => networkStatus().connected);
  useEffect(() => onNetworkChange((s) => setOnline(s.connected)), []);
  return online;
}
```

Choose a lighter image size on mobile data, with the `'none'` (offline) case answered too.

```ts
import { networkStatus } from '@/lib/appmintNative';

export function imageUrl(id: string): string {
  const { connected, type } = networkStatus();
  if (!connected) return '/img/placeholder.svg';         // offline: use the packaged picture
  return `/api/images/${id}?size=${type === 'cellular' ? 'small' : 'large'}`;
}
```

**Notes:**
- In the app it is the Network plugin's answer (kept current in the background, so the very first call may still read the WebView's own value). On the web it is `navigator.onLine` plus `navigator.connection.type` where the browser reports one, else `'unknown'` — a speed class such as `'4g'` is never reported as the type.
- "Connected" means the device has a network, not that your server answers. Handle failed requests anyway.

### `appmintNative.onNetworkChange`

**Example**

Calls you whenever the connection drops or comes back — for an offline banner, or to retry queued writes.

**Returns:** an unsubscribe function — call it on unmount. **Needs:** nothing.

Retry queued work when the device is back online:

```ts
import { useEffect } from 'react';
import { onNetworkChange, toast } from '@/lib/appmintNative';

export function useRetryWhenOnline(flushQueue: () => Promise<number>) {
  useEffect(() => {
    const off = onNetworkChange(async (s) => {
      if (!s.connected) return;
      const sent = await flushQueue();
      if (sent) toast(`Synced ${sent} change(s)`);
    });
    return off;                                  // unsubscribe on unmount
  }, [flushQueue]);
}
```

Show the current state at once, then follow changes (the callback is not called on subscribe):

```ts
import { useEffect, useState } from 'react';
import { networkStatus, onNetworkChange } from '@/lib/appmintNative';

export function useOnline(): boolean {
  const [online, setOnline] = useState(() => networkStatus().connected);
  useEffect(() => onNetworkChange((s) => setOnline(s.connected)), []);
  return online;
}
```

Hold large downloads back on mobile data, and treat `'none'` as offline:

```ts
import { useEffect } from 'react';
import { onNetworkChange, toast } from '@/lib/appmintNative';

export function useDataSaver(setHeavyAllowed: (ok: boolean) => void) {
  useEffect(() => onNetworkChange((s) => {
    if (s.type === 'none' || !s.connected) { toast('You are offline'); setHeavyAllowed(false); return; }
    setHeavyAllowed(s.type !== 'cellular');   // 'wifi' and 'unknown' may download freely
  }), [setHeavyAllowed]);
}
```

**Notes:**
- It does NOT call you with the current state when you subscribe. Read `networkStatus()` for the starting value.
- In the app it listens to the Capacitor Network plugin (`networkStatusChange`); `type` is `'wifi'`, `'cellular'`, `'none'` or `'unknown'`.
- On the web it listens to the `online` / `offline` events and reports `networkStatus()`.
- For a ready-made banner, use `offlineBanner()`, which is built on this.

### `appmintNative.setStatusBar`

**Example**

Paints the system status bar to match the screen, and picks light or dark icons for it.

**Returns:** nothing. **Needs:** nothing.

`style` is the colour of the ICONS: `'light'` = light icons for a dark bar (the default), `'dark'` = dark icons for a light bar. Call it once at startup with the app's `--bg`, and again on any screen with a different background:

```ts
import { useEffect } from 'react';
import { setStatusBar } from '@/lib/appmintNative';

export function App() {
  useEffect(() => { setStatusBar('#0B0F1A', 'light'); }, []);   // dark app, light icons
  return <main>{/* … */}</main>;
}

export function PhotoViewer() {
  useEffect(() => {
    setStatusBar('#000000', 'light');
    return () => setStatusBar('#0B0F1A', 'light');                // restore on leave
  }, []);
  return null;
}
```

A light screen:

```ts
import { setStatusBar } from '@/lib/appmintNative';

setStatusBar('#FFFFFF', 'dark');
```

The Capacitor-shaped form (the style is named after the BACKGROUND: `'DARK'` = light icons):

```ts
import { StatusBar } from '@/lib/appmintNative';

await StatusBar.setBackgroundColor({ color: '#0B0F1A' });
await StatusBar.setStyle({ style: 'DARK' });
```

**Notes:**
- In the Android app the shell paints the bar itself, which also works on Android 15 edge-to-edge; an unparseable colour is ignored — pass `#RRGGBB`. A style-only call (`backgroundColor = null`) keeps the colour this page last set and flips the icons; before the page set any colour it flips the icons through the StatusBar plugin and the bar keeps the colour chosen at build time.
- On iOS the Capacitor StatusBar plugin is used.
- On the web it sets `<meta name="theme-color">`; the browser picks the icon colour itself.

### `appmintNative.onBackGesture`

**Example**

Lets an open sheet, modal, lightbox or wizard step answer the BACK gesture first — the Android edge swipe / back button in the app, the browser's back button on the web.

**Returns:** an unsubscribe function — call it when the view closes or unmounts. Your handler returns `true` to consume the press (you closed something) or `false` to let it through. **Needs:** nothing.

Register only while the sheet is open:

```ts
import { useEffect, useState } from 'react';
import { onBackGesture } from '@/lib/appmintNative';

export function FilterPanel() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (!open) return;
    return onBackGesture(() => { setOpen(false); return true; });   // back closes THIS, not the app
  }, [open]);

  return (
    <>
      <button onClick={() => setOpen(true)}>Filters</button>
      {open && <div role="dialog" aria-label="Filters">{/* … */}</div>}
    </>
  );
}
```

Step back through a wizard, and let the last step's back leave normally:

```ts
import { useEffect } from 'react';
import { onBackGesture } from '@/lib/appmintNative';

export function useWizardBack(step: number, setStep: (n: number) => void) {
  useEffect(() => onBackGesture(() => {
    if (step === 0) return false;       // not ours: normal back navigation happens
    setStep(step - 1);
    return true;
  }), [step, setStep]);
}
```

Close a lightbox with the back gesture, keeping the registration tied to the image being open.

```ts
import { useEffect } from 'react';
import { onBackGesture } from '@/lib/appmintNative';

export function Lightbox({ src, onClose }: { src: string | null; onClose: () => void }) {
  useEffect(() => {
    if (!src) return;
    return onBackGesture(() => { onClose(); return true; });
  }, [src, onClose]);
  return src ? <img src={src} alt="" onClick={onClose} /> : null;
}
```

**Notes:**
- Handlers form a stack: the one registered last (the sheet on top) is asked first. A handler that throws consumes nothing.
- On Android it goes through the shell's back handler. Without it, swiping back with a sheet open closes the whole app.
- On the web it pushes one history entry while any handler is registered and removes it when the last one unsubscribes.
- `can('backGesture')` is `'none'` on iOS — no back gesture reaches the page there, so also draw a visible back / close control.
- React Router already handles normal page back; use this only for state the router does not know about. Always unsubscribe, or a closed sheet keeps eating back presses.

### `appmintNative.swipeable`

**Example**

Adds swipe gestures to an element — swipe-to-delete rows, paging, dismissing a card.

**Returns:** an unsubscribe function that also restores the element's original `touch-action`. **Needs:** nothing.

**Options:** `onLeft`, `onRight`, `onUp`, `onDown` (at least one), `threshold` (px of travel, default `56`), `edgeGuard` (px from the left/right screen edge where horizontal swipes are ignored, default `24`), `haptics` (selection haptic on a swipe, default `true`).

Swipe left to delete, with a visible button for the same action:

```ts
import { useEffect, useRef } from 'react';
import { swipeable, toast } from '@/lib/appmintNative';

export function MessageRow({ id, text, onDelete, onUndo }: {
  id: string; text: string; onDelete(id: string): void; onUndo(id: string): void;
}) {
  const ref = useRef<HTMLLIElement>(null);
  useEffect(() => {
    if (!ref.current) return;
    return swipeable(ref.current, {
      onLeft: () => { onDelete(id); toast('Deleted'); },
    });
  }, [id, onDelete]);

  return (
    <li ref={ref} className="flex items-center justify-between p-4">
      <span>{text}</span>
      <button onClick={() => onDelete(id)}>Delete</button>
      {/* swipe is never the only way to an action */}
    </li>
  );
}
```

Page between tabs with left / right:

```ts
import { swipeable } from '@/lib/appmintNative';

declare const panel: HTMLElement;
declare let tab: number;
const off = swipeable(panel, { onLeft: () => { tab += 1; }, onRight: () => { tab -= 1; }, threshold: 72 });
// later: off();
```

Dismiss a card by swiping it up, with the handler removed on unmount:

```ts
import { useEffect, useRef } from 'react';
import { swipeable } from '@/lib/appmintNative';

export function Banner({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => (ref.current ? swipeable(ref.current, { onUp: onClose, threshold: 40 }) : undefined), [onClose]);
  return <div ref={ref} className="banner">{children}</div>;
}
```

**Notes:**
- Built on Pointer Events, so the same code works with touch, pen and a mouse drag.
- It sets `touch-action` for you: `pan-y` for horizontal handlers (vertical scrolling still works), `pan-x` for vertical ones, `none` for both.
- A swipe must be clearly one direction (1.5 times more on one axis); diagonal scrolls do not fire. A second finger cancels the gesture.
- The edge guard exists because Android's system back gesture owns the screen edges.

### `appmintNative.Haptics`

**Example**

The Capacitor-style `Haptics` object: the same names and options as `@capacitor/haptics`, running on the same code as `haptic()`.

**Returns:** every method returns `void` (not a Promise; `await` on it is harmless). **Needs:** **Vibrate** in Step 4 (Access); AI builds switch it on for you when the code uses `Haptics`.

```ts
import { Haptics } from '@/lib/appmintNative';

Haptics.impact({ style: 'LIGHT' });        // 'LIGHT' | 'MEDIUM' | 'HEAVY'  (default 'LIGHT')
Haptics.notification({ type: 'SUCCESS' }); // 'SUCCESS' | 'WARNING' | 'ERROR' (default 'SUCCESS')
Haptics.vibrate({ duration: 300 });        // ms, default 300
```

A picker that ticks as the value changes:

```ts
import { Haptics } from '@/lib/appmintNative';

export function Stepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
  const step = (d: number) => { Haptics.selectionChanged(); onChange(value + d); };
  return (
    <div onPointerDown={() => Haptics.selectionStart()} onPointerUp={() => Haptics.selectionEnd()}>
      <button aria-label="Less" onClick={() => step(-1)}>-</button>
      <span>{value}</span>
      <button aria-label="More" onClick={() => step(1)}>+</button>
    </div>
  );
}
```

A form that confirms with a notification buzz and adds a short vibration when a field is wrong:

```ts
import { Haptics } from '@/lib/appmintNative';

export function onSubmit(valid: boolean) {
  Haptics.notification({ type: valid ? 'SUCCESS' : 'ERROR' });
  if (!valid) Haptics.vibrate({ duration: 200 });
}
```

**Notes:** `selectionStart` and `selectionChanged` both give one selection tick; `selectionEnd` does nothing. Works in the Android and iOS apps; on the web it uses `navigator.vibrate` where available and otherwise does nothing.

### `appmintNative.Share`

**Example**

Documented together with `appmintNative.share` — the example there shows this one too.

Opens the system share sheet in the app, and the Web Share API in a browser.

**Returns:** `Promise<boolean>` — `true` when the sheet opened; `false` when the user dismissed it or sharing is not available. It never rejects. **Needs:** nothing.

```ts
import { can, share, toast } from '@/lib/appmintNative';

export function ShareRecipe({ recipe }: { recipe: { title: string; url: string } }) {
  if (can('share') === 'none') return null;
  return (
    <button
      onClick={async () => {
        const ok = await share({ title: recipe.title, text: 'Try this recipe', url: recipe.url });
        if (ok) toast('Shared');           // false = the user closed the sheet; say nothing
      }}
    >
      Share
    </button>
  );
}
```

Text only (no link) is fine too:

```ts
import { share } from '@/lib/appmintNative';

await share({ text: 'My score today: 42' });
```

The Capacitor-shaped form `Share.share(opts)` calls the same code but REJECTS when the user cancels (Capacitor's contract) and resolves `{ activityType: '' }` otherwise; `Share.canShare()` resolves `{ value: can('share') !== 'none' }`:

```ts
import { Share } from '@/lib/appmintNative';

if ((await Share.canShare()).value) {
  try {
    await Share.share({ title: 'Invite', url: 'https://example.com/join' });
  } catch {
    /* cancelled or unavailable — never report success here */
  }
}
```

**Notes:**
- Pass only the fields you have. In the app, text and links go through the Capacitor Share plugin.
- `files` works in the Android app (the files go to the share sheet as real attachments; a `url` is added to the text) and on the web where the browser can share files (`navigator.canShare({ files })`). Keep files small — they are passed to the app in memory.
- On the web, call it from a tap; browsers refuse a share that no user action started.

### `appmintNative.Clipboard`

**Example**

The clipboard in the shape of Capacitor's Clipboard plugin — the same code as `copy()` / `paste()`.

**Returns:** `write()` → `Promise<void>`. `read()` → `Promise<{ value: string; type: 'text/plain' }>` (`value` is `''` when the clipboard is empty). **Needs:** nothing.

```ts
import { Clipboard, can } from '@/lib/appmintNative';

async function copyInvite(code: string) {
  await Clipboard.write({ string: code });
}

async function pasteInvite(): Promise<string> {
  if (can('clipboardRead') === 'none') return '';   // hide the Paste button instead
  try {
    const { value } = await Clipboard.read();
    return value;
  } catch {
    return '';   // the browser denied clipboard access
  }
}
```

Copy a code and confirm with a toast:

```ts
import { Clipboard, toast } from '@/lib/appmintNative';

export async function copyCode(code: string) {
  await Clipboard.write({ string: code });
  toast('Code copied');
}
```

Write a link when there is no text, and treat an empty clipboard as nothing to paste:

```ts
import { Clipboard } from '@/lib/appmintNative';

export async function copyUrl(url: string) {
  await Clipboard.write({ url });   // used only because string is empty
}

export async function readCode(): Promise<string | null> {
  try {
    const { value } = await Clipboard.read();
    return value.trim() || null;    // '' = the clipboard is empty
  } catch {
    return null;                    // the browser denied access
  }
}
```

**Notes:** In the app it uses the Capacitor Clipboard plugin; in a browser `navigator.clipboard`. `write` sends only `string` (or `url` when `string` is empty) — no images. In a browser, `read()` can throw when the user denies access; in the app an empty clipboard is `''`, not an error. Ask `can('clipboardRead')` before you show a Paste control.

### `appmintNative.Device`

**Example**

The `@capacitor/device` shape: phone model, OS version, a stable ID and the language. It uses the Capacitor Device plugin in both installed apps and the shell / browser answer elsewhere.

**Returns:** `getInfo()` → `Promise<DeviceInfo & { operatingSystem, isVirtual }>` (with Capacitor also `manufacturer`, `webViewVersion`, …); `getId()` → `Promise<{ identifier }>`; `getLanguageCode()` → `Promise<{ value }>` (two letters, e.g. `"en"`). **Needs:** nothing.

**Show the model and OS version on an About screen.**

```ts
import { Device } from '@/lib/appmintNative';

export async function aboutText(): Promise<string> {
  const info = await Device.getInfo();
  if (info.platform === 'web') return `Browser (${info.language})`;
  return `${info.model ?? 'Unknown model'} · ${info.operatingSystem} ${info.osVersion ?? ''}` +
    (info.isVirtual ? ' · emulator' : '');
}
```

**A stable ID and the language code.**

```ts
import { Device } from '@/lib/appmintNative';

const { identifier } = await Device.getId();       // Capacitor ID in the apps, installId() elsewhere
const { value: lang } = await Device.getLanguageCode();
await fetch('https://api.example.com/hello', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ device: identifier, lang }),
});
```

**Show the app version and the device model in a settings footer.** The sync report gives the app version; the plugin adds the operating system.

```ts
import { deviceInfo, Device } from '@/lib/appmintNative';

export async function footerText(): Promise<string> {
  const base = deviceInfo();                            // sync: platform, language, model, appVersion
  const rich = await Device.getInfo();                  // async: adds operatingSystem, isVirtual
  const app = base.appVersion ? `v${base.appVersion}` : 'web';
  return `${app} · ${rich.model ?? 'browser'} · ${rich.operatingSystem}`;
}
```

**Notes:** Prefer `Device.getInfo()` over the sync `deviceInfo()` when you need `model` or `osVersion` on iOS — the sync one fills them on Android only. On the web `operatingSystem` is `'unknown'` and `model` is empty. `getId()` on the web is the `localStorage` ID from `installId()` and throws when storage is blocked.

### `appmintNative.App`

**Example**

The app's name, package id and version, and a way to close the app — in the shape of Capacitor's App plugin.

**Returns:** `getInfo()` → `Promise<{ name: string; id: string; build: string; version: string }>`. `exitApp()` → `void`. **Needs:** nothing.

```ts
import { App } from '@/lib/appmintNative';

const info = await App.getInfo();
aboutLine.textContent = `${info.name} ${info.version}${info.build ? ` (${info.build})` : ''}`;
```

**Close the app** from a "Quit" item that is not the only way forward:

```ts
import { App, Dialog } from '@/lib/appmintNative';

async function quit() {
  const { value } = await Dialog.confirm({ title: 'Quit?', message: 'Unsaved changes will be lost.' });
  if (value) App.exitApp();
}
```

**Show the app version in a React screen.**

```tsx
import { useEffect, useState } from 'react';
import { App } from '@/lib/appmintNative';

export function VersionLabel() {
  const [text, setText] = useState('');
  useEffect(() => {
    void App.getInfo().then((i) => setText(`${i.name} v${i.version}`));
  }, []);
  return <small>{text}</small>;
}
```

**Notes:** `getInfo` reads the Capacitor App plugin (Android and iOS); when that is missing it uses the shell bridge's name and version (`id` and `build` are then `''`), and on the web `document.title` with an empty version. `exitApp` uses the shell's `closeWindow` on Android; on iOS apps should not quit themselves and this may do nothing. For pause/resume events and deep links see `Capacitor.Plugins.App`, `launchUrl()` and `onDeepLink()`.

### `appmintNative.TextToSpeech`

**Example**

The Capacitor-style `TextToSpeech` object (`speak` / `stop`), running on the same code as `speak()` and `stopSpeaking()`.

**Returns:** `speak({ text, lang?, rate?, pitch? })` → `Promise<void>` that resolves when the text is **queued**, not when it has been spoken. `stop()` → `Promise<void>`. **Needs:** nothing.

```ts
import { TextToSpeech } from '@/lib/appmintNative';

async function announce(orderNo: number) {
  await TextToSpeech.speak({ text: `Order ${orderNo} is ready`, lang: 'en-US', rate: 1, pitch: 1 });
  // Here the text is queued; it may still be speaking.
}

async function silence() {
  await TextToSpeech.stop();
}
```

Read out a list of lines one after the other; each `speak` resolves when queued, so the engine plays them in order:

```ts
import { TextToSpeech } from '@/lib/appmintNative';

export async function readSteps(steps: string[]) {
  await TextToSpeech.stop();   // drop anything still waiting from before
  for (const [i, s] of steps.entries()) {
    await TextToSpeech.speak({ text: `Step ${i + 1}. ${s}`, lang: 'en-US' });
  }
}
```

A read-aloud button that stops speaking when the screen is left:

```ts
import { useEffect } from 'react';
import { TextToSpeech } from '@/lib/appmintNative';

export function ArticleReader({ text }: { text: string }) {
  useEffect(() => () => { void TextToSpeech.stop(); }, []);
  return <button onClick={() => void TextToSpeech.speak({ text, rate: 0.9 })}>Listen</button>;
}
```

**Notes:** Because `speak` resolves early, do not chain "the next step after speaking" on it. For a real end signal use `window.speechSynthesis` with an utterance's `onend`. Android: the app's native engine; iOS: the TextToSpeech plugin; web: `speechSynthesis`.

### `appmintNative.LocalNotifications`

**Example**

The `@capacitor/local-notifications` shape (`LocalNotifications.schedule({ notifications })`) for code written against the Capacitor plugin. It shows each notification immediately through `notify()`.

**Returns:** `Promise<{ notifications }>` — the same list you passed in. It does not tell you whether each one was shown; use `notify()` when you need that. **Needs:** Android: **Notifications** turned on in the Permissions step when you build.

```ts
import { LocalNotifications } from '@/lib/appmintNative';

export async function announceSync(count: number) {
  await LocalNotifications.schedule({
    notifications: [
      { title: 'Sync complete', body: `${count} items updated`, tag: 'sync' },
    ],
  });
}
```

Several notifications in one call, each replacing an earlier one that has the same `tag`.

```ts
import { LocalNotifications } from '@/lib/appmintNative';

export async function reportBatch(done: number, failed: number) {
  await LocalNotifications.schedule({
    notifications: [
      { title: 'Upload finished', body: `${done} files sent`, tag: 'upload-ok' },
      ...(failed ? [{ title: 'Some files failed', body: `${failed} need a retry`, tag: 'upload-failed' }] : []),
    ],
  });
}
```

When you need to know whether it was shown, use `notify()` instead and react to the answer.

```ts
import { notify, toast } from '@/lib/appmintNative';

export async function remind(text: string) {
  const shown = await notify({ title: 'Reminder', body: text, tag: 'reminder' });
  if (!shown) toast(text);   // notifications are off: tell them in the app
}
```

**Notes:** immediate only — the Capacitor fields `id`, `schedule`, `at`, `actionTypeId` and `extra` are not supported; each item is `{ title, body?, icon?, tag? }`. For a notification at a later time, a hand-written page uses `WebToApk.scheduleNotificationEx` (Android). For buttons use `notifyWithActions`.

### `appmintNative.Toast`

**Example**

Documented together with `appmintNative.toast` — the example there shows this one too.

Shows a brief message that needs no answer — "Copied", "Saved", "Added to list".

**Returns:** nothing (fire and forget). **Needs:** nothing.

```ts
import { toast, haptic } from '@/lib/appmintNative';

export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  return (
    <button
      onClick={async () => {
        await onSave();
        haptic('success');
        toast('Saved');                              // 'short' ≈ 2 s
      }}
    >
      Save
    </button>
  );
}
```

A longer message stays about 3.5 seconds:

```ts
import { toast } from '@/lib/appmintNative';

toast('Your export will be ready in a minute', { duration: 'long' });
```

The Capacitor-shaped form `Toast.show({ text, duration? })` does the same and resolves `Promise<void>`:

```ts
import { Toast } from '@/lib/appmintNative';

await Toast.show({ text: 'Added to list' });
```

For a message the user MUST see, use a dialog instead:

```ts
import { Dialog } from '@/lib/appmintNative';

await Dialog.alert({ title: 'Payment failed', message: 'Your card was declined.' });
```

**Notes:**
- In the Android app the Capacitor Toast plugin shows it at the bottom of the screen.
- Everywhere else it is a small snackbar drawn over the page in the app's own colours (`--ink` on `--bg`), above the safe-area inset, with `role="status"` so screen readers announce it.
- Only one is shown at a time; a new toast replaces the old one.
- A toast is easy to miss by design. Never use it for an error the user has to act on.

### `appmintNative.ActionSheet`

**Example**

Documented together with `appmintNative.actionSheet` — the example there shows this one too.

Shows a "choose one of these" bottom sheet — the native control for a row's actions menu.

**Returns:** `Promise<number | null>` — the index of the chosen option, or `null` when the user dismissed the sheet (backdrop tap, Escape, the system cancel). Dismissal is normal, not an error. **Needs:** nothing.

```ts
import { actionSheet, haptic } from '@/lib/appmintNative';

export async function onNoteMenu(note: { id: string }, api: {
  edit(id: string): void; share(id: string): void; remove(id: string): void;
}) {
  const i = await actionSheet('Note', [
    { title: 'Edit' },
    { title: 'Share' },
    { title: 'Delete', destructive: true },
  ]);
  if (i === null) return;                 // dismissed
  if (i === 0) api.edit(note.id);
  if (i === 1) api.share(note.id);
  if (i === 2) { haptic('warning'); api.remove(note.id); }
}
```

Open it from a visible button, and from a long press on the row:

```ts
import { useEffect, useRef } from 'react';
import { actionSheet, longPress } from '@/lib/appmintNative';

export function Row({ label }: { label: string }) {
  const ref = useRef<HTMLLIElement>(null);
  const openMenu = () => actionSheet(label, [{ title: 'Rename' }, { title: 'Delete', destructive: true }]);
  useEffect(() => (ref.current ? longPress(ref.current, () => void openMenu()) : undefined), []);
  return <li ref={ref}>{label} <button aria-label="More actions" onClick={() => void openMenu()}>⋯</button></li>;
}
```

The Capacitor-shaped form `ActionSheet.showActions({ title?, options })` takes the same options and resolves `{ index }`, with `-1` for a dismissal:

```ts
import { ActionSheet } from '@/lib/appmintNative';

const { index } = await ActionSheet.showActions({
  title: 'Photo',
  options: [{ title: 'Save' }, { title: 'Delete', destructive: true }],
});
if (index === 1) console.log('delete');   // -1 = dismissed; never test with `if (index)`
```

**Notes:**
- In the Android app the Capacitor ActionSheet plugin draws the system sheet; `destructive` uses its destructive style.
- Elsewhere it is a real sheet in the app's palette (`--surface`, `--ink`, `--danger`), with backdrop, Escape, focus moved into the sheet and a selection haptic.
- An empty `options` list resolves `null` at once. For any content other than a list of actions, use `sheet()`.

### `appmintNative.Dialog`

**Example**

Real system dialogs: a message, a yes/no question, a one-line text question. Android uses the Capacitor Dialog plugin; iOS and the web use the browser's own `alert` / `confirm` / `prompt`.

**Returns:** `alert({ title?, message })` → `Promise<void>`. `confirm({ title?, message, okButtonTitle?, cancelButtonTitle? })` → `Promise<{ value: boolean }>`. `prompt({ title?, message, inputPlaceholder?, inputText? })` → `Promise<{ value: string; cancelled: boolean }>`. None of them reject. **Needs:** nothing.

```ts
import { Dialog } from '@/lib/appmintNative';

async function deleteList(id: string) {
  const { value } = await Dialog.confirm({
    title: 'Delete list?',
    message: 'All items in it will be removed.',
    okButtonTitle: 'Delete',
    cancelButtonTitle: 'Keep',
  });
  if (value) await removeList(id);
}
```

**Alert and prompt:**

```ts
import { Dialog } from '@/lib/appmintNative';

await Dialog.alert({ title: 'Offline', message: 'Your changes will sync when you are back online.' });

const r = await Dialog.prompt({ title: 'Rename', message: 'New name', inputText: current, inputPlaceholder: 'List name' });
if (!r.cancelled && r.value.trim()) rename(r.value.trim());
```

**Confirm before signing out.** Dismissing the dialog counts as "no".

```ts
import { Dialog, toast } from '@/lib/appmintNative';

export async function signOut(doSignOut: () => Promise<void>) {
  const { value } = await Dialog.confirm({
    title: 'Sign out?',
    message: 'You will need to sign in again.',
    okButtonTitle: 'Sign out',
    cancelButtonTitle: 'Stay',
  });
  if (!value) return;
  await doSignOut();
  toast('Signed out');
}
```

**Notes:** Dismissing a confirm gives `{ value: false }`; dismissing a prompt gives `{ value: '', cancelled: true }`. Dialogs block the screen — use them for real decisions, and `toast()` for "Saved"-style messages. `prompt` does not pass custom button titles to the plugin.

### `appmintNative.Keyboard`

**Example**

The software keyboard in the shape of Capacitor's Keyboard plugin: close it, and hear when the space it covers changes.

**Returns:** `hide()` → `Promise<void>`. `addListener(event, handler)` → `{ remove(): void }` (returned at once, not a Promise). `event` is `'keyboardWillShow'`, `'keyboardDidShow'`, `'keyboardWillHide'` or `'keyboardDidHide'`; the handler gets `{ keyboardHeight }`. **Needs:** nothing.

**Close the keyboard** when a form is sent or a sheet opens over the field:

```ts
import { Keyboard } from '@/lib/appmintNative';

async function onSubmit(e: React.FormEvent) {
  e.preventDefault();
  await Keyboard.hide();
  await save();
}
```

**Listen** (remove the handle on unmount):

```ts
import { Keyboard } from '@/lib/appmintNative';
import { useEffect } from 'react';

useEffect(() => {
  const h = Keyboard.addListener('keyboardDidShow', ({ keyboardHeight }) => {
    composer.style.paddingBottom = `${keyboardHeight}px`;
  });
  const g = Keyboard.addListener('keyboardDidHide', () => { composer.style.paddingBottom = '0px'; });
  return () => { h.remove(); g.remove(); };
}, []);
```

**Combine both** to scroll the focused field into view when the keyboard opens, then close the keyboard on Enter:

```ts
import { Keyboard } from '@/lib/appmintNative';
import { useEffect } from 'react';

export function useKeyboardScroll() {
  useEffect(() => {
    const h = Keyboard.addListener('keyboardDidShow', () => {
      document.activeElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
    });
    const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter') void Keyboard.hide(); };
    window.addEventListener('keydown', onKey);
    return () => { h.remove(); window.removeEventListener('keydown', onKey); };
  }, []);
}
```

**Notes:** `keyboardHeight` is the part of the keyboard the page still has to clear, not the raw height: in the Android app the shell resizes the page for the keyboard, so it is `0` there while the keyboard is open (the show event still fires, and fires again when the number changes). The hide events fire only when the keyboard really closes, so they can track "is the keyboard open". "Will" and "did" events fire together. For padding alone, `onKeyboard((px) => …)` is simpler.

### `appmintNative.ScreenOrientation`

**Example**

Pins the screen to portrait or landscape for one view, in the shape of Capacitor's ScreenOrientation plugin — the same code as `lockOrientation()`.

**Returns:** `Promise<void>` for both. They never reject; use `lockOrientation()` when you need to know whether the lock took. **Needs:** nothing.

```ts
import { ScreenOrientation } from '@/lib/appmintNative';
import { useEffect } from 'react';

function GameBoard() {
  useEffect(() => {
    void ScreenOrientation.lock({ orientation: 'landscape' });
    return () => { void ScreenOrientation.unlock(); };   // release when leaving
  }, []);
  return <canvas id="board" />;
}
```

Lock for one screen only when a lock is allowed, and let the user rotate back:

```ts
import { useEffect, useState } from 'react';
import { ScreenOrientation } from '@/lib/appmintNative';

export function ChartView() {
  const [locked, setLocked] = useState(true);
  useEffect(() => {
    if (locked) void ScreenOrientation.lock({ orientation: 'landscape-primary' });   // any 'landscape*' value
    else void ScreenOrientation.unlock();
    return () => { void ScreenOrientation.unlock(); };
  }, [locked]);
  return <button onClick={() => setLocked((v) => !v)}>{locked ? 'Free rotation' : 'Lock landscape'}</button>;
}
```

When you need to know whether the lock took, use `lockOrientation()` directly (it answers `false` where locking is refused, such as desktop browsers):

```ts
import { lockOrientation, toast } from '@/lib/appmintNative';

export async function openSignaturePad() {
  const ok = await lockOrientation('landscape');
  if (!ok) toast('Turn your phone sideways for more room');
}
```

**Notes:** Any `orientation` starting with `landscape` locks to landscape; anything else locks to portrait (`'landscape-secondary'` becomes plain landscape). Desktop browsers refuse locks; phone browsers usually allow them only in fullscreen. `lockOrientation(to)` returns `true`/`false`. Give the user a way out of a locked view.

### `appmintNative.Network`

**Example**

Online/offline state in the shape of Capacitor's Network plugin — the same code as `networkStatus()` / `onNetworkChange()`.

**Returns:** `getStatus()` → `Promise<{ connected: boolean; connectionType: string }>`. `addListener(event, handler)` → `{ remove(): void }` at once; the handler gets the same shape on every change. **Needs:** nothing.

```ts
import { Network } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

function useOnline() {
  const [online, setOnline] = useState(true);
  useEffect(() => {
    void Network.getStatus().then((s) => setOnline(s.connected));
    const h = Network.addListener('networkStatusChange', (s) => setOnline(s.connected));
    return () => h.remove();
  }, []);
  return online;
}
```

Read the current type once and show it in a status chip.

```ts
import { Network } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

export function ConnectionChip() {
  const [label, setLabel] = useState('Checking...');
  useEffect(() => {
    void Network.getStatus().then((s) =>
      setLabel(s.connected ? (s.connectionType === 'cellular' ? 'Mobile data' : 'Wi-Fi') : 'Offline'));
  }, []);
  return <span className="chip">{label}</span>;
}
```

Hold back a large download on mobile data, and answer the offline case too.

```ts
import { Network, toast } from '@/lib/appmintNative';

export async function startBigDownload(download: () => Promise<void>) {
  const s = await Network.getStatus();
  if (s.connectionType === 'none' || !s.connected) { toast('You are offline.'); return; }
  if (s.connectionType === 'cellular' && !window.confirm('Download over mobile data?')) return;
  await download();
}
```

**Notes:** The event name is ignored — every listener gets connectivity changes. `connectionType` is `'wifi'`, `'cellular'`, `'none'` or `'unknown'`: in the app `getStatus()` and the listener use the Network plugin, on the web `navigator.onLine` / `navigator.connection.type`. `connected` means the phone has a network, not that your server answers.

### `appmintNative.StatusBar`

**Example**

The status bar in the shape of Capacitor's StatusBar plugin, built on `setStatusBar()`.

**Returns:** `Promise<void>` for both. **Needs:** nothing.

```ts
import { StatusBar } from '@/lib/appmintNative';

// Colour only — the icon style is left alone (the shell picks readable icons on Android).
await StatusBar.setBackgroundColor({ color: '#0B0F1A' });
```

**Colour and icons together** — prefer `setStatusBar()`, which sets both in one call:

```ts
import { setStatusBar } from '@/lib/appmintNative';

setStatusBar('#0B0F1A', 'light');   // dark bar, light icons
setStatusBar('#FFFFFF', 'dark');    // light bar, dark icons
```

Switch the bar with the app's own light and dark theme, using the Capacitor-shaped calls together.

```ts
import { useEffect } from 'react';
import { StatusBar } from '@/lib/appmintNative';

export function useStatusBarForTheme(dark: boolean) {
  useEffect(() => {
    void StatusBar.setBackgroundColor({ color: dark ? '#0B0F1A' : '#FFFFFF' });
    void StatusBar.setStyle({ style: dark ? 'DARK' : 'LIGHT' });   // 'DARK' = dark bar, light icons
  }, [dark]);
}
```

**Notes:** `style` follows Capacitor: `'DARK'` means a dark background (light icons), anything else means dark icons. In the Android app the shell bridge paints the bar (the plugin's colour call does nothing on Android 15+), and `setStyle()` flips the icons on the bar's current colour. On the web the colour becomes `<meta name="theme-color">`.

### `appmintNative.updates`

**Example**

The page-side updater for web-bundle updates. Page code does not need it: Remote Update is done natively by the app on both stores, and `updates.supported()` returns `false` everywhere.

**Returns:** `supported()` → `boolean` (currently always `false`); `download(url, sha256, version, binaryVersion)` → `Promise<boolean>` (`false` while unsupported); `stage(dir, version, binaryVersion)` → `Promise<boolean>`; `confirm()` → `void`; `revertIfUnconfirmed(binaryVersion)` → `Promise<boolean>`; `current()` → `{ path, version, binaryVersion } | null`. **Needs:** nothing for your app — updates are published from Remote Update in Appwright.

The only safe use in app code is to check, and do nothing when it is off:

```ts
import { updates } from '@/lib/appmintNative';

if (updates.supported()) {
  // Not reached today: the native updaters own the whole update lane.
  const staged = await updates.download(
    'https://example.com/bundle-1.2.0.zip',
    'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',  // sha256, required
    '1.2.0',
    '36',
  );
  console.log('update staged:', staged);
}
```

Show which bundle is running (a settings "About" row):

```ts
import { updates } from '@/lib/appmintNative';

const active = updates.current();          // null = the version the store installed
const label = active ? `Content ${active.version}` : 'Built-in content';
```

Show the running content version in a settings row, with the built-in label when the page has no staged bundle:

```ts
import { useMemo } from 'react';
import { updates } from '@/lib/appmintNative';

export function AboutRow() {
  const label = useMemo(() => {
    const c = updates.current();
    return c ? `Content ${c.version} (app ${c.binaryVersion})` : 'Built-in content';
  }, []);
  return <p>{label}</p>;
}
```

**Notes:**
- Android: the shell's Remote Update manager downloads, verifies and serves updates, and rolls back a bundle that fails to load. iOS: the scaffold's own updater does the same at every launch. A bundle staged from page code would be overridden on the next start, which is why `supported()` is `false`.
- Updates can change HTML, CSS and JavaScript only; a new native feature still needs a new app build.

### `appmintNative.remoteConfig`

**Example**

Shows the announcement strip and the maintenance screen the creator publishes from Remote Update, without a rebuild — on iOS and on the website.

**Returns:** `start(manifestUrl?)` → `Promise<void>` (fetch + draw; never rejects); `fetch(url)` → `Promise<RemoteConfig | null>` (never rejects; `null` = nothing applies); `apply(cfg)` → a teardown function; `manifestUrl()` → `string` (`''` when the build baked none); `handledHere()` → `boolean`; `dismissed(id)` → `boolean`; `dismiss(id)` → `void`. **Needs:** nothing — the manifest URL is written into the page when the app is exported.

One line at startup is enough:

```ts
import { useEffect } from 'react';
import { remoteConfig } from '@/lib/appmintNative';

export function App() {
  useEffect(() => { void remoteConfig.start(); }, []);   // does nothing on Android
  return <main>{/* … */}</main>;
}
```

Or read the values and draw your own UI:

```ts
import { remoteConfig, type RemoteConfig } from '@/lib/appmintNative';

export async function loadNotice(): Promise<RemoteConfig | null> {
  if (!remoteConfig.handledHere()) return null;         // Android draws its own
  const cfg = await remoteConfig.fetch(remoteConfig.manifestUrl());
  if (!cfg || !cfg.announcementText) return cfg;
  return remoteConfig.dismissed(cfg.announcementId) ? null : cfg;
}
// when the user closes your banner: remoteConfig.dismiss(cfg.announcementId)
```

Show the maintenance text yourself on the web, and leave the app to its own screen:

```ts
import { useEffect, useState } from 'react';
import { remoteConfig } from '@/lib/appmintNative';

export function useMaintenance(url: string): string | null {
  const [text, setText] = useState<string | null>(null);
  useEffect(() => {
    if (!remoteConfig.handledHere()) return;               // the Android app shows its own screen
    void remoteConfig.fetch(url).then((cfg) => {
      setText(cfg?.maintenanceMode ? cfg.maintenanceText : null);
    });
  }, [url]);
  return text;
}
```

**Notes:**
- `RemoteConfig` = `{ version, announcementText, announcementId, announcementImageUrl, announcementBgColor, announcementTextColor, maintenanceMode, maintenanceText }`.
- `handledHere()` is `false` on Android: the shell reads the same manifest natively and draws the announcement itself, so `start()` returns at once there and you never see two banners.
- `fetch()` returns `null` for a paused or reverted publish, a manifest aimed at other platforms, a newer manifest format, or any network error — the app simply runs without remote config.
- Maintenance mode wins: `apply()` then covers the whole app with the maintenance text. A dismissed announcement stays hidden until a new `announcementId` is published.

### `appmintNative.folder`

**Example**

A whole folder the user grants once and the app keeps across launches, with real file operations inside it (Android: the shell's folder bridge; iOS: the AppwrightFolders plugin).

**Returns:** every member is async — `connected()` → `boolean`, `label()` → `string`, `request()` → `boolean` (false on cancel), `disconnect()` → `void`, `list(path)` → `[{ name, uri, isDirectory, length, lastModified }]`, `readText(path)` → `string | null` (null = no such file), `writeText(path, text)` / `mkdir(path)` / `rename(path, newName)` / `remove(path, recursive)` → `boolean`, `stat(path)` → `{ name, size, mime, lastModified, uri, isDirectory, canWrite } | null`. **Needs:** an AI-built app turns the **Native Folder Access (SAF)** switch on by itself when the code uses `folder`; on a hand-made build tick it in Step 4 (Access).

Connect, show the folder, and disconnect:

```ts
import { can, folder } from '@/lib/appmintNative';

export async function setupFolderPanel(el: { name: HTMLElement; connect: HTMLButtonElement; disconnect: HTMLButtonElement }) {
  if (can('folderAccess') === 'none') { el.connect.hidden = true; return; }   // web build, or not shipped

  const refresh = async () => {
    const on = await folder.connected();
    el.name.textContent = on ? `Saving to: ${await folder.label()}` : 'No folder chosen';
    el.connect.hidden = on;
    el.disconnect.hidden = !on;
  };

  el.connect.onclick = async () => { await folder.request(); await refresh(); };   // from a tap only
  el.disconnect.onclick = async () => { await folder.disconnect(); await refresh(); };
  await refresh();
}
```

Read, write, list, rename and delete — every path is relative to the granted folder (`''` is its root):

```ts
import { folder } from '@/lib/appmintNative';

await folder.mkdir('notes');
const saved = await folder.writeText('notes/today.md', '# Today\n');   // parents created
const text = await folder.readText('notes/today.md');                  // null if missing
const entries = await folder.list('notes');
entries.forEach((e) => console.log(e.isDirectory ? 'dir ' : 'file', e.name, e.length));

const info = await folder.stat('notes/today.md');
if (info) console.log(info.size, 'bytes, writable:', info.canWrite);

await folder.rename('notes/today.md', 'journal.md');                   // a new NAME, not a path
await folder.remove('notes', true);                                    // recursive for a non-empty folder
```

Back up a note to the granted folder, and say so plainly when no folder is connected.

```ts
import { can, folder, toast } from '@/lib/appmintNative';

export async function backupNote(name: string, text: string) {
  if (can('folderAccess') === 'none') return;
  if (!(await folder.connected()) && !(await folder.request())) {
    toast('Choose a folder to save backups');
    return;
  }
  const ok = await folder.writeText(`backups/${name}.md`, text);
  toast(ok ? 'Backed up' : 'Could not write to the folder');
}
```

**Notes:** `can('folderAccess')` is `'none'` on the web — there is no persistent-folder equivalent in a phone browser, so do not render the feature there (use `pickFile()` / `saveFile()` for single files). Call `request()` only from a user tap. `..` and absolute paths are refused. The granted folder itself can never be removed. `stat()` works for files and sub-folders (a folder has `isDirectory: true` and `size: 0`); `stat('')` — the root — is `null`, use `label()` for it. `request()` can be called again after a cancel.

### `appmintNative.SecureKeyError`

**Example**

The error `secureKeys` throws. Its `code` is one short word you can branch on, the same on Android, iOS and the web.

**Returns:** an `Error` with `name === 'SecureKeyError'` and `code`: `'no-such-key'`, `'auth-required'` (unlock and retry), `'key-invalidated'` (biometrics or the screen lock changed — the data is gone), `'strongbox-unavailable'`, `'disabled'` (Secure Keys not in this build / no Web Crypto), `'bad-alias'`, `'bad-payload'` (not valid base64 or too short), `'too-large'` (over 4 MB in one call on Android), `'access-control'` (iOS could not set up the key's unlock rule), `'foreign-origin'` (a page from another site asked), `'failed'` (anything else). **Needs:** nothing.

```ts
import { secureKeys, SecureKeyError, unlock } from '@/lib/appmintNative';

export async function openSecret(sealed: string): Promise<string | null> {
  try {
    return await secureKeys.decrypt('vault', sealed);
  } catch (e) {
    if (!(e instanceof SecureKeyError)) throw e;
    switch (e.code) {
      case 'auth-required':
        if (await unlock('Open vault')) return secureKeys.decrypt('vault', sealed);
        return null;
      case 'no-such-key':
      case 'key-invalidated':
        alert('This secret can no longer be opened.');
        return null;
      case 'disabled':
        alert('Secure storage is not available in this app.');
        return null;
      default:
        alert('Could not open it: ' + e.code);
        return null;
    }
  }
}
```

Turn the code into a short message for the user in one place:

```ts
import { SecureKeyError } from '@/lib/appmintNative';

export function secureKeyMessage(e: unknown): string {
  if (!(e instanceof SecureKeyError)) return 'Something went wrong';
  switch (e.code) {
    case 'auth-required': return 'Unlock your phone to continue';
    case 'key-invalidated': return 'Your screen lock changed, so this data was erased';
    case 'too-large': return 'That is too big to protect in one piece';
    case 'foreign-origin': return 'This page is not allowed to use secure storage';
    default: return 'Secure storage failed (' + e.code + ')';
  }
}
```

Treat a tampered or wrong payload as a normal outcome and clear it, but keep a missing key as an error you show:

```ts
import { secureKeys, SecureKeyError } from '@/lib/appmintNative';

export async function readNote(sealed: string): Promise<string | null> {
  try {
    return await secureKeys.decrypt('notes', sealed);
  } catch (e) {
    if (e instanceof SecureKeyError && (e.code === 'bad-payload' || e.code === 'failed')) {
      localStorage.removeItem('note');   // the stored text cannot be read any more
      return null;
    }
    throw e;
  }
}
```

**Notes:** `e.message` is `code` or `"code: detail"`; branch on `e.code`, never on the message. The platforms' own error words are turned into these codes, and their original text is kept in the message. A changed or foreign payload is `'bad-payload'` on the web and `'failed'` on Android and iOS, which cannot tell it apart from other cipher errors.

### `appmintNative.secureKeys`

**Example**

Encryption keys the device holds and the app can never export: the Android Keystore, the iOS Secure Enclave, and a non-extractable Web Crypto key on the web. Removing the key is the one real "secure delete".

**Returns:** all Promises. `generate(alias, {requireAuth?, authValiditySeconds?})` → `{hardwareBacked}`; `encrypt(alias, text)` → base64 string; `decrypt(alias, base64)` → the text; `remove(alias)` / `has(alias)` / `isHardwareBacked(alias)` → boolean; `list()` → string[]. `generate`, `encrypt` and `decrypt` throw `SecureKeyError` with a `code`. **Needs:** Android: **Secure Keys (encryption)** turned on when you build.

```ts
import { secureKeys, SecureKeyError, can } from '@/lib/appmintNative';

export async function saveDiary(text: string) {
  if (can('secureKeys') === 'none') throw new Error('Secure storage is not available here');
  if (!(await secureKeys.has('diary'))) {
    const { hardwareBacked } = await secureKeys.generate('diary', { requireAuth: true, authValiditySeconds: 60 });
    console.log(hardwareBacked ? 'Key in secure hardware' : 'Key in software');
  }
  const sealed = await secureKeys.encrypt('diary', text);    // strings in, base64 out
  localStorage.setItem('diary', sealed);
}

export async function readDiary(): Promise<string | null> {
  const sealed = localStorage.getItem('diary');
  if (!sealed) return null;
  try {
    return await secureKeys.decrypt('diary', sealed);
  } catch (e) {
    if (e instanceof SecureKeyError && e.code === 'key-invalidated') return null;  // data is gone
    throw e;
  }
}
```

Delete for real — nobody can read the sealed data after this:

```ts
import { secureKeys } from '@/lib/appmintNative';

export async function wipeDiary() {
  await secureKeys.remove('diary');
  localStorage.removeItem('diary');
}
```

List the keys the app holds and show whether each one is protected by hardware (the web always answers `false`):

```ts
import { secureKeys, can } from '@/lib/appmintNative';

export async function describeKeys(): Promise<string[]> {
  if (can('secureKeys') === 'none') return [];
  const aliases = await secureKeys.list();
  return Promise.all(aliases.map(async (alias) =>
    `${alias}: ${(await secureKeys.isHardwareBacked(alias)) ? 'secure hardware' : 'software'}`));
}
```

**Notes:** `can('secureKeys')` is `'native'` in the app and `'web'` in a browser, where `isHardwareBacked()` is always `false` — never say "hardware" there. A `requireAuth` key works differently per platform: on Android `encrypt`/`decrypt` throw `auth-required` without showing anything once the unlock window has passed — call `unlock()` and try again (as above); on iOS `decrypt` shows Face ID / the passcode itself (a cancel is `auth-required`); on the web the runtime shows `unlock()` itself. Encrypt one record at a time (Android caps a call at 4 MB). Aliases: letters, digits, `_ - .`, max 64.

### `appmintNative.mediaSession`

**Example**

Lock-screen and notification playback controls for an app that plays audio or video: publish what is playing, receive the Play / Pause / Next / Previous / Stop buttons, and hand a video to a TV.

**Returns:** `setMetadata`, `setPlaybackState`, `clear` → `void`. `onAction(handler)` → an unsubscribe function. `cast(url)` → `Promise<boolean>` (`false` when nothing could be offered). **Needs:** Android: **Media Notification** in Step 4 (Access); AI builds switch it on for you when the code uses `mediaSession`.

```ts
import { useEffect, useRef } from 'react';
import { mediaSession } from '@/lib/appmintNative';

type Track = { url: string; title: string; artist: string; cover: string };

export function Player({ track, onNext, onPrev }: { track: Track; onNext: () => void; onPrev: () => void }) {
  const audio = useRef<HTMLAudioElement>(null);
  const unsubscribe = useRef<(() => void) | null>(null);

  const publish = () => {
    const a = audio.current;
    if (!a) return;
    mediaSession.setPlaybackState({
      playing: !a.paused,
      positionMs: a.currentTime * 1000,
      durationMs: (a.duration || 0) * 1000,
    });
  };

  const onPlay = () => {
    // Register the buttons when playback starts, not on mount (see Notes).
    if (!unsubscribe.current) {
      unsubscribe.current = mediaSession.onAction((action) => {
        const a = audio.current;
        if (!a) return;
        if (action === 'play') void a.play();
        if (action === 'pause') a.pause();
        if (action === 'next') onNext();
        if (action === 'prev') onPrev();
        if (action === 'stop') { a.pause(); mediaSession.clear(); }
      });
    }
    mediaSession.setMetadata({ title: track.title, artist: track.artist, artworkUrl: track.cover });
    publish();
  };

  useEffect(() => () => { unsubscribe.current?.(); mediaSession.clear(); }, []);

  return <audio ref={audio} src={track.url} controls onPlay={onPlay} onPause={publish} onSeeked={publish} />;
}
```

Send the current video to a TV:

```ts
import { can, mediaSession } from '@/lib/appmintNative';

export function CastButton({ url }: { url: string }) {
  if (can('cast') === 'none') return null;
  return <button onClick={async () => { if (!(await mediaSession.cast(url))) alert('No cast target found'); }}>Cast</button>;
}
```

Clear the lock-screen card when playback ends, and leave it alone when the platform has no media controls.

```ts
import { can, mediaSession } from '@/lib/appmintNative';

export function publishEnded(audio: HTMLAudioElement): () => void {
  if (can('mediaControls') === 'none') return () => {};
  const ended = () => {
    mediaSession.setPlaybackState({ playing: false, positionMs: 0, durationMs: audio.duration * 1000 || 0 });
    mediaSession.clear();
  };
  audio.addEventListener('ended', ended);
  return () => audio.removeEventListener('ended', ended);
}
```

**Notes:**
- On Android the buttons come through the app's `navigator.mediaSession`, which is installed when the page finishes loading. An `onAction` registered earlier (for example in a mount effect) is lost, so register it on the first play.
- The `seek` action (with `positionMs`) is delivered on iOS and the web only; the Android notification has no seek.
- `artworkUrl` must be a full `https://` address on the internet. On Android, `cast(url)` opens the "Cast to..." chooser (a hand-off; it resolves `true` once the chooser is asked for). iOS shows the AirPlay picker; the web uses the Remote Playback API.

### `appmintNative.bleSensors`

**Example**

Standard Bluetooth fitness sensors — heart rate, cycling speed/cadence, power, running speed/cadence and sensor battery — with the numbers already parsed, on Android and iPhone.

**Returns:** every method returns `Promise<void>` (`setWheelCircumference` returns `void`). Everything else arrives as `appmint:ble` window events with `detail` = `{kind:'device', address, name, sensors, rssi}` | `{kind:'state', state, address?, services?}` | `{kind:'data', type, data}` | `{kind:'error', error}`. **Needs:** Bluetooth, which the build turns on by itself when the code uses `bleSensors`. In a browser (web preview) there is no sensor bridge: `startScan()` answers with `{kind:'error', error:'not_enabled'}`, so handle that error. To know up front, test `can('bluetooth') === 'native'` — not `!== 'none'`: in Chrome `can('bluetooth')` is `'web'` (Web Bluetooth), which `bleSensors` does not use.

```ts
import { useEffect, useState } from 'react';
import { bleSensors } from '@/lib/appmintNative';

type BleDetail = {
  kind: 'device' | 'state' | 'data' | 'error';
  address?: string; name?: string | null; sensors?: string[]; rssi?: number;
  state?: 'scanning' | 'scan_stopped' | 'connecting' | 'connected' | 'ready' | 'disconnected';
  type?: 'heart_rate' | 'speed_cadence' | 'power' | 'running' | 'battery';
  data?: { bpm?: number; speedKmh?: number; cadenceRpm?: number; watts?: number; cadenceSpm?: number; percent?: number };
  error?: string;
};

export function SensorPanel() {
  const [devices, setDevices] = useState<BleDetail[]>([]);
  const [bpm, setBpm] = useState<number | null>(null);
  const [status, setStatus] = useState('');

  useEffect(() => {
    const onBle = (e: Event) => {
      const d = (e as CustomEvent<BleDetail>).detail;
      if (d.kind === 'device') setDevices((list) => [...list, d]);
      else if (d.kind === 'state') setStatus(d.state ?? '');
      else if (d.kind === 'data' && d.type === 'heart_rate') setBpm(d.data?.bpm ?? null);
      else if (d.kind === 'error') {
        setStatus(d.error === 'not_enabled' ? 'Sensors work in the installed app.' : `Bluetooth: ${d.error}`);
      }
    };
    window.addEventListener('appmint:ble', onBle);
    return () => { window.removeEventListener('appmint:ble', onBle); void bleSensors.disconnect(); };
  }, []);

  return (
    <div>
      <button onClick={() => { setDevices([]); bleSensors.setWheelCircumference(2105); void bleSensors.startScan(); }}>
        Find sensors
      </button>
      {devices.map((d) => (
        <button key={d.address} onClick={() => void bleSensors.connect(d.address!)}>
          {d.name ?? 'Sensor'} ({d.sensors?.join(', ')})
        </button>
      ))}
      <p>{status}</p>
      {bpm !== null && <p>{bpm} bpm</p>}
    </div>
  );
}
```

**Gate the screen on the installed app.**

```ts
import { bleSensors, can } from '@/lib/appmintNative';

export function SensorsEntry() {
  // 'native' only: in Chrome can('bluetooth') is 'web', which bleSensors does not use.
  if (can('bluetooth') !== 'native') return <p>Heart-rate sensors work in the installed app.</p>;
  return <button onClick={() => void bleSensors.startScan()}>Find sensors</button>;
}
```

**Stop the scan on the first sensor and watch cycling speed.**

```ts
import { useEffect, useState } from 'react';
import { bleSensors } from '@/lib/appmintNative';

type BikeEvent = { kind: string; address?: string; type?: string; data?: { speedKmh?: number } };

export function BikeSpeed() {
  const [kmh, setKmh] = useState<number | null>(null);

  useEffect(() => {
    bleSensors.setWheelCircumference(2096);   // 700x23c
    const onBle = (e: Event) => {
      const d = (e as CustomEvent<BikeEvent>).detail;
      if (d.kind === 'device' && d.address) {
        void bleSensors.stopScan().then(() => bleSensors.connect(d.address!));
      } else if (d.kind === 'data' && d.type === 'speed_cadence') {
        setKmh(d.data?.speedKmh ?? null);
      }
    };
    window.addEventListener('appmint:ble', onBle);
    void bleSensors.startScan();
    return () => { window.removeEventListener('appmint:ble', onBle); void bleSensors.disconnect(); };
  }, []);

  return <p>{kmh === null ? 'Looking for a sensor...' : `${kmh.toFixed(1)} km/h`}</p>;
}
```

**Notes:** Scans last 15 seconds (`stopScan()` ends one early); one sensor is connected at a time. Error codes: `not_enabled`, `permission_denied`, `bluetooth_unavailable`, `bluetooth_off`, `scan_failed`, `invalid_address`, and on iPhone `connect_failed`. Speed needs two packets and the wheel size (default 2096 mm). On Android the same messages also go to `window.onAppMintBle`; on iPhone only the event fires, so listen to the event. For any other Bluetooth device use `navigator.bluetooth`.

### `appmintNative.splash`

**Example**

Keeps the launch splash on screen until the first real screen is ready, instead of showing "splash, then a spinner".

**Returns:** nothing (both are synchronous). **Needs:** nothing.

By default the splash hides by itself at the app's first paint — you need nothing. When the first screen needs data, hold the splash at module top and release it when that screen is on:

```ts
// main.tsx
import { splash } from '@/lib/appmintNative';

splash.hold();   // at import time, before the first render
```

```ts
// Home.tsx
import { useEffect, useState } from 'react';
import { splash } from '@/lib/appmintNative';

export function Home({ load }: { load: () => Promise<string[]> }) {
  const [items, setItems] = useState<string[] | null>(null);

  useEffect(() => {
    load()
      .then(setItems)
      .catch(() => setItems([]))
      .finally(() => splash.ready());     // ALWAYS release, also when loading failed
  }, [load]);

  if (!items) return null;
  return <ul>{items.map((t) => <li key={t}>{t}</li>)}</ul>;
}
```

Hold the splash while a signed-in session is restored, and release it in a `finally` so a failure cannot keep it up.

```ts
// main.tsx
import { splash } from '@/lib/appmintNative';
import { supabase } from '@/lib/supabase';

splash.hold();
supabase.auth.getSession().finally(() => splash.ready());
```

Release it once, from a hook, after the first screen's data is on screen.

```ts
import { useEffect } from 'react';
import { splash } from '@/lib/appmintNative';

export function useReleaseSplash(loaded: boolean) {
  useEffect(() => {
    if (loaded) splash.ready();   // calling it again later does nothing harmful
  }, [loaded]);
}
```

**Notes:**
- Android: `hold()` tells the shell to keep its splash; `ready()` hides it (the creator's minimum splash time still applies).
- iOS: `ready()` hides the SplashScreen plugin's launch screen.
- Web: both do nothing — there is no splash.
- Both platforms cap the hold at about 6 seconds, so a page that never calls `ready()` still opens.

### `appmintNative.safeArea`

**Example**

Gives the space taken by the status bar, notch and home indicator, in CSS pixels, so fixed bars do not sit under them.

**Returns:** `{ top, right, bottom, left }` numbers, synchronously. **Needs:** nothing.

Most of the time CSS is enough — the runtime puts `--safe-top`, `--safe-right`, `--safe-bottom`, `--safe-left` on `:root`:

```ts
export function BottomNav() {
  return (
    <nav className="fixed inset-x-0 bottom-0" style={{ paddingBottom: 'var(--safe-bottom)' }}>
      {/* tabs */}
    </nav>
  );
}
```

Use the numbers when code must do the maths, for example to place a floating button:

```ts
import { useEffect, useState } from 'react';
import { safeArea } from '@/lib/appmintNative';

export function useBottomInset(): number {
  const [bottom, setBottom] = useState(0);
  useEffect(() => {
    const read = () => setBottom(safeArea().bottom);
    read();
    window.addEventListener('resize', read);        // rotation changes the insets
    return () => window.removeEventListener('resize', read);
  }, []);
  return bottom;
}
```

Keep a scrolling list clear of the bars by sizing its padding from the numbers:

```ts
import { useEffect, useState, type ReactNode } from 'react';
import { safeArea } from '@/lib/appmintNative';

export function PaddedList({ children }: { children: ReactNode }) {
  const [inset, setInset] = useState({ top: 0, bottom: 0 });
  useEffect(() => {
    const read = () => { const s = safeArea(); setInset({ top: s.top, bottom: s.bottom }); };
    read();
    window.addEventListener('resize', read);
    return () => window.removeEventListener('resize', read);
  }, []);
  return <div style={{ paddingTop: inset.top + 8, paddingBottom: inset.bottom + 8 }}>{children}</div>;
}
```

**Notes:**
- Measured from CSS `env(safe-area-inset-*)`. The value is cached and re-measured after a window resize or rotation.
- Before `document.body` exists (very early at import time) it returns all zeros — call it from an effect.
- On a desktop browser all four are usually `0`.

### `appmintNative.onPullToRefresh`

**Example**

Adds the pull-down-to-refresh gesture to a scrolling list: pull down at the top, release, and your data reloads.

**Returns:** an unsubscribe function — call it on unmount. **Needs:** nothing.

`el` is the element that scrolls (or `document.documentElement` when the whole page scrolls). The spinner stays until the promise from `onRefresh` settles:

```ts
import { useEffect, useRef } from 'react';
import { onPullToRefresh, toast } from '@/lib/appmintNative';

export function Inbox({ reload }: { reload: () => Promise<void> }) {
  const listRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!listRef.current) return;
    return onPullToRefresh(listRef.current, async () => {
      try { await reload(); }
      catch { toast('Could not refresh — check your connection'); }
    });
  }, [reload]);

  return <div ref={listRef} className="h-full overflow-y-auto">{/* rows */}</div>;
}
```

A page that scrolls as a whole, with a longer pull:

```ts
import { onPullToRefresh } from '@/lib/appmintNative';

declare function reloadFeed(): Promise<void>;
const off = onPullToRefresh(document.documentElement, reloadFeed, { threshold: 96 });
```

Refresh a list through a data hook, with a haptic tick when the data has arrived:

```ts
import { useEffect, useRef } from 'react';
import { onPullToRefresh, haptic } from '@/lib/appmintNative';

export function useListRefresh(refetch: () => Promise<unknown>) {
  const ref = useRef<HTMLUListElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    return onPullToRefresh(el, async () => {
      await refetch();
      haptic('success');                     // the spinner stays until this promise settles
    }, { threshold: 80 });
  }, [refetch]);
  return ref;                                // <ul ref={ref} className="h-full overflow-y-auto">
}
```

**Notes:**
- It starts only when the list is scrolled to the very top. The default `threshold` is 72 px of pull.
- It draws a round indicator in the app's colours (`--surface`, `--accent`), fires a medium haptic at the commit point, and ignores new pulls while a refresh runs.
- It refreshes your DATA, not the page. The shell's own page-reload pull stays off in AI-built apps.
- Built on Pointer Events, so it is the same on every surface. It sets `overscroll-behavior-y: contain` on `el` and restores it on unsubscribe.

### `appmintNative.transition`

**Example**

Runs a screen change with a native-feeling animation: `push` slides the new screen in from the right, `pop` slides back, `fade` cross-fades.

**Returns:** `Promise<void>` that settles after the change and its animation. `update` is your state change (a router `navigate`, a `setState`) and is always applied. **Needs:** nothing.

```ts
import { useNavigate } from 'react-router-dom';
import { transition, haptic } from '@/lib/appmintNative';

export function NoteRow({ id, title }: { id: string; title: string }) {
  const navigate = useNavigate();
  return (
    <button
      onClick={() => { haptic('light'); void transition(() => navigate(`/note/${id}`)); }}   // push
    >
      {title}
    </button>
  );
}

export function BackButton() {
  const navigate = useNavigate();
  return <button onClick={() => void transition(() => navigate(-1), { type: 'pop' })}>Back</button>;
}
```

Fade between two tabs:

```ts
import { transition } from '@/lib/appmintNative';

declare function setTab(t: 'list' | 'map'): void;
await transition(() => setTab('map'), { type: 'fade' });
```

Use a fade for a tab change, in one handler per tab:

```ts
import { transition } from '@/lib/appmintNative';

export function Tabs({ tab, setTab }: { tab: 'home' | 'stats'; setTab: (t: 'home' | 'stats') => void }) {
  return (
    <nav>
      <button onClick={() => void transition(() => setTab('home'), { type: 'fade' })} aria-current={tab === 'home'}>Home</button>
      <button onClick={() => void transition(() => setTab('stats'), { type: 'fade' })} aria-current={tab === 'stats'}>Stats</button>
    </nav>
  );
}
```

**Notes:**
- It uses the View Transitions API when the WebView has it. Otherwise it applies the update and plays a short enter animation on `#root` (or the first child of `<body>`).
- When the user asked the system for reduced motion, it only applies the update — no animation.
- Animations are about 240 ms.

### `appmintNative.longPress`

**Example**

Calls you when the user holds a finger on an element — for a row's context menu, reactions, or card actions.

**Returns:** an unsubscribe function — call it on unmount. **Needs:** nothing.

**Options:** `ms` — hold time, default `450`; `haptics` — medium haptic when it fires, default `true`.

Open an action sheet on long press, with a visible button for the same menu:

```ts
import { useEffect, useRef } from 'react';
import { actionSheet, longPress } from '@/lib/appmintNative';

export function PhotoTile({ src, onDelete }: { src: string; onDelete(): void }) {
  const ref = useRef<HTMLDivElement>(null);
  const openMenu = async () => {
    const i = await actionSheet('Photo', [{ title: 'Delete', destructive: true }]);
    if (i === 0) onDelete();
  };

  useEffect(() => {
    if (!ref.current) return;
    return longPress(ref.current, () => void openMenu());
  }, []);

  return (
    <div ref={ref} className="relative">
      <img src={src} alt="" />
      <button aria-label="Photo actions" className="absolute right-1 top-1" onClick={() => void openMenu()}>⋯</button>
    </div>
  );
}
```

A slower hold, without the haptic:

```ts
import { longPress } from '@/lib/appmintNative';

declare const card: HTMLElement;
const off = longPress(card, (e) => console.log('held at', e.clientX, e.clientY), { ms: 700, haptics: false });
```

Pair it with `haptic` for a custom confirmation, and clean up for each list row.

```ts
import { useEffect, useRef } from 'react';
import { haptic, longPress } from '@/lib/appmintNative';

export function SelectableRow({ id, onSelect, children }: { id: string; onSelect(id: string): void; children: React.ReactNode }) {
  const ref = useRef<HTMLLIElement>(null);
  useEffect(() => {
    if (!ref.current) return;
    return longPress(ref.current, () => { haptic('heavy'); onSelect(id); }, { haptics: false });
  }, [id, onSelect]);
  return <li ref={ref}>{children}</li>;
}
```

**Notes:**
- It fires once per hold and swallows the click that would follow, so a long press does not also "tap".
- Moving more than 8 px cancels it, so scrolling never triggers it. It also blocks the browser's own long-press menu on that element.
- A long press is invisible. Always keep a visible button for the same action.

### `appmintNative.sheet`

**Example**

Opens a modal bottom sheet with real sheet behaviour — for a filter panel, a composer or a detail preview.

**Returns:** a handle: `close()` closes it, `element` is the body element (fill or update it), `closed` is a `Promise<void>` that resolves when the sheet has closed, however it closed. **Needs:** nothing.

**Options:** `title` (optional heading), `content` — an element, plain text (not HTML), or a function `(host) => { … }` that fills the body; `dismissible` (default `true`: backdrop tap, drag down, Escape and the back gesture close it).

Plain DOM content:

```ts
import { sheet } from '@/lib/appmintNative';

const h = sheet({
  title: 'About this list',
  content: (host) => {
    const p = document.createElement('p');
    p.textContent = 'Items sync to all your devices.';
    host.appendChild(p);
  },
});
await h.closed;
```

React content, rendered into the sheet with a portal:

```ts
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { sheet } from '@/lib/appmintNative';

export function FiltersButton() {
  const [host, setHost] = useState<HTMLElement | null>(null);
  const [close, setClose] = useState<() => void>(() => () => {});

  const open = () => {
    const h = sheet({ title: 'Filters', content: () => {} });
    setHost(h.element);
    setClose(() => h.close);
    void h.closed.then(() => setHost(null));      // unmount the portal after any close
  };

  return (
    <>
      <button onClick={open}>Filters</button>
      {host && createPortal(
        <form onSubmit={(e) => { e.preventDefault(); close(); }}>
          <label><input type="checkbox" name="open" /> Open now</label>
          <button type="submit">Apply</button>
        </form>,
        host,
      )}
    </>
  );
}
```

A sheet that stays open until the user chooses, using `dismissible: false` and the `closed` promise.

```ts
import { sheet } from '@/lib/appmintNative';

export async function confirmDelete(name: string): Promise<boolean> {
  let answer = false;
  const h = sheet({
    title: 'Delete this item?',
    dismissible: false,                       // only the buttons below close it
    content: (host) => {
      const p = document.createElement('p');
      p.textContent = `"${name}" will be removed.`;
      const yes = document.createElement('button');
      yes.textContent = 'Delete';
      yes.onclick = () => { answer = true; h.close(); };
      const no = document.createElement('button');
      no.textContent = 'Keep it';
      no.onclick = () => h.close();
      host.append(p, yes, no);
    },
  });
  await h.closed;                             // resolves however the sheet closed
  return answer;
}
```

**Notes:**
- Drawn in the app's tokens (`--surface`, `--ink`, `--muted`, `--border`), above the safe area, with a light haptic on open.
- Drag down closes it when dragged past 120 px or flicked; the body scrolls first when it is not at the top.
- With `dismissible: false` only your own `close()` closes it — give the user a button.
- For a plain list of actions use `actionSheet()`.

### `appmintNative.offlineBanner`

**Example**

Mounts a ready-made strip that slides in under the status bar when the connection drops, and briefly says "Back online" when it returns.

**Returns:** an unsubscribe function that removes the banner and stops listening. **Needs:** nothing.

Mount it once at app start:

```ts
import { useEffect } from 'react';
import { offlineBanner } from '@/lib/appmintNative';

export function App() {
  useEffect(() => offlineBanner(), []);          // cleanup removes it
  return <main>{/* … */}</main>;
}
```

Your own words (for example in the app's language):

```ts
import { offlineBanner } from '@/lib/appmintNative';

const off = offlineBanner({ message: 'Sin conexión', onlineMessage: 'Conectado de nuevo' });
```

Mount the banner only for the signed-in area of the app, and remove it on sign-out.

```ts
import { useEffect } from 'react';
import { offlineBanner } from '@/lib/appmintNative';

export function useOfflineBanner(signedIn: boolean) {
  useEffect(() => {
    if (!signedIn) return;
    return offlineBanner({ message: 'Offline: changes will sync later' });
  }, [signedIn]);
}
```

**Notes:**
- Defaults: "You are offline" and "Back online". The online message hides after about 1.8 seconds; the offline one stays until the connection returns.
- If the device is already offline when it mounts, the banner shows at once.
- Built on `onNetworkChange()`, so it is real on every surface. Colours come from the app's tokens (`--ink` / `--bg` offline, `--accent` / `--accent-ink` online). It has `role="status"` for screen readers.
- Mount it once. Two calls draw two banners.

### `appmintNative.collapsingHeader`

**Example**

Makes a large-title header react to scrolling the way native ones do, by publishing a 0 → 1 value your CSS can use.

**Returns:** an unsubscribe function that also removes the variable and the attribute. **Needs:** nothing.

It sets `--collapse` (from `0` at the top to `1` after `range` px of scroll) on the header, and adds `data-collapsed` once fully collapsed:

```ts
import { useEffect, useRef } from 'react';
import { collapsingHeader } from '@/lib/appmintNative';

export function NotesScreen() {
  const headerRef = useRef<HTMLElement>(null);
  const listRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!headerRef.current || !listRef.current) return;
    return collapsingHeader(headerRef.current, { scroller: listRef.current, range: 80 });
  }, []);

  return (
    <div className="flex h-full flex-col">
      <header ref={headerRef} className="app-header">Notes</header>
      <div ref={listRef} className="flex-1 overflow-y-auto">{/* rows */}</div>
    </div>
  );
}
```

```css
.app-header { font-size: calc(28px - 10px * var(--collapse, 0)); padding-top: var(--safe-top); }
.app-header[data-collapsed] { box-shadow: 0 1px 0 var(--border); }
```

Stop listening from plain script; this also removes `--collapse` and `data-collapsed`:

```ts
import { collapsingHeader } from '@/lib/appmintNative';

const header = document.querySelector<HTMLElement>('.app-header')!;
const stop = collapsingHeader(header);   // whole-page scroll, default 64 px range
addEventListener('pagehide', stop);
```

**Notes:**
- Default `scroller` is the window (a page that scrolls as a whole); default `range` is 64 px.
- The scroll listener is passive and updates once per animation frame, so it does not slow scrolling.

### `appmintNative.shortcuts`

**Example**

Adds shortcuts to the app icon's long-press menu (Android dynamic shortcuts, iOS Home Screen quick actions). A tap opens the app at that route.

**Returns:** `set()` returns `Promise<boolean>` — `true` when the shortcuts were handed to the system, `false` where there are none (the web). `clear()` returns `Promise<void>`. **Needs:** nothing.

Set them once at start, and route on the launch link:

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { can, shortcuts, launchUrl } from '@/lib/appmintNative';

export function useAppShortcuts() {
  const navigate = useNavigate();
  useEffect(() => {
    if (can('shortcuts') !== 'none') {
      void shortcuts.set([
        { id: 'new', title: 'New note', route: '/new' },
        { id: 'search', title: 'Search', route: '/search' },
      ]);
    }
    void launchUrl().then((u) => { if (u) navigate(new URL(u).pathname); });
  }, [navigate]);
}
```

Remove them (for example after sign-out):

```ts
import { shortcuts } from '@/lib/appmintNative';

await shortcuts.clear();
```

Open the matching screen when a shortcut is tapped while the app is already running, with `onDeepLink`.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { onDeepLink } from '@/lib/appmintNative';

export function useShortcutRouting() {
  const navigate = useNavigate();
  useEffect(() => onDeepLink((url) => navigate(new URL(url).pathname)), [navigate]);
}
```

Show shortcuts only for a signed-in user, and handle the web, where `set()` answers `false`.

```ts
import { useEffect } from 'react';
import { shortcuts } from '@/lib/appmintNative';

export function useAccountShortcuts(signedIn: boolean) {
  useEffect(() => {
    if (!signedIn) { void shortcuts.clear(); return; }
    void shortcuts.set([
      { id: 'inbox', title: 'Inbox', route: '/inbox' },
      { id: 'compose', title: 'Write a message', route: 'compose' },   // gets a leading "/"
    ]).then((handed) => { if (!handed) console.info('No launcher shortcuts on this platform'); });
  }, [signedIn]);
}
```

**Notes:**
- At most 4 items; extra items are dropped. A `route` without a leading `/` gets one. `id` is optional.
- Android 7.1+; on older Android the call does nothing. Titles are cut to 25 characters (short label) and 50 (long label).
- On Android a tap opens the app with the launch link `appmint-shortcut://<package><route>`, so `new URL(u).pathname` is your route. Read it with `launchUrl()` on mount and `onDeepLink()` while the app runs.
- `can('shortcuts')` is `'none'` on the web: a browser has no app icon.

### `appmintNative.launchUrl`

**Example**

Gives the link that launched or resumed the app — a deep link, an icon shortcut, a widget tap, a notification — so the app can open the right screen.

**Returns:** `Promise<string>` — the full URL, or `''` when the app was opened normally. Always `''` on the web, where the address bar already is the route. **Needs:** nothing for shortcuts and widget taps; outside links need the **Deep Linking** option when you build.

Route on it once when the app mounts:

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { launchUrl } from '@/lib/appmintNative';

export function useLaunchRoute() {
  const navigate = useNavigate();
  useEffect(() => {
    void launchUrl().then((u) => {
      if (!u) return;
      const { pathname, search } = new URL(u);
      navigate(pathname + search, { replace: true });
    });
  }, [navigate]);
}
```

Pair it with `onDeepLink()` for links that arrive while the app is already open:

```ts
import { onDeepLink } from '@/lib/appmintNative';

const off = onDeepLink((url) => console.log('opened with', url));
```

Treat the empty string as "opened normally" and pick the start screen from the link only when there is one. A shortcut such as `appmint-shortcut://<package>/orders` has the route in its path.

```ts
import { launchUrl, onDeepLink } from '@/lib/appmintNative';

export async function startRoute(): Promise<string> {
  const u = await launchUrl();
  if (!u) return '/';                         // normal start, or the web
  const { pathname, search } = new URL(u);
  return pathname + search;
}

// While the app stays open, later links arrive here.
export function watchLinks(go: (route: string) => void): () => void {
  return onDeepLink((u) => { const x = new URL(u); go(x.pathname + x.search); });
}
```

**Notes:**
- The value is KEPT, not consumed: a component that mounts late still sees it. A newer link replaces it.
- Android: read from the shell (`WebToApk.getLaunchUrl()`); never call that directly in an AI-built app. Shortcuts and widget taps arrive as `appmint-shortcut://<package><route>`. iOS: the Capacitor App plugin's launch URL.
- Parse with `new URL(u)` and navigate to its path; do not load the URL itself.

### `appmintNative.onDeepLink`

**Example**

Calls you when a link opens the app while it is already running — a deep link, an icon shortcut, a widget tap.

**Returns:** an unsubscribe function — call it on unmount. **Needs:** nothing for shortcuts and widget taps; outside links need the **Deep Linking** option when you build.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { launchUrl, onDeepLink } from '@/lib/appmintNative';

export function DeepLinkRouter() {
  const navigate = useNavigate();
  useEffect(() => {
    const go = (u: string) => { const { pathname, search } = new URL(u); navigate(pathname + search); };
    void launchUrl().then((u) => { if (u) go(u); });   // the link that started the app
    return onDeepLink(go);                             // links while it runs; returns the unsubscribe
  }, [navigate]);
  return null;
}
```

A hand-written page (no React) can listen to the same shell event directly:

```js
window.addEventListener('appmint:deep-link', function (e) {
  var url = e.detail && e.detail.url;
  if (url) console.log('deep link', url);
});
```

Open a specific product when a widget or shortcut is tapped, ignoring links that are not ours.

```ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { onDeepLink } from '@/lib/appmintNative';

export function ProductLinks() {
  const navigate = useNavigate();
  useEffect(() => onDeepLink((url) => {
    const m = new URL(url).pathname.match(/^\/product\/(\w+)$/);
    if (m) navigate(`/products/${m[1]}`);
  }), [navigate]);
  return null;
}
```

**Notes:**
- Android: listens to the shell's `appmint:deep-link` event (`detail.url`); the shell also calls `window.onAppMintDeepLink(detail)` when a page defines it. After a link arrives, `launchUrl()` returns it too.
- iOS: the Capacitor App plugin's `appUrlOpen` event.
- Web: never fires (the callback is not called) and the unsubscribe does nothing.

### `appmintNative.appLauncher`

**Example**

Opens another app by link — `whatsapp://send?text=…`, `tel:`, `mailto:`, `geo:`, or an https link the OS gives to its app — and asks first whether that can work.

**Returns:** `canOpen()` → `Promise<boolean>`. `open()` → `Promise<boolean>` (`false` when no app took the link). **Needs:** nothing.

```ts
import { appLauncher } from '@/lib/appmintNative';

async function chat(phone: string, text: string) {
  const url = `whatsapp://send?phone=${phone}&text=${encodeURIComponent(text)}`;
  if (!(await appLauncher.canOpen(url))) {
    showMessage('WhatsApp is not installed on this phone.');
    return;
  }
  await appLauncher.open(url);
}
```

**Call or email** (no check needed; a phone always has an app for these):

```ts
import { appLauncher } from '@/lib/appmintNative';

await appLauncher.open('tel:+15551234567');
await appLauncher.open('mailto:help@example.com?subject=Order%2042');
```

**Open the maps app** with a `geo:` link, and say so when nothing took it:

```ts
import { appLauncher } from '@/lib/appmintNative';

export async function showOnMap(lat: number, lng: number, label: string) {
  const ok = await appLauncher.open(`geo:${lat},${lng}?q=${lat},${lng}(${encodeURIComponent(label)})`);
  if (!ok) showMessage('No maps app could open this place.');
}
```

**Notes:** In the app it uses the Capacitor AppLauncher plugin. On Android 11+ `canOpen` can only see schemes the app declares (`https`, `geo`, `google.navigation`, `tel`, `mailto`, `sms`, `whatsapp`, `intent`, `upi`); for others it answers `false` even when the app is installed — call `open()` and check its result instead. On the web a custom scheme cannot be checked, so `canOpen` is `true` only for http(s), and `open` navigates to the link.

### `appmintNative.browser`

**Example**

Opens a page in an in-app browser tab (Chrome Custom Tabs on Android, SFSafariViewController on iOS) — for terms, help pages or a sign-in page — and a new tab on the web.

**Returns:** `open()` → `Promise<boolean>` (`false` when the tab could not open). `close()` → `Promise<void>`. `onClosed(cb)` → an unsubscribe function; `cb` runs when the user closes the tab (in the app only). **Needs:** nothing.

```ts
import { browser } from '@/lib/appmintNative';

async function openHelp() {
  const off = browser.onClosed(() => {
    off();
    void refreshAccount();       // the user may have changed something on the site
  });
  const ok = await browser.open('https://example.com/help', { toolbarColor: '#0B0F1A' });
  if (!ok) { off(); showMessage('Could not open the help page.'); }
}
```

**A terms link in a React component.**

```ts
import { browser, toast } from '@/lib/appmintNative';

export function TermsLink() {
  return (
    <button
      onClick={async () => {
        if (!(await browser.open('https://example.com/terms'))) toast('Could not open the terms page');
      }}
    >
      Terms of use
    </button>
  );
}
```

**Close the tab from code and refresh when the user comes back.**

```ts
import { useEffect } from 'react';
import { browser } from '@/lib/appmintNative';

export function usePaymentTab(onBack: () => void) {
  useEffect(() => browser.onClosed(onBack), [onBack]);   // the returned function unsubscribes
  return {
    start: (url: string) => browser.open(url),
    cancel: () => browser.close(),   // closes the tab in the app; does nothing on the web
  };
}
```

**Notes:** `toolbarColor` defaults to the app's `--bg` token. The tab is a separate browser — it does not share the app's cookies or `localStorage`. `close()` closes the tab from code in both apps; on the web it does nothing. `onClosed` never fires on the web.

### `appmintNative.camera`

**Example**

Takes a photo with the camera or picks photos from the gallery: the Capacitor Camera plugin in both installed apps, a file input with `capture` on the web.

**Returns:** `take()` → `Promise<Photo | null>` with `{ dataUrl, mime }`; `null` on cancel or refused permission. `pick()` → `Promise<Photo[]>`, empty on cancel. **Needs:** Android: **Camera** in Step 4 (Access) — the build turns it on when your code calls `camera.take`. iOS: the camera / photo usage texts are added for you.

**Take a profile photo with the front camera.**

```ts
import { camera } from '@/lib/appmintNative';

export async function takeSelfie(img: HTMLImageElement): Promise<boolean> {
  const photo = await camera.take({ source: 'camera', direction: 'front', quality: 70 });
  if (!photo) return false;                 // cancelled or permission refused
  img.src = photo.dataUrl;                  // "data:image/jpeg;base64,..."
  return true;
}
```

**Choose one photo from the gallery with `take`, or several with `pick`.**

```ts
import { camera } from '@/lib/appmintNative';

const one = await camera.take({ source: 'photos' });          // Photo | null
const many = await camera.pick({ limit: 5, quality: 80 });    // Photo[] (limit 0 = no limit)
console.log(one?.mime, many.length);
```

**Upload a photo** by turning the data URL into a Blob.

```ts
import { camera } from '@/lib/appmintNative';

const p = await camera.take();
if (p) {
  const blob = await (await fetch(p.dataUrl)).blob();
  const form = new FormData();
  form.append('file', blob, 'photo.jpg');
  await fetch('https://api.example.com/upload', { method: 'POST', body: form });
}
```

**Notes:** Options: `source` (`'camera'` default, or `'photos'`), `quality` 0–100 (default 80), `direction` (`'rear'` default, or `'front'`), `allowEditing`. Photos are not saved to the gallery. A refused permission resolves `null` / `[]`, never throws — show a short hint so the user knows why nothing happened. Camera is sensitive: declare it in your Play Data safety form.

### `appmintNative.Geolocation`

**Example**

The `@capacitor/geolocation` shape: the Capacitor Geolocation plugin in both installed apps, the standard `navigator.geolocation` on the web. All return the W3C position object.

**Returns:** `getCurrentPosition(opts)` → `Promise<GeolocationPosition>` (rejects on refusal, timeout or no location); `watchPosition(opts, cb)` → `Promise<string>` (the watch id); `clearWatch({ id })` → `Promise<void>`; `checkPermissions()` / `requestPermissions()` → `Promise<{ location: 'granted' | 'denied' | 'prompt' | … }>`. **Needs:** Android: **GPS** / **Location** in Step 4 (Access) — the build turns both on when your code calls `getCurrentPosition` or `watchPosition`. iOS: the location usage text is added for you.

**Where am I, once.**

```ts
import { Geolocation } from '@/lib/appmintNative';

export async function currentSpot(): Promise<string> {
  try {
    const perm = await Geolocation.checkPermissions();
    if (perm.location !== 'granted') {
      const asked = await Geolocation.requestPermissions();
      if (asked.location !== 'granted') return 'Location is off for this app.';
    }
    const pos = await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 15000 });
    return `${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)}`;
  } catch {
    return 'Could not get your location. Is location turned on?';
  }
}
```

**Track a run, then stop.** The callback gets `null` and an error when a fix fails.

```ts
import { Geolocation } from '@/lib/appmintNative';

let watchId: string | null = null;

export async function startRun(onPoint: (lat: number, lng: number) => void) {
  watchId = await Geolocation.watchPosition({ enableHighAccuracy: true }, (pos, err) => {
    if (!pos) { console.warn('location error', err); return; }
    onPoint(pos.coords.latitude, pos.coords.longitude);
  });
}

export async function stopRun() {
  if (watchId) { await Geolocation.clearWatch({ id: watchId }); watchId = null; }
}
```

**Get a one-shot position.** Returns `null` when the user refuses.

```ts
import { Geolocation } from '@/lib/appmintNative';

export async function nearestCoords(): Promise<{ lat: number; lng: number } | null> {
  try {
    const pos = await Geolocation.getCurrentPosition({ timeout: 10000 });
    return { lat: pos.coords.latitude, lng: pos.coords.longitude };
  } catch {
    return null;                          // refused, timed out or location is off
  }
}
```

**Notes:** Plain `navigator.geolocation` also works in the app; use this when you want the Capacitor shape. On Android 12+ the user may allow only approximate location — you still get a position, just less accurate. On the web `requestPermissions()` makes one real position request to trigger the browser prompt. Location is personal data: declare it in your Play Data safety form.

### `appmintNative.screenReader`

**Example**

Tells the app whether TalkBack (Android) or VoiceOver (iOS) is on, and announces short messages.

**Returns:** `isEnabled()` → `Promise<boolean>` (`false` on the web — no web API exists). `speak()` → `Promise<void>`. `onChange(cb)` → an unsubscribe function; `cb(enabled)` runs when the screen reader is switched on or off (in the app only). **Needs:** nothing.

```ts
import { screenReader } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';

function useScreenReader() {
  const [on, setOn] = useState(false);
  useEffect(() => {
    void screenReader.isEnabled().then(setOn);
    return screenReader.onChange(setOn);
  }, []);
  return on;   // e.g. skip auto-advancing carousels when true
}

async function onPaid() {
  await screenReader.speak('Payment complete', 'en');
}
```

Announce a result only to people using the screen reader, and leave visual toasts for everyone else:

```ts
import { screenReader, toast } from '@/lib/appmintNative';

export async function notifySaved() {
  if (await screenReader.isEnabled()) await screenReader.speak('Note saved', 'en');
  else toast('Note saved');
}
```

Remove an animation while TalkBack is on, and stop listening when the component goes away:

```ts
import { useEffect, useState } from 'react';
import { screenReader } from '@/lib/appmintNative';

export function Banner({ text }: { text: string }) {
  const [reduce, setReduce] = useState(false);
  useEffect(() => {
    let alive = true;
    void screenReader.isEnabled().then((v) => { if (alive) setReduce(v); });
    const off = screenReader.onChange(setReduce);   // returns an unsubscribe function
    return () => { alive = false; off(); };
  }, []);
  return <div className={reduce ? '' : 'animate-pulse'} role="status" aria-live="polite">{text}</div>;
}
```

**Notes:** In the app `speak` goes through the ScreenReader plugin; on the web (or if the plugin fails) it is spoken with text-to-speech. Prefer good markup (`aria-label`, `aria-live` regions) for most announcements; use `speak` for short, important changes.

### `appmintNative.textZoom`

**Example**

An in-app text-size control. In the app it changes the page's text zoom (`1` = 100%); on the web it changes the root font size, which scales every `rem`-based layout.

**Returns:** `get()` → `Promise<number>`. `getPreferred()` → `Promise<number>` — the size the user chose in the phone's settings (`1` on the web). `set(value)` → `Promise<void>`; the value is clamped to 0.5–3. **Needs:** nothing.

```ts
import { textZoom } from '@/lib/appmintNative';

async function bigger() { await textZoom.set((await textZoom.get()) + 0.1); }
async function smaller() { await textZoom.set((await textZoom.get()) - 0.1); }
async function reset() { await textZoom.set(await textZoom.getPreferred()); }
```

**Remember the choice** across launches:

```ts
import { textZoom } from '@/lib/appmintNative';

const saved = Number(localStorage.getItem('textZoom'));
if (saved) await textZoom.set(saved);

export async function setSize(v: number) {
  await textZoom.set(v);
  localStorage.setItem('textZoom', String(v));
}
```

A text-size stepper that reads the current value, keeps it inside the allowed range, and shows it in percent:

```ts
import { useEffect, useState } from 'react';
import { textZoom } from '@/lib/appmintNative';

export function TextSizeStepper() {
  const [z, setZ] = useState(1);
  useEffect(() => { textZoom.get().then(setZ); }, []);
  const change = async (d: number) => {
    const next = Math.min(3, Math.max(0.5, Math.round((z + d) * 10) / 10));
    await textZoom.set(next);
    setZ(next);
  };
  return (
    <div>
      <button onClick={() => change(-0.1)}>A-</button>
      <span>{Math.round(z * 100)}%</span>
      <button onClick={() => change(0.1)}>A+</button>
    </div>
  );
}
```

**Notes:** The app already follows the phone's font-size setting; this is an extra, in-app control. Test the layout at large sizes — fixed heights on text controls clip.

### `appmintNative.privacyScreen`

**Example**

Blocks screenshots, screen recording and the app-switcher preview while a screen with secrets is open.

**Returns:** `enable()` → `Promise<boolean>` (`false` where nothing can block it); `disable()` → `Promise<void>`; `isEnabled()` → `Promise<boolean>`. **Needs:** nothing. Android uses the shell's screenshot block; iOS uses the PrivacyScreen plugin (capture blocked, blurred in the switcher).

```ts
import { useEffect } from 'react';
import { privacyScreen } from '@/lib/appmintNative';

/** Use in the component that shows the secret (a card number, a statement). */
export function usePrivacyScreen() {
  useEffect(() => {
    void privacyScreen.enable();
    return () => { void privacyScreen.disable(); };   // e.g. a modal closed without a route change
  }, []);
}
```

Tell the user what is protected — only when it really is:

```ts
import { privacyScreen, can } from '@/lib/appmintNative';

export async function protectionLabel(): Promise<string> {
  if (can('privacyScreen') === 'none') return '';           // web: nothing can stop a screenshot
  return (await privacyScreen.isEnabled()) ? 'Screenshots are blocked on this screen' : '';
}
```

Turn protection on for one sensitive view and tell the user if it could not be applied:

```ts
import { privacyScreen, toast } from '@/lib/appmintNative';

export async function openStatement(show: () => void) {
  const protectedNow = await privacyScreen.enable();   // false where nothing can block capture
  if (!protectedNow) toast('Screenshots cannot be blocked here');
  show();
}

export async function closeStatement(hide: () => void) {
  hide();
  await privacyScreen.disable();
}
```

**Notes:** On Android the block belongs to the current page and is released when the app navigates to another route, so enable it again when that screen shows again. On the web `can('privacyScreen')` is `'none'` and `enable()` returns `false` — do not claim protection there. Nothing stops a camera pointed at the screen.

### `appmintNative.fileViewer`

**Example**

Opens a document (PDF, image, office file) in the phone's viewer app. On the web it opens the address in a new tab.

**Returns:** `Promise<boolean>` — `true` when the viewer was opened, `false` when it failed (no app for that type, bad path) or the target cannot be opened on this surface. **Needs:** nothing to switch on; it uses the Capacitor FileViewer plugin that ships in the app.

Open a document from the web:

```ts
import { fileViewer } from '@/lib/appmintNative';

async function openManual() {
  const ok = await fileViewer.open('https://example.com/manual.pdf');
  if (!ok) alert('No app on this phone can open this file.');
}
```

Open a file you downloaded first with `fileTransfer.download` (a local path in the app):

```ts
import { fileTransfer, fileViewer } from '@/lib/appmintNative';

const r = await fileTransfer.download('https://example.com/invoice.pdf', 'invoice.pdf');
if (r.path) await fileViewer.open(r.path);            // in the app
else if (r.blob) console.log('saved by the browser'); // on the web
```

Open a link from a button and tell the user when no viewer could open it.

```tsx
import { fileViewer, toast } from '@/lib/appmintNative';

export function ManualButton({ url }: { url: string }) {
  return (
    <button onClick={async () => {
      if (!(await fileViewer.open(url))) toast('No app can open this file');
    }}>
      View manual
    </button>
  );
}
```

**Notes:** An `http(s)` target uses `openDocumentFromUrl`; anything else is treated as a local path (`file://` is stripped). On the web only `http(s):`, `blob:` and `data:` targets open; a local path answers `false`.

### `appmintNative.fileTransfer`

**Example**

Downloads or uploads a file with a progress callback. In the app the transfer runs natively (Capacitor FileTransfer); on the web it uses `fetch` / `XMLHttpRequest` with the same callback.

**Returns:** `download(url, fileName, opts?)` → `Promise<{ path?: string; blob?: Blob }>` — `path` (a file in the app's cache) in the app, `blob` on the web (where the file is also saved through the browser). `upload(url, file, opts?)` → `Promise<{ responseCode: number; responseBody: string }>`. Both reject on failure. **Needs:** nothing to switch on.

Download with a progress bar:

```ts
import { fileTransfer, fileViewer } from '@/lib/appmintNative';

async function getReport(bar: HTMLProgressElement) {
  try {
    const r = await fileTransfer.download('https://example.com/report.pdf', 'report.pdf', {
      headers: { Authorization: `Bearer ${token}` },
      onProgress: (p) => { bar.value = p.fraction; },     // {bytes, total, fraction 0..1}
    });
    if (r.path) await fileViewer.open(r.path);
  } catch (e) {
    alert(`Download failed: ${String(e)}`);
  }
}
```

Upload a `File` from an `<input type="file">`:

```ts
import { fileTransfer } from '@/lib/appmintNative';

async function sendPhoto(file: File) {
  const r = await fileTransfer.upload('https://example.com/api/photos', file, {
    fieldName: 'photo',                // form field name, default 'file'
    headers: { Authorization: `Bearer ${token}` },
    onProgress: (p) => console.log(Math.round(p.fraction * 100) + '%'),
  });
  if (r.responseCode >= 200 && r.responseCode < 300) console.log('uploaded', r.responseBody);
  else alert(`Upload failed (${r.responseCode})`);
}
```

Download with a progress state, then open the file, in one hook.

```ts
import { useState } from 'react';
import { fileTransfer, fileViewer } from '@/lib/appmintNative';

export function useOpenDocument() {
  const [percent, setPercent] = useState(0);
  const [error, setError] = useState<string | null>(null);

  const run = async (url: string, name: string) => {
    setError(null);
    try {
      const r = await fileTransfer.download(url, name, { onProgress: (p) => setPercent(Math.round(p.fraction * 100)) });
      if (r.path) await fileViewer.open(r.path);        // in the app; on the web the browser saved it
    } catch (e) {
      setError(String(e));
    }
  };
  return { percent, error, run };
}
```

**Notes:** `fraction` is 0 while the server sends no length. In the app a download lands in the app's CACHE folder (not the user's Downloads); to give the user a copy, use `saveFile`. For an upload the app first copies the file into its cache, so very large files need that much free space. `method` (default `'POST'`) and `fileName` are optional upload options.

### `appmintNative.calendar`

**Example**

Adds, lists and deletes events in the user's OWN phone calendar (Android CalendarContract, iOS EventKit), and opens the calendar app at a date.

**Returns:** `requestPermission()` → `Promise<'granted' | 'denied'>`. `createEvent(input)` → `Promise<string | null>` (the event id; `null` when refused or failed). `listEvents({ from, to })` → `Promise<{ id, title, start, end, location?, notes?, allDay? }[]>` (times in epoch ms). `deleteEvent(id)` / `openCalendar(date?)` → `Promise<boolean>`. Nothing rejects. **Needs:** turn on **Calendar (read & write events)** in the permissions step when you build — the build ticks it for you when it sees `calendar.createEvent(`, `calendar.listEvents(` or `calendar.requestPermission(` in the code.

```ts
import { calendar } from '@/lib/appmintNative';

async function addBooking(b: { name: string; at: Date; minutes: number; place: string }) {
  if ((await calendar.requestPermission()) !== 'granted') {
    showMessage('Allow calendar access to add the booking.');
    return;
  }
  const id = await calendar.createEvent({
    title: `Booking: ${b.name}`,
    start: b.at,
    end: new Date(b.at.getTime() + b.minutes * 60_000),
    location: b.place,
    notes: 'Added by the app',
  });
  if (id) saveEventId(b, id);
}
```

**List this week, delete, open:**

```ts
import { calendar, can } from '@/lib/appmintNative';

if (can('calendarRead') !== 'none') {
  const week = await calendar.listEvents({ from: Date.now(), to: Date.now() + 7 * 86_400_000 });
  if (week[0]) await calendar.deleteEvent(week[0].id);
}
await calendar.openCalendar(new Date());
```

**Add to the calendar only when writing is possible.**

```ts
import { calendar, can, toast } from '@/lib/appmintNative';

export async function addReminder(title: string, when: Date) {
  if (can('calendarWrite') === 'none') { toast('Calendar is not available here'); return null; }
  const id = await calendar.createEvent({ title, start: when, end: new Date(when.getTime() + 30 * 60_000) });
  toast(id ? 'Added to your calendar' : 'Calendar access was refused');
  return id;
}
```

**Notes:** On the web `createEvent` downloads an `.ics` file the user opens in their calendar (returns its UID), `listEvents` returns `[]` (`can('calendarRead')` is `'none'`), and `deleteEvent` / `openCalendar` return `false`. Reminders and repeating events are available on `Capacitor.Plugins.Calendar`, not through this wrapper.

### `appmintNative.backgroundSync`

**Example**

Native HTTP requests that run while the app is closed: `enqueue` (a one-off, durable auto-save sent when online), `schedule` (a repeating request, at least every 15 minutes), `cancel` and `list`.

**Returns:** `enqueue` / `schedule` → `Promise<boolean>`; `cancel` → `Promise<void>`; `list` → `Promise<BackgroundJob[]>` (`{ id, url, periodic, createdAt?, pendingDelivery? }` — `pendingDelivery` on Android: a one-off not delivered yet). **Needs:** nothing on Android (WorkManager). iOS uses the Background Runner (best effort, the system chooses when). On the web `can('backgroundSync')` is `'none'`.

**Durable auto-save:** the same id replaces the save that has not been sent yet.

```ts
import { backgroundSync, can } from '@/lib/appmintNative';

export async function saveDraft(draft: object, token: string) {
  if (can('backgroundSync') === 'none') return false;   // web: save normally instead
  return backgroundSync.enqueue({
    id: 'draft',
    url: 'https://api.example.com/drafts',
    method: 'PUT',
    headers: { Authorization: `Bearer ${token}` },
    body: JSON.stringify(draft),
  });
}
```

**Repeating sync, list and cancel:**

```ts
await backgroundSync.schedule({
  id: 'sync', url: 'https://api.example.com/sync', body: '{}', intervalMinutes: 60,
});

const jobs = await backgroundSync.list();
console.log(jobs.map((j) => `${j.id} ${j.periodic ? 'repeating' : 'one-off'}`));

await backgroundSync.cancel('sync');
```

**Raw event** (what the wrapper waits for on Android):

```js
window.addEventListener('appmint:work', function (e) {
  // { id, status: 'enqueued' | 'scheduled' | 'cancelled' | 'failed', reason?, intervalMinutes? }
  // or { requestId, jobs: [...] } for a list
  console.log('work', e.detail);
});
```

**Notes:** `true` means the job was queued, not delivered. `false` means it was not queued: the shell answered `status: 'failed'` (a URL not starting with `http`, an empty id, or the system refused) or did not answer within 10 seconds. The page's JavaScript never runs in the background; only this request does. A 4xx reply is not retried; network errors and 5xx retry up to 8 times.

### `appmintNative.Motion`

**Example**

The `@capacitor/motion` shape over the standard `devicemotion` and `deviceorientation` events: movement and tilt of the phone. The same code runs in the apps and in a browser.

**Returns:** `Promise<{ remove(): Promise<void> }>` — call `remove()` to stop. `'accel'` calls back with `{ acceleration: {x,y,z}, accelerationIncludingGravity: {x,y,z}, rotationRate: {alpha,beta,gamma}, interval }`; `'orientation'` with `{ alpha, beta, gamma }` (degrees). **Needs:** nothing on Android. iOS 13+ asks the user first, so start it from a tap.

**Shake to refresh.** Start it from a button click so iOS can show its prompt.

```ts
import { Motion, type AccelEvent } from '@/lib/appmintNative';

let handle: { remove(): Promise<void> } | null = null;

export async function startShakeDetect(onShake: () => void) {
  let last = 0;
  handle = await Motion.addListener('accel', (e) => {
    const g = (e as AccelEvent).accelerationIncludingGravity;
    const force = Math.sqrt(g.x * g.x + g.y * g.y + g.z * g.z);
    if (force > 25 && Date.now() - last > 1000) { last = Date.now(); onShake(); }
  });
}

export async function stopShakeDetect() {
  await handle?.remove();
  handle = null;
}
```

**Tilt a level bubble** with the orientation event.

```ts
import { Motion, type OrientationEvent } from '@/lib/appmintNative';

const tilt = await Motion.addListener('orientation', (e) => {
  const { beta, gamma } = e as OrientationEvent;       // front-back, left-right
  bubble.style.transform = `translate(${gamma * 2}px, ${beta * 2}px)`;
});
// later: await tilt.remove();
```

**Hook form with automatic cleanup** for a React component that reads the phone's movement.

```ts
import { useEffect, useState } from 'react';
import { Motion, can, type AccelEvent } from '@/lib/appmintNative';

export function useAcceleration() {
  const [g, setG] = useState({ x: 0, y: 0, z: 0 });
  const [supported] = useState(() => can('motion') !== 'none');
  useEffect(() => {
    if (!supported) return;
    let handle: { remove(): Promise<void> } | undefined;
    let cancelled = false;
    void Motion.addListener('accel', (e) => setG((e as AccelEvent).accelerationIncludingGravity)).then((h) => {
      if (cancelled) void h.remove(); else handle = h;
    });
    return () => { cancelled = true; void handle?.remove(); };
  }, [supported]);
  return { g, supported };
}
```

**Notes:** If the iOS user refuses, the handle is returned but no events arrive. `can('motion')` is `'none'` where the browser has no `DeviceMotionEvent`. Missing sensor values arrive as `0`. Always `remove()` when the screen closes — motion events fire many times a second.

### `appmintNative.keepAwake`

**Example**

Keeps the screen on while a recipe, a route, a QR code or a timer is showing, and lets it sleep again.

**Returns:** `Promise<boolean>` — `true` when the request took effect, `false` when nothing could keep the screen on (or the browser refused). **Needs:** nothing.

```ts
import { keepAwake } from '@/lib/appmintNative';
import { useEffect } from 'react';

function CookingMode() {
  useEffect(() => {
    void keepAwake(true);
    return () => { void keepAwake(false); };   // always release on unmount
  }, []);
  return <Steps />;
}
```

Keep the screen on only while a timer is running, and tell the user when it could not be done.

```ts
import { keepAwake, toast } from '@/lib/appmintNative';
import { useEffect } from 'react';

export function useAwakeWhile(running: boolean) {
  useEffect(() => {
    if (!running) return;
    let held = false;
    void keepAwake(true).then((ok) => {
      held = ok;
      if (!ok) toast('Your screen may turn off while the timer runs');
    });
    return () => { if (held) void keepAwake(false); };
  }, [running]);
}
```

Check the capability first and take the lock again when the tab comes back to the front (the web releases it when the page is hidden).

```ts
import { keepAwake, can } from '@/lib/appmintNative';

export function holdScreenOn(): () => void {
  if (can('keepAwake') === 'none') return () => {};
  void keepAwake(true);
  const again = () => { if (document.visibilityState === 'visible') void keepAwake(true); };
  document.addEventListener('visibilitychange', again);
  return () => {
    document.removeEventListener('visibilitychange', again);
    void keepAwake(false);
  };
}
```

**Notes:** In the app it uses the KeepAwake plugin (the window's keep-screen-on flag); on the web the Screen Wake Lock API, which browsers release by themselves when the tab is hidden — call `keepAwake(true)` again when the page becomes visible if the screen must stay on. It only works while the app is in front.

### `appmintNative.brightness`

**Example**

Turns the screen up for a boarding pass or QR code, then gives control back to the system. Native only — a web page cannot drive the backlight.

**Returns:** `get()` → `Promise<number | null>` (0–1 while the app overrides the level; `-1` when the app is following the system level; `null` on the web or when it cannot be read). `set(value)` → `Promise<boolean>` (value clamped to 0–1; `false` on the web). `restore()` → `Promise<void>`. **Needs:** nothing.

```ts
import { brightness, can } from '@/lib/appmintNative';
import { useEffect } from 'react';

function TicketScreen() {
  useEffect(() => {
    if (can('brightness') === 'none') return;
    void brightness.set(1);
    return () => { void brightness.restore(); };
  }, []);
  return <QrCode />;
}
```

**Remember the level, brighten, then put it back.**

```ts
import { brightness } from '@/lib/appmintNative';

export async function showBoardingPass(render: () => Promise<void>) {
  const before = await brightness.get();   // -1 = following the system, null = not readable here
  const ok = await brightness.set(1);
  try {
    await render();
  } finally {
    if (ok) await brightness.restore();    // returns to the system level
  }
  return before;
}
```

**Keep the screen bright and awake while a code is on show.**

```ts
import { brightness, keepAwake, can } from '@/lib/appmintNative';
import { useEffect } from 'react';

export function useQrPresenting(active: boolean) {
  useEffect(() => {
    if (!active) return;
    if (can('brightness') !== 'none') void brightness.set(0.95);
    void keepAwake(true);
    return () => { void brightness.restore(); void keepAwake(false); };
  }, [active]);
}
```

**Notes:** It changes only this app's window, not the phone's brightness setting, and the override ends when the app closes. `restore()` sets `-1` (follow the system) on Android. `get()` reads the window's override, so it is `-1` until you call `set()` — it is not the system level.

### `appmintNative.askForReview`

**Example**

Asks Google Play to show its own rating sheet inside the app and reports what happened.

**Returns:** `Promise<ReviewOutcome>` — `{ result, openedStore, message? }` with `result` `'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable'` (`'unavailable'` on the web). `{ openStore: true }` opens the Play listing when the sheet did not show. **Needs:** nothing; the sheet only appears for an app installed from Google Play.

```ts
import { askForReview } from '@/lib/appmintNative';

export async function onRateTap() {
  const r = await askForReview({ openStore: true });   // listing if Play shows no sheet
  if (r.result === 'shown' || r.openedStore) toast('Thanks for rating!');
}
```

Tell the user what happened for each outcome:

```ts
import { askForReview } from '@/lib/appmintNative';

export async function rateMessage(): Promise<string> {
  const r = await askForReview();
  switch (r.result) {
    case 'shown': return 'Thanks for rating!';
    case 'not_from_play': return 'Rating works for installs from Google Play.';
    case 'quota': return 'Google limits how often the sheet shows. Try again later.';
    case 'unavailable': return '';                       // the web: draw no rating button at all
    default: return `Could not open the rating sheet${r.message ? `: ${r.message}` : ''}.`;
  }
}
```

Ask once, after the user finishes their third task, and remember that you asked:

```ts
import { askForReview } from '@/lib/appmintNative';

export async function maybeAskAfterTask(completed: number) {
  if (completed !== 3 || localStorage.getItem('asked-review')) return;
  localStorage.setItem('asked-review', '1');
  await askForReview();
}
```

**Notes:** Ask after a success moment and rarely. A sideloaded test install always answers `not_from_play`.

### `appmintNative.requestReview`

**Example**

Asks the store to show its own rating sheet inside the app (Play In-App Review on Android, the App Store sheet on iOS).

**Returns:** `Promise<boolean>` — `true` only when the store showed the sheet; `false` on the web, for an install that did not come from Google Play, when Play declined (its quota) or failed. Use `askForReview()` to learn which. On iOS the system never reports anything, so a request that reached it is `true`. **Needs:** nothing; the sheet only appears for an app installed from the store.

```ts
import { requestReview } from '@/lib/appmintNative';

async function onOrderDelivered(count: number) {
  // After a success moment, and rarely — not on launch, not after an error.
  if (count === 3 && !localStorage.getItem('askedReview')) {
    localStorage.setItem('askedReview', '1');
    await requestReview();
  }
}
```

Learn why the sheet did not show, without sending the user away:

```ts
import { askForReview } from '@/lib/appmintNative';

export async function maybeAsk() {
  const { result } = await askForReview();
  // 'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable'
  if (result !== 'shown') console.info('review sheet not shown:', result);
}
```

Only ask after a success moment, and at most once a month:

```ts
import { requestReview } from '@/lib/appmintNative';

const KEY = 'lastReviewAsk';

export async function afterSuccess() {
  const last = Number(localStorage.getItem(KEY) ?? 0);
  if (Date.now() - last < 30 * 24 * 3600 * 1000) return;
  localStorage.setItem(KEY, String(Date.now()));
  await requestReview();
}
```

**Notes:** The store rate-limits the sheet and often shows nothing — never reward a review and never build a "Rate us" button that expects the sheet to open (use `askForReview({ openStore: true })` for a button). In the app it uses the InAppReview plugin (`can('review')` is `'none'` without it). It never leaves the app for the store listing.

### `appmintNative.appUpdate`

**Example**

Checks the store for a newer version and starts the update: Google Play's in-app update on Android (downloads and installs in place), the App Store listing on iOS.

**Returns:** `check()` → `Promise<{ currentVersion, availableVersion?, currentVersionCode?, updateAvailable, inPlace } | null>` (`null` on the web, without Play services, or when Play fails). `updateAvailable` is the store's own answer. `start()` → `Promise<boolean>`: Android — `true` only when Play's update completed, `false` when the user cancelled, it failed or none was available; iOS — `true` once the App Store listing opened. **Needs:** nothing; the app must be installed from the store.

```ts
import { appUpdate } from '@/lib/appmintNative';

async function offerUpdate() {
  const info = await appUpdate.check();
  if (!info?.updateAvailable) return;            // up to date (or no store here)
  showBanner('A new version is ready', async () => {
    const done = await appUpdate.start();        // Play's full-screen flow / the App Store page
    if (!done) console.log('Not updated — the user can update later from the store');
  });
}
```

Compare Play's version code with the installed one, and treat `null` as "cannot check here":

```ts
import { appUpdate } from '@/lib/appmintNative';

export async function updateStatus(): Promise<string> {
  const info = await appUpdate.check();
  if (!info) return 'Update check is not available here.';     // web, no Play services, or Play failed
  if (!info.updateAvailable) return `You have the latest version (${info.currentVersion}).`;
  return `Update available: version code ${info.availableVersion} (you have ${info.currentVersionCode}).`;
}
```

A React banner that checks once when the screen opens:

```tsx
import { useEffect, useState } from 'react';
import { appUpdate, type AppUpdateInfo } from '@/lib/appmintNative';

export function UpdateBanner() {
  const [info, setInfo] = useState<AppUpdateInfo | null>(null);
  useEffect(() => { void appUpdate.check().then(setInfo); }, []);
  if (!info?.updateAvailable) return null;
  return <button onClick={() => void appUpdate.start()}>Update now</button>;
}
```

**Notes:** On Android `availableVersion` is Play's version CODE (Play gives no name) — compare it with `currentVersionCode`, never with `currentVersion`, which is the version name. `inPlace` is `true` when Play can run its in-app update right now. On iOS `start()` opens the App Store listing; the update itself happens there.

### `appmintNative.badge`

**Example**

Sets or clears the unread count on the app's launcher icon.

**Returns:** `badge.set(count)` → `Promise<boolean>` (`false` when the Badge plugin is not there, e.g. on the web); `badge.clear()` → `Promise<void>`. **Needs:** nothing on Android; iOS asks for notification permission the first time.

```ts
import { badge, pushInbox } from '@/lib/appmintNative';

export async function syncBadge() {
  const unread = pushInbox().filter((m) => !m.read).length;
  await badge.set(unread);        // 0 or less clears it
}

export async function onInboxOpened() {
  await badge.clear();
}
```

Mirror the unread count when a push message arrives while the app is open:

```ts
import { badge, pushInbox } from '@/lib/appmintNative';

export function watchInbox(): () => void {
  const refresh = () => void badge.set(pushInbox().filter((m) => !m.read).length);
  window.addEventListener('appmint:push', refresh);
  refresh();
  return () => window.removeEventListener('appmint:push', refresh);
}
```

Mark everything read and clear the count together; skip the badge where it does not exist:

```ts
import { badge, can, markPushRead } from '@/lib/appmintNative';

export async function markAllRead(): Promise<boolean> {
  markPushRead();                              // no id = every message
  if (can('badge') === 'none') return false;   // the web: nothing to clear
  await badge.clear();
  return true;
}
```

**Notes:** exact on iOS. On Android it depends on the launcher: many show a number, some show only a dot, some show nothing — do not put information only in the badge. On the web `can('badge')` is `'none'`.

### `appmintNative.readOtp`

**Example**

Waits for the SMS one-time code for this app and returns it — Google's SMS consent sheet in the Android app, WebOTP in Chrome, and `can('otp') === 'none'` on iPhone (use `autocomplete="one-time-code"` there).

**Returns:** `Promise<string | null>` — the code, or `null` when the user declined, five minutes passed, or `signal` aborted. Throws when `can('otp')` is `'none'`. **Needs:** nothing to switch on. The SMS must end with `@<host> #<code>` (in the Android app the host is `appassets.androidplatform.net`).

```ts
import { useEffect, useState } from 'react';
import { can, readOtp } from '@/lib/appmintNative';

export function CodeField({ onDone }: { onDone: (code: string) => void }) {
  const [code, setCode] = useState('');

  useEffect(() => {
    if (can('otp') === 'none') return;
    const ac = new AbortController();
    readOtp({ signal: ac.signal }).then((c) => { if (c) { setCode(c); onDone(c); } });
    return () => ac.abort();          // leaving the screen stops the wait
  }, [onDone]);

  return (
    <input value={code} onChange={(e) => setCode(e.target.value)}
      inputMode="numeric" autoComplete="one-time-code" aria-label="Verification code" />
  );
}
```

Handle the "no code" answer, and offer a resend when the wait ends without one:

```ts
import { useState } from 'react';
import { can, readOtp } from '@/lib/appmintNative';

export function VerifyStep({ resend, submit }: { resend: () => void; submit: (c: string) => void }) {
  const [waited, setWaited] = useState(false);

  async function start() {
    resend();
    if (can('otp') === 'none') return;          // readOtp would throw here — the user types it
    const code = await readOtp();               // null: declined, 5 minutes passed, or aborted
    if (code) submit(code); else setWaited(true);
  }

  return (
    <div>
      <button onClick={start}>Send code</button>
      {waited && <p>No code detected. <button onClick={start}>Send again</button></p>}
    </div>
  );
}
```

Catch the throw when you cannot check `can('otp')` first:

```ts
import { readOtp } from '@/lib/appmintNative';

export async function tryAutoFill(): Promise<string | null> {
  try {
    return await readOtp({ signal: AbortSignal.timeout(60_000) });
  } catch {
    return null;                                // one-time codes unavailable: the user types it
  }
}
```

**Notes:** it is an accelerator — always keep the field typeable.

### `appmintNative.copyImage`

**Example**

Copies an image to the clipboard so it pastes into chat and mail apps: the app's clipboard in the Android app, the Clipboard plugin on iPhone, the Async Clipboard API on the web.

**Returns:** `Promise<boolean>` — `true` once copied. **Needs:** nothing; gate the control on `can('clipboardImage')`.

```ts
import { can, copyImage, toast } from '@/lib/appmintNative';

export async function copyQr(canvas: HTMLCanvasElement) {
  if (can('clipboardImage') === 'none') return;
  const png = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'));
  if (png && (await copyImage(png))) toast('QR code copied');
  else toast('Could not copy the image');
}
```

Copy a photo from the camera, then confirm it with a toast.

```ts
import { camera, copyImage, toast } from '@/lib/appmintNative';

export async function copySnapshot() {
  const photo = await camera.take({ source: 'camera' });
  if (!photo) return;                                  // the user cancelled
  const blob = await (await fetch(photo.dataUrl)).blob();
  toast((await copyImage(blob)) ? 'Photo copied' : 'Could not copy the photo');
}
```

Show the button only where the clipboard accepts images, and copy an image from a URL.

```ts
import { useEffect, useState } from 'react';
import { can, copyImage, toast } from '@/lib/appmintNative';

export function CopyLogoButton({ src }: { src: string }) {
  const [ok, setOk] = useState(false);
  useEffect(() => { setOk(can('clipboardImage') !== 'none'); }, []);
  if (!ok) return null;
  return (
    <button onClick={async () => {
      const blob = await (await fetch(src)).blob();
      toast((await copyImage(blob)) ? 'Logo copied' : 'Copy failed');
    }}>
      Copy logo
    </button>
  );
}
```

**Notes:** PNG is the safest type everywhere; the Android app also accepts JPEG and WebP.

### `appmintNative.speech`

**Example**

Dictation: turns the user's speech into text. Android's speech recognizer in the Android app, Apple's in the iOS app, the Web Speech API in Chrome.

**Returns:** `speech.isAvailable()` → `Promise<boolean>`. `speech.start(onResult, opts?)` → `Promise<boolean>`: `true` when listening started, `false` when the microphone permission was refused or dictation is not available. `onResult(text, isFinal)` is called with the text. `speech.stop()` → `Promise<void>`. `opts`: `{ lang?: string; partial?: boolean; prompt?: string }`. **Needs:** **Mic** in Step 4 (Access); AI builds switch it on for you when the code uses speech recognition.

```ts
import { useEffect, useState } from 'react';
import { speech } from '@/lib/appmintNative';

export function VoiceNote() {
  const [ok, setOk] = useState(false);
  const [text, setText] = useState('');
  const [msg, setMsg] = useState('');

  useEffect(() => { void speech.isAvailable().then(setOk); }, []);
  if (!ok) return null;                      // no dictation here: hide the mic button

  const listen = async () => {
    setMsg('Listening...');
    const started = await speech.start(
      (t, isFinal) => { setText(t); if (isFinal) setMsg(''); },
      { lang: 'en-US' },                     // partial results by default
    );
    if (!started) setMsg('Microphone permission is needed for voice input.');
  };

  return (
    <div>
      <button aria-label="Speak" onClick={listen}>🎤</button>
      <button onClick={() => { void speech.stop(); setMsg(''); }}>Stop</button>
      <p>{text}</p>
      <p role="status">{msg}</p>
    </div>
  );
}
```

Dictate into a text field and say so when the microphone was refused, since `start()` answers `false`.

```tsx
import { useState } from 'react';
import { speech } from '@/lib/appmintNative';

export function DictateField() {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');

  const dictate = async () => {
    setError('');
    const started = await speech.start((text, isFinal) => { if (isFinal) setValue((v) => (v ? `${v} ${text}` : text)); }, { partial: false });
    if (!started) setError('Voice input is not available. Check the microphone permission.');
  };

  return (
    <div>
      <textarea value={value} onChange={(e) => setValue(e.target.value)} />
      <button onClick={dictate}>Dictate</button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
```

Stop listening when the screen closes, and read the result back with `speak`.

```tsx
import { useEffect, useState } from 'react';
import { speech, speak } from '@/lib/appmintNative';

export function VoiceCommand() {
  const [heard, setHeard] = useState('');
  useEffect(() => () => { void speech.stop(); }, []);

  return (
    <button
      onClick={() => void speech.start((t, isFinal) => {
        setHeard(t);
        if (isFinal) speak(`You said ${t}`);
      }, { lang: 'en-US' })}
    >
      {heard || 'Say something'}
    </button>
  );
}
```

**Notes:**
- With `partial` on (the default) `onResult(text, false)` arrives as words come in, then once `onResult(text, true)` with the final text. With `partial: false` you get only the final one, when the user stops talking.
- The permission prompt is shown on the first `start()`.
- If the user says nothing, `onResult` is not called; listening just ends. Keep a Stop button or a timeout in your UI.
- Listening stops by itself after a pause. `lang` defaults to the phone's language.

### `appmintNative.hapticPlay`

**Example**

Plays a rich vibration pattern for a special moment (a win, a streak, a heartbeat). Use `haptic()` for normal taps.

**Returns:** `Promise<boolean>`: `true` when the pattern played, `false` when it could not. **Needs:** **Vibrate** in Step 4 (Access).

Named patterns: `'success-ramp'`, `'heartbeat'`, `'tick-tick-thud'`, `'drum-roll'`, `'nudge'`.

```ts
import { hapticPlay } from '@/lib/appmintNative';

async function onLevelComplete() {
  const felt = await hapticPlay('success-ramp');
  if (!felt) console.log('No vibration on this device');
  showConfetti();
}
```

Your own pattern: a list of `{ at, duration?, intensity?, sharpness? }` (ms, ms, 0–1, 0–1):

```ts
import { hapticPlay } from '@/lib/appmintNative';

await hapticPlay([
  { at: 0,   intensity: 0.4 },               // soft tap
  { at: 150, intensity: 0.4 },               // soft tap
  { at: 320, duration: 120, intensity: 1 },  // strong buzz
]);
```

Combine it with `haptic()`: a normal tap feedback for every answer, the long pattern only for the special moment:

```ts
import { haptic, hapticPlay } from '@/lib/appmintNative';

export async function onAnswer(correct: boolean, streak: number) {
  haptic(correct ? 'success' : 'error');            // every answer
  if (correct && streak > 0 && streak % 5 === 0) {
    await hapticPlay('drum-roll');                  // every fifth in a row
  }
}
```

**Notes:** Android uses the `AppwrightHaptics` plugin (strength per beat where the phone supports it), iOS uses Core Haptics. The web gets the timing only. `sharpness` matters on iOS only. `haptic.play(pattern)` is the same function.

### `appmintNative.docScanner`

**Example**

Opens the system document scanner — edge detection, straightening, several pages — ML Kit on Android, VisionKit on iOS. Pages come back as JPEG data URLs.

**Returns:** `Promise<{ pages: string[]; pdfPath?: string } | null>` — `null` when the user cancels, the scanner cannot start, or on the web. `isSupported()` → `Promise<boolean>`: whether this phone can scan (Google Play services on Android, VisionKit on iOS); `false` on the web. **Needs:** nothing to switch on (on Android the scanner runs in Google Play services and needs no camera permission).

```ts
import { docScanner, can } from '@/lib/appmintNative';

export function ScanButton({ onPages }: { onPages: (p: string[]) => void }) {
  if (can('docScanner') === 'none') return null;     // no web scanner: hide the button
  return (
    <button onClick={async () => {
      const res = await docScanner.scan({ pageLimit: 3, quality: 0.8 });
      if (res) onPages(res.pages);                   // each a data:image/jpeg;base64,… URL
    }}>
      Scan document
    </button>
  );
}
```

Ask the phone first, because an older device may lack the scanner, then keep the pages.

```tsx
import { useEffect, useState } from 'react';
import { docScanner, can } from '@/lib/appmintNative';

export function ScanPanel() {
  const [ready, setReady] = useState(false);
  const [pages, setPages] = useState<string[]>([]);

  useEffect(() => {
    if (can('docScanner') === 'none') return;          // web: no scanner at all
    docScanner.isSupported().then(setReady);           // false without Google Play services
  }, []);

  if (!ready) return <p>Scanning is not available on this phone.</p>;
  return (
    <>
      <button onClick={async () => {
        const res = await docScanner.scan({ pageLimit: 5 });
        if (!res) return;                              // cancelled or could not start
        setPages((p) => [...p, ...res.pages]);
      }}>Scan pages</button>
      {pages.map((src, i) => <img key={i} src={src} alt={`Page ${i + 1}`} width={120} />)}
    </>
  );
}
```

Scan, then upload the first page with the file-transfer helper.

```ts
import { docScanner, fileTransfer } from '@/lib/appmintNative';

export async function scanAndUpload(): Promise<boolean> {
  const res = await docScanner.scan({ pageLimit: 1, quality: 0.7 });
  if (!res || res.pages.length === 0) return false;
  const blob = await (await fetch(res.pages[0])).blob();
  const file = new File([blob], 'page-1.jpg', { type: 'image/jpeg' });
  const r = await fileTransfer.upload('https://example.com/api/scans', file, { fieldName: 'scan' });
  return r.responseCode >= 200 && r.responseCode < 300;
}
```

**Notes:** `pageLimit` `0` (default) = no limit; `quality` 0.1–1 (default 0.85). With `pdf: true`, `pdfPath` is a file path in the app's cache (Android), not a URL the page can load. Data URLs are large — resize or upload them rather than keeping many in `localStorage`. For a plain photo use `camera.take()`.

### `appmintNative.systemPalette`

**Example**

Reads the phone's Material You colours (built from the wallpaper on Android 12+), so the app's accent can follow the user's wallpaper like the system apps.

**Returns:** `Promise<{ available: boolean; accent1?, accent2?, accent3?, neutral1?, neutral2? }>` — each swatch maps a tone (`'0'`, `'10'`, `'50'`, `'100'` … `'900'`, `'1000'`) to a hex colour. `{ available: false }` below Android 12, on iOS and on the web. **Needs:** nothing.

```ts
import { systemPalette } from '@/lib/appmintNative';

export async function applyWallpaperAccent() {
  const p = await systemPalette();
  if (!p.available || !p.accent1) return;          // keep the app's own palette
  const root = document.documentElement.style;
  root.setProperty('--accent', p.accent1['600']);
  root.setProperty('--accent-soft', p.accent1['100']);
}
```

Use the wallpaper colours only when the phone offers them, and re-read them when the app comes back to the screen:

```ts
import { useEffect, useState } from 'react';
import { systemPalette } from '@/lib/appmintNative';

export function useWallpaperAccent(enabled: boolean) {
  const [accent, setAccent] = useState<string | null>(null);
  useEffect(() => {
    if (!enabled) { setAccent(null); return; }
    const read = () => systemPalette().then((p) => setAccent(p.available ? p.accent1?.['600'] ?? null : null));
    void read();
    document.addEventListener('visibilitychange', read);
    return () => document.removeEventListener('visibilitychange', read);
  }, [enabled]);
  return accent;   // null = no wallpaper palette on this device (Android 11 and older, or the web)
}
```

Show the "Match my wallpaper" switch only when a palette exists, using `{ available: false }` as the answer to "not here":

```ts
import { useEffect, useState } from 'react';
import { can, systemPalette } from '@/lib/appmintNative';

export function WallpaperToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
  const [ok, setOk] = useState(false);
  useEffect(() => { if (can('systemPalette') !== 'none') systemPalette().then((p) => setOk(p.available)); }, []);
  if (!ok) return null;
  return <label><input type="checkbox" checked={value} onChange={(e) => onChange(e.target.checked)} /> Match my wallpaper</label>;
}
```

**Notes:** Check contrast before you use a wallpaper tone for text or buttons — `--accent` must still reach 3:1 on your background. Offer it as an option ("Match my wallpaper"), not a forced theme. Call it again when the app resumes; the user may have changed the wallpaper.

### `appmintNative.liveProgress`

**Example**

A sticky, silent status card in the notification shade that you update in place — a delivery, an upload, a timer, a workout. `start`, `update`, `end`.

**Returns:** `start` / `update` → `Promise<boolean>` (`false` if not available, notifications are not allowed, or the user turned off its "Live status" category); `end` → `Promise<void>`. **Needs:** Android, and the notification permission. On iOS and the web `can('liveProgress')` is `'none'` — show the same state inside your app there.

```ts
import { liveProgress, can } from '@/lib/appmintNative';

export async function trackUpload(files: File[]) {
  const card = can('liveProgress') !== 'none';
  if (card) await liveProgress.start({ id: 'upload', title: 'Uploading photos', text: 'Starting…', indeterminate: true });

  for (let i = 0; i < files.length; i++) {
    await uploadOne(files[i]);
    if (card) await liveProgress.update({
      id: 'upload', title: 'Uploading photos',
      text: `${i + 1} of ${files.length}`,
      progress: (i + 1) / files.length,       // 0–1
      route: '/uploads',                      // opens this screen when tapped
    });
  }
  if (card) await liveProgress.end('upload'); // removes the card
}
```

**A running timer:** `startedAt` shows a clock counting up from that moment.

```ts
await liveProgress.start({ id: 'run', title: 'Morning run', text: '5 km goal', startedAt: Date.now() });
// ...later
await liveProgress.end('run');
```

**Handle a refusal.** `start` answers `false`; `liveProgress.lastError` says why.

```ts
import { liveProgress, toast } from '@/lib/appmintNative';

export async function startDelivery() {
  const ok = await liveProgress.start({ id: 'order', title: 'Order on its way', text: 'Packing', progress: 0.1, route: '/orders' });
  if (!ok) {
    if (liveProgress.lastError === 'permission') toast('Allow notifications to follow your order here');
    else if (liveProgress.lastError === 'channel_disabled') toast('Live status is turned off in system settings');
  }
  return ok;
}
```

**Notes:** the same `id` updates one card; different ids make separate cards (the default id is `'live'`). The user cannot swipe it away, so always call `end`. Ask for notification permission first on Android 13+ (`Notification.requestPermission()`), or `start` returns `false`.

### `appmintNative.widget`

**Example**

Fills the app's home-screen widget — one card with a title, a big value, up to six lines, and a tap that opens the app at a route.

**Returns:** `set(card)` → `Promise<boolean>` (`true` when saved); `clear()` → `Promise<void>`; `placedCount()` → `Promise<number>` (how many copies the user placed); `requestPin()` → `Promise<boolean>` (`true` when the launcher's "add widget" flow opened). None of them reject. **Needs:** nothing in the Android app (the widget is always part of the build).

Update the card after every change the user would want to see on the home screen:

```ts
import { can, widget } from '@/lib/appmintNative';

export async function updateTodayWidget(tasks: { title: string; done: boolean }[]) {
  if (can('widget') === 'none') return;          // web and iOS
  const open = tasks.filter((t) => !t.done);
  await widget.set({
    title: 'Today',
    value: `${open.length} left`,
    lines: open.slice(0, 6).map((t) => '• ' + t.title),
    route: '/today',                              // a tap opens the app here
  });
}
```

An "Add to home screen" button:

```ts
import { can, widget, toast } from '@/lib/appmintNative';

export function AddWidgetButton() {
  if (can('widget') === 'none') return null;
  return (
    <button
      onClick={async () => {
        if (!(await widget.requestPin())) toast('Long-press your home screen, tap Widgets, and pick this app');
      }}
    >
      Add widget to home screen
    </button>
  );
}
```

Empty the card when the user signs out, and show how many copies are on the home screen:

```ts
import { useEffect, useState } from 'react';
import { can, widget } from '@/lib/appmintNative';

export function WidgetStatus({ signedIn }: { signedIn: boolean }) {
  const [placed, setPlaced] = useState(0);
  useEffect(() => {
    if (can('widget') === 'none') return;
    if (!signedIn) void widget.clear();
    widget.placedCount().then(setPlaced);
  }, [signedIn]);
  return <p>{placed === 0 ? 'Not on your home screen yet' : `On your home screen (${placed})`}</p>;
}
```

**Notes:**
- The card is saved on the device, survives a reboot, and every placed copy is refreshed. `placedCount()` of `0` means no copy is on the home screen yet.
- Card fields: `title` (empty = the app name), `value` (shown large in `accent`, hidden when empty), `lines` (max 6), `route` (default `/`), and `bg` / `ink` / `accent`. The colours default to the CSS tokens `--bg`, `--ink`, `--accent`; they must be colours Android can read, such as `#RRGGBB` — an `hsl()`/`oklch()` value falls back to the default colours.
- A tap opens the app with `appmint-shortcut://<package><route>`; read it with `launchUrl()` / `onDeepLink()`.
- `requestPin()` needs Android 8+ and a launcher that supports it. `clear()` shows the empty "Nothing to show yet" card.
- Android only: `can('widget')` is `'none'` on iOS and the web, where `set()` and `requestPin()` return `false`.

### `appmintNative.nfc`

**Example**

One-call helpers over Web NFC (`NDEFReader`): check support, read tags, write a text tag. Android uses the app's NDEFReader, iPhone the app's NFC plugin, the web Chrome's own Web NFC.

**Returns:** `isSupported(): Promise<boolean>`; `scan(onTag): Promise<() => Promise<void>>` (the stop function), and `onTag` gets `Array<{recordType, text, mediaType?}>` per tag — Web NFC's type names (`'text'`, `'url'`, `'mime'`, …) and the decoded text on every platform; `write(text): Promise<boolean>` (`true` once a tag was written, `false` on any failure). **Needs:** on Android, turn on **Web NFC Support** in Step 4 (Access) when you build. `scan()` throws when there is no `NDEFReader` — check `can('nfc')` first.

```ts
import { useEffect, useRef, useState } from 'react';
import { can, nfc } from '@/lib/appmintNative';

export function TagReader() {
  const [last, setLast] = useState('');
  const stopRef = useRef<null | (() => Promise<void>)>(null);

  useEffect(() => () => { void stopRef.current?.(); }, []);

  // Check at the tap: on Android the app installs NDEFReader while the page loads.
  const start = async () => {
    if (can('nfc') === 'none' || !(await nfc.isSupported())) { setLast('NFC is not available here.'); return; }
    try {
      stopRef.current = await nfc.scan((records) => {
        setLast(records.filter((r) => r.recordType === 'text' || r.recordType === 'url').map((r) => r.text).join(' | '));
      });
      setLast('Hold a tag to the phone.');
    } catch (e) {
      setLast(`NFC error: ${(e as Error).message}`);
    }
  };

  const writeTag = async () => {
    if (can('nfc') === 'none') { setLast('NFC is not available here.'); return; }
    setLast('Tap a tag to write it...');
    setLast((await nfc.write('Hello from my app')) ? 'Tag written.' : 'Write failed or cancelled.');
  };

  return (
    <div>
      <button onClick={start}>Read tags</button>
      <button onClick={writeTag}>Write tag</button>
      <p>{last}</p>
    </div>
  );
}
```

Write a link to a tag, and tell the user why it failed when it did.

```ts
import { can, nfc, toast } from '@/lib/appmintNative';

export async function writeShareTag(url: string) {
  if (can('nfc') === 'none') { toast('This device cannot write NFC tags.'); return; }
  toast('Hold a blank tag to the back of the phone...');
  const ok = await nfc.write(url);
  toast(ok ? 'Tag written.' : 'Could not write the tag. Try again.');
}
```

Read one tag, then stop scanning.

```ts
import { nfc } from '@/lib/appmintNative';

export function readOnce(): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    let stop: (() => Promise<void>) | undefined;
    let done = false;
    nfc.scan((records) => {
      if (done) return;
      done = true;
      void stop?.();
      resolve(records.map((r) => r.text).join(' '));
    }).then((s) => { stop = s; if (done) void s(); }, reject);
  });
}
```

**Notes:** On Android `isSupported()` is `true` whenever the build has NFC — it does not say whether NFC is switched on (`window.AndroidNFC.startScan()` does). After the stop function, `onTag` is never called again. On Android the app itself keeps catching tags while it is in front (so a tap does not open another app), it just stops passing them to you. A text record's `text` has no language prefix; a URL record's `text` is the full URL. `write()` waits until a tag is tapped. For writing URL or mime records, and for locking a tag, use `NDEFReader` directly.

### `appmintNative.pip`

**Example**

Keeps a video playing in a small floating window while the user does something else.

**Returns:** `enter()` returns `Promise<boolean>` — `true` when the floating window opened. `exit()` returns `Promise<void>`. Neither rejects. **Needs:** in the Android app the build's **Picture-in-Picture (PiP)** option — it is switched on automatically when your code calls `pip.enter(`.

A "minimise" button on a player:

```ts
import { useRef } from 'react';
import { can, pip, toast } from '@/lib/appmintNative';

export function Player({ src }: { src: string }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  return (
    <div>
      <video ref={videoRef} src={src} controls playsInline />
      {can('pip') !== 'none' && (
        <button
          onClick={async () => {
            const ok = await pip.enter(videoRef.current ?? undefined);
            if (!ok) toast('Picture-in-picture is not available here');
          }}
        >
          Minimise
        </button>
      )}
    </div>
  );
}
```

Leave it when the player closes (web and iOS):

```ts
import { pip } from '@/lib/appmintNative';

declare const video: HTMLVideoElement;
await pip.exit(video);
```

Enter picture-in-picture automatically when the user leaves a playing video, only where it is supported:

```ts
import { useEffect, type RefObject } from 'react';
import { can, pip } from '@/lib/appmintNative';

export function useAutoPip(videoRef: RefObject<HTMLVideoElement>) {
  useEffect(() => {
    if (can('pip') === 'none') return;
    const onHide = () => {
      const v = videoRef.current;
      if (document.hidden && v && !v.paused) void pip.enter(v);
    };
    document.addEventListener('visibilitychange', onHide);
    return () => document.removeEventListener('visibilitychange', onHide);
  }, [videoRef]);
}
```

**Notes:**
- Android (8+): the WHOLE app shrinks into the floating window, and the `video` argument is ignored. It returns `false` on older Android or in a build without the PiP option. The user expands the window to come back; `exit()` does nothing there.
- iOS and the web: the `<video>` element's own picture-in-picture. The video must be loaded (usually playing); a call without a `video` returns `false`.
- Call it from a tap.

### `appmintNative.sharedText`

**Example**

Gives the text or link another app shared INTO this app ("Share → your app"), for example a link shared from a browser or YouTube.

**Returns:** synchronously, `{ text, subject, url, receivedAt }` or `null` when nothing is waiting. `url` is the first `http(s)` link found in `text` (`''` if none); `receivedAt` is milliseconds since 1970. **Needs:** the build's **Receive Shares From Other Apps** option with "Text and links" — switched on automatically when your code calls `sharedText(` or `onShared(`.

Check on mount, save it, then clear it:

```ts
import { useEffect } from 'react';
import { can, sharedText, clearShared, toast } from '@/lib/appmintNative';

export function useSaveSharedLink(save: (item: { title: string; url: string; text: string }) => Promise<void>) {
  useEffect(() => {
    if (can('shareTarget') === 'none') return;     // web and iOS
    const p = sharedText();
    if (!p) return;
    void save({ title: p.subject || p.url || 'Shared note', url: p.url, text: p.text })
      .then(() => { clearShared(); toast('Saved'); });
  }, [save]);
}
```

Listen for a share that arrives while the app is already open with `onShared`, and clear it after it is used.

```ts
import { useEffect, useState } from 'react';
import { sharedText, onShared, clearShared } from '@/lib/appmintNative';

export function useIncomingShare() {
  const [text, setText] = useState(() => sharedText()?.text ?? '');
  useEffect(() => onShared((p) => setText(p.text)), []);
  return { text, done: () => { clearShared(); setText(''); } };
}
```

Prefill a form from a shared link and show nothing on the web, where `sharedText()` is `null`.

```tsx
import { useEffect, useState } from 'react';
import { can, sharedText } from '@/lib/appmintNative';

export function NewBookmark() {
  const [url, setUrl] = useState('');
  useEffect(() => {
    if (can('shareTarget') === 'none') return;   // web: nobody can share into the page
    const p = sharedText();
    if (p?.url) setUrl(p.url);
  }, []);
  return <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Paste a link" />;
}
```

**Notes:**
- The share is KEPT until you call `clearShared()`, so an app that mounts after the share still sees it. Clear it after saving, or the next mount saves it again.
- For a share that arrives while the app is open, use `onShared()`.
- A photo or video shared WITH a caption: the caption arrives here, the file through the opened-file API.
- Android only. `can('shareTarget')` is `'none'` on the web and on iOS (no Share Extension), and `sharedText()` returns `null` there.

### `appmintNative.clearShared`

**Example**

Forgets the text or link another app shared into this one, once your app has saved it.

**Returns:** nothing. **Needs:** nothing (does nothing on the web and iOS).

```ts
import { sharedText, clearShared } from '@/lib/appmintNative';

export async function importPendingShare(addNote: (text: string) => Promise<void>): Promise<boolean> {
  const p = sharedText();
  if (!p) return false;
  await addNote(p.text);
  clearShared();          // otherwise sharedText() returns the same share on the next mount
  return true;
}
```

Clear the share only after it is saved, and keep it when saving fails:

```ts
import { sharedText, clearShared, toast } from '@/lib/appmintNative';

export async function saveSharedLink(save: (url: string) => Promise<void>) {
  const p = sharedText();
  if (!p?.url) return;
  try {
    await save(p.url);
    clearShared();
    toast('Saved');
  } catch {
    toast('Could not save, the shared link is still waiting');   // not cleared, so it is not lost
  }
}
```

Handle a share that arrives while the app is open:

```ts
import { useEffect } from 'react';
import { onShared, clearShared } from '@/lib/appmintNative';

export function useIncomingShare(addNote: (text: string) => void) {
  useEffect(() => onShared((p) => {
    addNote(p.subject ? `${p.subject}\n${p.text}` : p.text);
    clearShared();
  }), [addNote]);
}
```

**Notes:** `sharedText()` keeps a share until this is called, on purpose, so a late-mounting screen still sees it. Call `clearShared()` only AFTER the share is safely saved — if saving fails, leave it so the user does not lose it.

### `appmintNative.onShared`

**Example**

Calls you when another app shares text or a link into this app while it is already open.

**Returns:** an unsubscribe function — call it on unmount. **Needs:** the build's **Receive Shares From Other Apps** option with "Text and links" — switched on automatically when your code calls `onShared(` or `sharedText(`.

Handle both cases — a share that started the app, and one that arrives later:

```ts
import { useEffect } from 'react';
import { can, sharedText, clearShared, onShared, toast } from '@/lib/appmintNative';

export function useIncomingShares(save: (text: string, url: string) => Promise<void>) {
  useEffect(() => {
    if (can('shareTarget') === 'none') return;
    const handle = async (p: { text: string; url: string }) => {
      await save(p.text, p.url);
      clearShared();
      toast('Added');
    };
    const waiting = sharedText();
    if (waiting) void handle(waiting);                    // shared before the app opened
    return onShared((p) => void handle(p));              // shared while open; returns the unsubscribe
  }, [save]);
}
```

A hand-written page can listen to the shell event itself:

```js
window.addEventListener('appmint:shared', function (e) {
  var p = e.detail;                  // { text, subject, url, receivedAt }
  if (p && p.text) console.log('shared:', p.text);
});
```

Prefill a "new note" form from a shared link, only where the build can receive shares:

```ts
import { useEffect, useState } from 'react';
import { can, onShared, toast } from '@/lib/appmintNative';

export function NewNote() {
  const [body, setBody] = useState('');
  useEffect(() => {
    if (can('shareTarget') === 'none') return;
    return onShared((p) => {
      setBody([p.subject, p.text, p.url].filter(Boolean).join('\n'));
      toast('Shared text added');
    });
  }, []);
  return <textarea value={body} onChange={(e) => setBody(e.target.value)} />;
}
```

**Notes:**
- Android: listens to the shell's `appmint:shared` event; the shell also calls `window.onAppMintShared(detail)` when a page defines it. The same payload is also kept for `sharedText()` until `clearShared()`.
- On the web and iOS it never fires and returns a no-op unsubscribe.

### `appmintNative.notifyWithActions`

**Example**

Shows a notification with up to 3 buttons, optionally with an inline reply box ("Mark done", "Snooze", "Reply…"). The tap comes back through `onNotificationAction`.

**Returns:** `Promise<boolean>` — `true` when posted, `false` when the notification permission was refused (it asks the first time, like `notify()`). **Needs:** Android: **Notifications** turned on in the Permissions step when you build; iOS asks for permission on first use.

```ts
import { notifyWithActions, onNotificationAction, can } from '@/lib/appmintNative';

export async function remindTask(taskId: string, title: string) {
  if (can('notificationActions') === 'none') return false;
  return notifyWithActions({
    title,
    body: 'Due now',
    tag: `task-${taskId}`,                       // comes back as e.tag
    actions: [
      { id: 'done', title: 'Done' },
      { id: 'snooze', title: 'Snooze' },
      { id: 'reply', title: 'Reply', input: 'Add a note' }, // string = reply placeholder
    ],
  });
}

// Mount once (e.g. in App.tsx):
export const stopListening = onNotificationAction((e) => {
  // e = { actionId: 'snooze', tag: 'task-42', text: '' }
  const taskId = e.tag.replace('task-', '');
  if (e.actionId === 'done') completeTask(taskId);
  if (e.actionId === 'snooze') snoozeTask(taskId);
  if (e.actionId === 'reply') addNote(taskId, e.text);
});
```

A timer finished: one button adds five minutes, the other stops it.

```ts
import { notifyWithActions, onNotificationAction, can } from '@/lib/appmintNative';

export async function timerDone() {
  if (can('notificationActions') === 'none') return false;
  return notifyWithActions({
    title: 'Timer finished',
    tag: 'timer',
    actions: [{ id: 'more', title: '+5 min' }, { id: 'stop', title: 'Stop' }],
  });
}

export const stopTimerButtons = onNotificationAction((e) => {
  if (e.tag !== 'timer') return;                 // ignore other notifications' buttons
  if (e.actionId === 'more') startTimer(5);
});
```

Handle a refused permission after the call, and clean up in a component.

```ts
import { useEffect, useState } from 'react';
import { notifyWithActions, onNotificationAction } from '@/lib/appmintNative';

export function ApprovalRequest({ requestId }: { requestId: string }) {
  const [note, setNote] = useState('');
  useEffect(() => onNotificationAction((e) => {
    if (e.tag === `req-${requestId}`) setNote(e.actionId === 'approve' ? 'Approved' : 'Rejected');
  }), [requestId]);

  const ask = async () => {
    const posted = await notifyWithActions({
      title: 'Approve request?', tag: `req-${requestId}`,
      actions: [{ id: 'approve', title: 'Approve' }, { id: 'reject', title: 'Reject' }],
    });
    if (!posted) setNote('Notifications are off, so no buttons were shown.');
  };
  return <button onClick={ask}>{note || 'Ask me'}</button>;
}
```

**Notes:** Each button shows its `title`; its `id` is what comes back as `e.actionId`. Android shows at most 3 buttons. Android and iOS support the reply box. The web has no buttons a page can hear, so `can('notificationActions')` is `'none'` there; if called anyway it shows a plain notification without the buttons. A tap reaches the page only while the app is running.

### `appmintNative.onNotificationAction`

**Example**

Listens for taps on notification buttons (from `notifyWithActions`), including the text typed into an inline reply.

**Returns:** a function that stops listening. The callback gets `{ actionId, tag, text }` — `text` is the typed reply, or `''`. **Needs:** the same as `notifyWithActions`.

```ts
import { useEffect } from 'react';
import { onNotificationAction } from '@/lib/appmintNative';

export function useNotificationActions(onReply: (chatId: string, text: string) => void) {
  useEffect(() => {
    const stop = onNotificationAction(({ actionId, tag, text }) => {
      if (actionId === 'Reply' && text) onReply(tag, text);
      if (actionId === 'Mute') muteChat(tag);
    });
    return stop;                              // remove on unmount
  }, [onReply]);
}
```

**Raw event in a hand-written page** (Android shell):

```js
window.addEventListener('appmint:notification-action', function (e) {
  // e.detail = { actionId: 'Reply', tag: 'chat-7', text: 'On my way' }
  console.log(e.detail.actionId, e.detail.tag, e.detail.text);
});
```

Post a notification with buttons only where the build supports them, and handle the tap by the notification's tag:

```ts
import { useEffect } from 'react';
import { can, notifyWithActions, onNotificationAction, toast } from '@/lib/appmintNative';

export async function remindWithButtons(taskId: string, title: string) {
  if (can('notificationActions') === 'none') { toast(title); return; }   // no buttons here
  await notifyWithActions({
    title, body: 'Tap a button', tag: `task-${taskId}`,
    actions: [{ id: 'done', title: 'Done' }, { id: 'later', title: 'Later' }],
  });
}

export function useTaskActions(complete: (id: string) => void, snooze: (id: string) => void) {
  useEffect(() => onNotificationAction(({ actionId, tag }) => {
    const id = tag.replace(/^task-/, '');
    if (actionId === 'done') complete(id);
    else if (actionId === 'later') snooze(id);
  }), [complete, snooze]);
}
```

**Notes:** Android: the `appmint:notification-action` event (also `window.onAppMintNotificationAction(detail)`); iOS: the LocalNotifications `localNotificationActionPerformed` listener; on the web it does nothing. On Android a tap is delivered only while the app is running — tapping a button does not open the app.

### `appmintNative.onNotificationClick`

**Example**

Runs a callback when the user taps a notification the app posted — including the tap that started the app from cold — so the app can open the right screen.

**Returns:** the unsubscribe function. The callback gets `{ tag, title, body, data }` — `data` is what you passed to `notify({ …, data })`. **Needs:** notifications, like `notify()`. Event behind it: `appmint:notification-click`.

```ts
import { useEffect } from 'react';
import { notify, onNotificationClick } from '@/lib/appmintNative';

export function useOrderAlerts(navigate: (path: string) => void) {
  useEffect(() => onNotificationClick((e) => {
    const d = e.data as { orderId?: number } | null;
    if (d?.orderId) navigate(`/orders/${d.orderId}`);
  }), [navigate]);
}

async function orderShipped(orderId: number) {
  await notify({ title: 'Order shipped', body: `#${orderId} is on its way`, tag: `order-${orderId}`, data: { orderId } });
}
```

Open the right screen after the tap, even when it started the app from cold, and ignore notifications that carry no data:

```ts
import { useEffect } from 'react';
import { onNotificationClick } from '@/lib/appmintNative';

export function useNotificationRouting(navigate: (path: string) => void) {
  useEffect(() => onNotificationClick(({ tag, data }) => {
    const d = data as { screen?: string; id?: string } | null;
    if (!d?.screen) { console.log('plain notification tapped:', tag); return; }
    navigate(d.id ? `/${d.screen}/${d.id}` : `/${d.screen}`);
  }), [navigate]);
}
```

Send a notification only where notifications exist, with data for the tap (the `data` needs a `tag`):

```ts
import { can, notify, toast } from '@/lib/appmintNative';

export async function announceMessage(chatId: string, from: string, text: string) {
  if (can('notify') === 'none') return;                 // nothing to tap on this surface
  const shown = await notify({ title: from, body: text, tag: `chat-${chatId}`, data: { screen: 'chat', id: chatId } });
  if (!shown) toast('Notifications are turned off');
}
```

**Notes:** Android: the tap travels through the splash and the PIN screen to the page. iOS: the LocalNotifications tap. `data` must be JSON (under 16 KB) and needs a `tag`.

### `appmintNative.torch`

**Example**

The phone's flashlight: `torch.on()`, `off()`, `toggle()`, `isOn()`, `isAvailable()`. Android; `can('torch')` is `'none'` on iPhone and the web.

**Returns:** `on()`/`off()` → Promise (rejects with `code` `'unavailable'` or `'CAMERA_IN_USE'`); `toggle()` → the new state; `isOn()` / `isAvailable()` → boolean. **Needs:** nothing, no permission.

```ts
import { useEffect, useState } from 'react';
import { can, torch } from '@/lib/appmintNative';

export function LightButton() {
  const [on, setOn] = useState(false);
  const [ok, setOk] = useState(false);
  useEffect(() => { if (can('torch') !== 'none') torch.isAvailable().then(setOk); }, []);
  if (!ok) return null;
  return (
    <button aria-pressed={on} aria-label="Flashlight" onClick={async () => setOn(await torch.toggle())}>
      {on ? 'Light off' : 'Light on'}
    </button>
  );
}
```

Switch the light off when the user leaves the screen and show the error the phone reports when it cannot be switched:

```ts
import { useEffect, useState } from 'react';
import { torch } from '@/lib/appmintNative';

export function LightSwitch() {
  const [on, setOn] = useState(false);
  const [err, setErr] = useState('');
  useEffect(() => () => { void torch.off().catch(() => {}); }, []);
  const flip = async () => {
    try { setOn(await torch.toggle()); setErr(''); }
    catch (e) {
      const code = (e as { code?: string }).code;
      setErr(code === 'CAMERA_IN_USE' ? 'Close the camera first' : 'No flashlight on this device');
    }
  };
  return <div><button onClick={flip}>{on ? 'Off' : 'On'}</button>{err && <p role="alert">{err}</p>}</div>;
}
```

Blink the light as a signal, then leave it off:

```ts
import { can, torch } from '@/lib/appmintNative';

export async function blink(times = 3) {
  if (can('torch') === 'none') return;
  for (let i = 0; i < times; i++) {
    await torch.on();
    await new Promise((r) => setTimeout(r, 200));
    await torch.off();
    await new Promise((r) => setTimeout(r, 200));
  }
}
```

**Notes:** switched off when the app closes. While the app's own camera stream is open, light it with the track's `torch` constraint instead.

### `appmintNative.saveToGallery`

**Example**

Saves a photo or video into the phone's gallery — `can('gallery')` is `'native'` in the Android app and `'web'` in a browser (the file downloads there).

**Returns:** Promise of the saved item's URI (`''` on the web). Rejects with `code` `'permission-denied'`, `'download-failed'`, `'bad-source'` or `'failed'`. **Needs:** nothing; no permission on Android 10+.

```ts
import { can, saveToGallery, toast } from '@/lib/appmintNative';

export function SaveButton({ canvas }: { canvas: HTMLCanvasElement }) {
  if (can('gallery') === 'none') return null;
  return (
    <button onClick={() => canvas.toBlob(async (blob) => {
      if (!blob) return;
      try {
        await saveToGallery(blob, { album: 'My Cards', fileName: 'card.png' });
        toast('Saved to gallery');
      } catch (e) {
        toast('Could not save');
      }
    }, 'image/png')}>Save to gallery</button>
  );
}
```

Save a video clip and handle the failure codes it can reject with:

```ts
import { saveToGallery, toast } from '@/lib/appmintNative';

export async function saveClip(url: string) {
  try {
    await saveToGallery(url, { kind: 'video', album: 'My Clips', fileName: 'clip.mp4' });
    toast('Saved to your Movies folder');
  } catch (e) {
    const code = (e as { code?: string }).code;
    toast(code === 'download-failed' ? 'Check your connection and try again' : 'Could not save the clip');
  }
}
```

Save an image from a data URL, and hand it to the share sheet only when it was saved:

```ts
import { can, saveToGallery, share } from '@/lib/appmintNative';

export async function saveAndShare(dataUrl: string) {
  if (can('gallery') !== 'none') await saveToGallery(dataUrl, { album: 'Receipts' });
  await share({ title: 'My receipt', text: 'Here is my receipt' });
}
```

**Notes:** `source` may also be a `data:` URL or an http(s) URL; pass `{ kind: 'video' }` for a video URL without a video MIME type.

### `appmintNative.recognizeText`

**Example**

Reads the text in an image on the device (`can('ocr')`): the Android app's ML Kit recognizer, or Chrome's TextDetector where enabled.

**Returns:** Promise of `{ text, blocks: [{ text, box, lines: [{ text, box }] }] }`, boxes in image pixels. Rejects with `code` `'unavailable'` where `can('ocr')` is `'none'`, or a DOMException when recognition fails. **Needs:** nothing, no permission.

```ts
import { useState } from 'react';
import { can, recognizeText } from '@/lib/appmintNative';

export function ReceiptReader() {
  const [total, setTotal] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  if (can('ocr') === 'none') return null;
  return (
    <label>
      Scan a receipt
      <input type="file" accept="image/*" capture="environment" onChange={async (e) => {
        const file = e.target.files?.[0];
        if (!file) return;
        setBusy(true);
        try {
          const { text } = await recognizeText(file);
          setTotal(/total\s*[:$€£]?\s*([\d.,]+)/i.exec(text)?.[1] ?? 'not found');
        } finally { setBusy(false); }
      }} />
      {busy ? <span role="status">Reading…</span> : total && <strong>Total: {total}</strong>}
    </label>
  );
}
```

Handle the "no recognizer" and failure cases explicitly:

```ts
import { can, recognizeText } from '@/lib/appmintNative';

export async function readLabel(file: Blob): Promise<string> {
  if (can('ocr') === 'none') return '';
  try {
    const { text } = await recognizeText(file);
    return text.trim();
  } catch (e) {
    if ((e as { code?: string }).code === 'unavailable') return '';
    throw e;                                    // a real recognition failure
  }
}
```

Read a script other than Latin, and list each line with its position:

```ts
import { recognizeText } from '@/lib/appmintNative';

export async function readHindiSign(img: HTMLImageElement) {
  const { blocks } = await recognizeText(img, { script: 'devanagari' });
  return blocks.flatMap((b) => b.lines).map((l) => ({ text: l.text, box: l.box }));
}
```

**Notes:** Latin scripts only. The first call on a fresh phone can take a few seconds while Play services fetches the model — show progress.

### `appmintNative.watchIdle`

**Example**

Tells the app when the user goes idle or locks the screen, and when they are back (`can('idle')`). Start it from a tap the first time — the permission request needs one.

**Returns:** Promise of the stop function. The callback gets `{ user: 'active' | 'idle', screen: 'locked' | 'unlocked' }` at start and on every change. Rejects with `code` `'permission-denied'` or `'unavailable'`. **Needs:** nothing, no Android permission.

```ts
import { useRef } from 'react';
import { can, watchIdle } from '@/lib/appmintNative';

export function AwayStatus({ setAway }: { setAway: (away: boolean) => void }) {
  const stop = useRef<(() => void) | null>(null);
  if (can('idle') === 'none') return null;
  return (
    <button onClick={async () => {
      stop.current?.();
      stop.current = await watchIdle((s) => setAway(s.user === 'idle' || s.screen === 'locked'), { thresholdMs: 5 * 60000 });
    }}>Show me as away when idle</button>
  );
}
```

Show an away state and stop watching on unmount; a refused permission is reported with its `code`:

```ts
import { useEffect, useRef, useState } from 'react';
import { can, watchIdle } from '@/lib/appmintNative';

export function useIdleWatch() {
  const stop = useRef<(() => void) | null>(null);
  const [state, setState] = useState<'off' | 'active' | 'idle' | 'denied'>('off');
  useEffect(() => () => stop.current?.(), []);
  // Call this from a tap: the first permission request needs a gesture.
  const start = async () => {
    if (can('idle') === 'none') return;
    try {
      stop.current = await watchIdle((s) => setState(s.user === 'idle' || s.screen === 'locked' ? 'idle' : 'active'));
    } catch (e) {
      if ((e as { code?: string }).code === 'permission-denied') setState('denied');
    }
  };
  return { state, start };
}
```

Pause a polling timer while the user is away and refresh when they are back:

```ts
import { watchIdle } from '@/lib/appmintNative';

export async function pausePollingWhenIdle(refresh: () => void) {
  let timer: ReturnType<typeof setInterval> | undefined = setInterval(refresh, 30000);
  const stop = await watchIdle((s) => {
    const away = s.user === 'idle' || s.screen === 'locked';
    if (away && timer) { clearInterval(timer); timer = undefined; }
    if (!away && !timer) { refresh(); timer = setInterval(refresh, 30000); }
  });
  return () => { stop(); if (timer) clearInterval(timer); };
}
```

**Notes:** the threshold is at least one minute (the spec's floor).

### `appmintNative.appLock`

**Example**

Locks the app again when the user comes back after it was in the background for a while: it asks for fingerprint / face / PIN (`unlock()`), and turns on the privacy screen so the app switcher does not show your content.

**Returns:** a function that stops the lock (call it on sign-out or unmount). `onLocked(true)` fires when the app locks, `onLocked(false)` after a successful unlock. **Needs:** the same as `unlock()` — Android: **Fingerprint** ticked when you build.

```ts
import { useEffect, useState } from 'react';
import { appLock } from '@/lib/appmintNative';

/** Call once in your root component; render a lock screen while it returns true. */
export function useAppLock(): boolean {
  const [locked, setLocked] = useState(false);
  useEffect(() => appLock({
    timeoutSec: 60,                 // lock after 1 minute in the background (default 30)
    title: 'Unlock MyNotes',
    onLocked: setLocked,            // true → cover the screen, false → show the app again
  }), []);                          // appLock returns its own stop function = the cleanup
  return locked;
}
```

In the root component: `const locked = useAppLock(); if (locked) return <LockScreen />;`.

Stop the lock on sign-out, so a signed-out user is not asked to unlock:

```ts
import { appLock } from '@/lib/appmintNative';

let stopLock: (() => void) | null = null;

export function onSignedIn() {
  stopLock = appLock({ timeoutSec: 120, title: 'Unlock your wallet' });
}

export function onSignedOut() {
  stopLock?.();            // removes the listener and turns the privacy screen off
  stopLock = null;
}
```

Offer the lock only where the device can open it, and combine it with `unlock()` for a manual "Lock now" button:

```tsx
import { appLock, can, unlock } from '@/lib/appmintNative';

export function LockSettings({ onLockedChange }: { onLockedChange: (locked: boolean) => void }) {
  if (can('unlock') === 'none') return <p>This device has no screen lock to use.</p>;   // appLock would do nothing here
  return (
    <>
      <button onClick={() => appLock({ onLocked: onLockedChange })}>Turn on app lock</button>
      <button onClick={async () => { onLockedChange(true); if (await unlock('Unlock')) onLockedChange(false); }}>
        Lock now
      </button>
    </>
  );
}
```

**Notes:** If the device has nothing to unlock with (`can('unlock') === 'none'`) it does nothing and returns a no-op — an app lock that could never open would trap the user. If the user cancels, it asks again up to five times, then stays locked (keep your lock screen up) until the app goes to the background and comes back. This is separate from the **PIN Lock** option in the build wizard, which the app shows natively at launch.

### `appmintNative.barcodeFormats`

**Example**

Lists the barcode and QR formats this device's `BarcodeDetector` can read.

**Returns:** `Promise<string[]>` — format names such as `'qr_code'`, `'ean_13'`, `'code_128'`; an empty array when barcode reading is not available here. It never rejects. **Needs:** nothing.

Show the "Scan a product barcode" option only when the device reads that format:

```ts
import { useEffect, useState } from 'react';
import { barcodeFormats } from '@/lib/appmintNative';

export function useCanReadEan(): boolean {
  const [ok, setOk] = useState(false);
  useEffect(() => {
    void barcodeFormats().then((f) => setOk(f.includes('ean_13')));
  }, []);
  return ok;
}
```

Narrow a scan to what the device supports:

```ts
import { barcodeFormats, readBarcodeFrom, type BarcodeFormat } from '@/lib/appmintNative';

declare const photo: File;
const wanted: BarcodeFormat[] = ['qr_code', 'ean_13'];
const have = await barcodeFormats();
const codes = await readBarcodeFrom(photo, wanted.filter((f) => have.includes(f)));
```

Build a format picker that lists only what this device can read:

```tsx
import { useEffect, useState } from 'react';
import { barcodeFormats } from '@/lib/appmintNative';

export function FormatPicker({ onPick }: { onPick: (format: string) => void }) {
  const [formats, setFormats] = useState<string[]>([]);
  useEffect(() => { void barcodeFormats().then(setFormats); }, []);
  if (formats.length === 0) return <p>Barcode reading is not available on this device.</p>;   // [] = none
  return (
    <select onChange={(e) => onPick(e.target.value)}>
      {formats.map((f) => <option key={f} value={f}>{f}</option>)}
    </select>
  );
}
```

**Notes:**
- It asks the browser engine's `BarcodeDetector.getSupportedFormats()`. The Android app's WebView has it (measured: aztec, code_128, code_39, code_93, codabar, data_matrix, ean_13, ean_8, itf, pdf417, qr_code, upc_a, upc_e); many desktop browsers and Safari do not, and answer `[]`.
- This list is about `readBarcodeFrom()` / `scanBarcode()`. `scanCode()` in the app uses Google's scanner instead and does not depend on it.

### `appmintNative.readBarcodeFrom`

**Example**

Reads barcodes and QR codes from a picture the page already has — a photo file, a Blob, an `<img>`, a `<canvas>` or a video frame. No camera is opened.

**Returns:** `Promise<{ value: string; format: string }[]>` — every code found, in the detector's order; an empty array when there is none, when the image cannot be read, or when barcode reading is not available. It never rejects. **Needs:** nothing.

Read a code from a photo the user picks:

```ts
import { can, readBarcodeFrom, toast } from '@/lib/appmintNative';

export function ReadFromPhoto({ onCode }: { onCode: (v: string) => void }) {
  if (can('barcodeScan') === 'none') return null;
  return (
    <label>
      Read a code from a photo
      <input
        type="file"
        accept="image/*"
        onChange={async (e) => {
          const file = e.currentTarget.files?.[0];
          if (!file) return;
          const codes = await readBarcodeFrom(file, ['qr_code']);
          if (codes.length) onCode(codes[0].value);
          else toast('No QR code found in that photo');
        }}
      />
    </label>
  );
}
```

From an image already on the page:

```ts
import { readBarcodeFrom } from '@/lib/appmintNative';

const img = document.querySelector('img#ticket') as HTMLImageElement;
const [first] = await readBarcodeFrom(img);
if (first) console.log(first.format, first.value);
```

Read a code from a canvas frame, checking what the device supports first:

```ts
import { barcodeFormats, readBarcodeFrom } from '@/lib/appmintNative';

export async function scanCanvas(canvas: HTMLCanvasElement): Promise<string | null> {
  const supported = await barcodeFormats();
  if (supported.length === 0) return null;                     // no reader on this device
  const wanted = ['qr_code', 'ean_13'].filter((f) => supported.includes(f)) as ('qr_code' | 'ean_13')[];
  const [hit] = await readBarcodeFrom(canvas, wanted);
  return hit?.value ?? null;
}
```

**Notes:**
- Uses the browser engine's `BarcodeDetector` (present in the Android app's WebView; missing in Safari and many desktop browsers — then it returns `[]`).
- A plain `<input type="file" accept="image/*">` opens the Android photo picker and needs no storage permission.
- Narrow `formats` when you know them: it is faster and misreads less. `barcodeFormats()` lists what the device supports.

### `appmintNative.scanBarcode`

**Example**

Opens the camera in a `<video>` YOU place in your layout, and resolves with the first barcode or QR code it sees.

**Returns:** `Promise<{ value: string; format: string } | null>` — the first code, or `null` when cancelled (the `signal` aborted), when the camera was refused, or when scanning is not available. It never rejects and always stops the camera. **Needs:** camera access — in the Android app tick **Camera** in the permissions list when you build.

Use it only when the viewfinder must sit inside your own screen; otherwise `scanCode()` is simpler.

```ts
import { useEffect, useRef, useState } from 'react';
import { can, scanBarcode, haptic } from '@/lib/appmintNative';

export function InlineScanner({ onCode }: { onCode: (v: string) => void }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [error, setError] = useState('');

  useEffect(() => {
    const video = videoRef.current;
    if (!video || can('barcodeScan') === 'none') return;
    const ctl = new AbortController();
    void scanBarcode(video, { signal: ctl.signal, formats: ['qr_code'] }).then((hit) => {
      if (hit) { haptic('success'); onCode(hit.value); }
      else if (!ctl.signal.aborted) setError('Camera not available. Type the code instead.');
    });
    return () => ctl.abort();          // leaving the screen stops the camera
  }, [onCode]);

  return (
    <div>
      <video ref={videoRef} className="aspect-square w-full object-cover" muted playsInline />
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
```

Use the front camera:

```ts
import { scanBarcode } from '@/lib/appmintNative';

declare const video: HTMLVideoElement;
const hit = await scanBarcode(video, { facingMode: 'user' });
```

Close the scanner after the first hit and offer a typed code when the camera is unavailable:

```ts
import { useRef, useState } from 'react';
import { can, scanBarcode } from '@/lib/appmintNative';

export function TicketCheck({ onCode }: { onCode: (v: string) => void }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const ctl = useRef<AbortController | null>(null);
  const [typed, setTyped] = useState(false);

  async function start() {
    if (!videoRef.current || can('barcodeScan') === 'none') { setTyped(true); return; }
    ctl.current = new AbortController();
    const hit = await scanBarcode(videoRef.current, { signal: ctl.current.signal, formats: ['ean_13', 'code_128'] });
    if (hit) onCode(hit.value);
    else if (!ctl.current.signal.aborted) setTyped(true);   // null and not cancelled: no camera
  }

  return (
    <div>
      <video ref={videoRef} muted playsInline />
      <button onClick={start}>Scan ticket</button>
      <button onClick={() => ctl.current?.abort()}>Cancel</button>
      {typed && <input placeholder="Type the ticket number" onBlur={(e) => onCode(e.target.value)} />}
    </div>
  );
}
```

**Notes:**
- It calls `getUserMedia` (so the user sees the camera permission prompt) and decodes with `BarcodeDetector` on each frame. `can('barcodeScan')` is `'none'` where either is missing.
- The build scans your code: a `scanBarcode(` call switches **Camera** on for you. If you turned it off by hand, the camera request fails and you get `null`.
- Start it from a user action (a Scan button or opening a scan screen), never on app launch. Handle `null` as a normal outcome with a manual-entry option.

### `appmintNative.scanCode`

**Example**

Opens a full-screen scanner and resolves with the first barcode or QR code — one call, nothing to draw.

**Returns:** `Promise<ScanCodeResult | null>` — `{ value, format, type?, displayValue?, url?, wifi?, phone?, email?, sms?, geo?, contact? }`, or `null` when the user closed the scanner. Anything else rejects with an `Error` whose `code` is `'unavailable'` (no scanner here — Play services missing in the app; no `BarcodeDetector` or camera on the web), `'busy'` (a scan is already open), `'camera-denied'` (web: camera permission refused) or `'failed'`. **Needs:** nothing in the app (Google's scanner needs no camera permission).

From a Scan button:

```ts
import { can, scanCode, haptic, toast } from '@/lib/appmintNative';

export function ScanButton({ onCode }: { onCode: (v: string) => void }) {
  if (can('codeScanner') === 'none') return null;       // nothing can scan here
  return (
    <button
      onClick={async () => {
        try {
          const hit = await scanCode({ formats: ['qr_code'] });
          if (!hit) return;                               // closed: a normal outcome
          haptic('success');
          onCode(hit.value);
        } catch (e) {
          const code = (e as { code?: string }).code;
          toast(code === 'camera-denied' ? 'Allow the camera to scan.' : 'Scanning is not available right now.');
        }
      }}
    >
      Scan QR code
    </button>
  );
}
```

Use the parsed parts the app fills in (Wi-Fi, links, contacts …), with a typed-in fallback in Google's scanner:

```ts
import { scanCode } from '@/lib/appmintNative';

const hit = await scanCode({ manualInput: true });
if (hit?.wifi) console.log('Network', hit.wifi.ssid, hit.wifi.encryption);
else if (hit?.url) window.open(hit.url.url, '_blank');
else if (hit) console.log(hit.type ?? 'text', hit.value);
```

**Notes:**
- In the app this is Google's code scanner (Play services, through the shell's `AppMint.scanCode`): auto-zoom, no camera prompt. `type` is `'url'`, `'wifi'`, `'text'`, `'phone'`, `'email'`, `'sms'`, `'geo'`, `'contact'`, `'calendar'`, `'isbn'`, `'product'` or `'driver_license'`, and the matching field is filled. `manualInput` lets the user type the code.
- On the web it draws its own full-screen camera layer (close button, back gesture closes it) and decodes with `BarcodeDetector`; only `value` and `format` are filled there, and the browser asks for camera permission.
- `null` always means the user closed the scanner; every other outcome rejects with a `code`, so show a message in `catch`.
- Call it from a tap, never on page load. Narrow `formats` when you know them.

### `appmintNative.passkeys`

**Example**

Passkey sign-in and registration with the JSON options your passkey server makes. In the Android app it uses Android's passkey manager; on the web it uses `navigator.credentials`.

**Returns:** `available()` → `'native' | 'web' | 'none'`; `create(optionsJSON)` → Promise of the RegistrationResponseJSON; `get(optionsJSON)` → Promise of the AuthenticationResponseJSON (POST either back to your server). They reject with a `DOMException` (`NotAllowedError`, `InvalidStateError`, `SecurityError`, `NotSupportedError`). `origin()` → string, `assetLinks()` → array (app only; `''` / `[]` on the web). **Needs:** a passkey server; in the app also Android 9+ with Google Play services and your domain's `assetlinks.json`.

```ts
import { passkeys } from '@/lib/appmintNative';

export async function signInWithPasskey(): Promise<boolean> {
  if (passkeys.available() === 'none') return false;          // hide the passkey button
  const options = await (await fetch('https://your-domain.com/passkey/login-options')).json();
  try {
    const answer = await passkeys.get(options);
    const res = await fetch('https://your-domain.com/passkey/login-verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer),
    });
    return res.ok;
  } catch (e) {
    if ((e as DOMException).name === 'NotAllowedError') return false;   // cancelled / none here
    throw e;
  }
}

export async function addPasskey() {
  const options = await (await fetch('https://your-domain.com/passkey/register-options')).json();
  const answer = await passkeys.create(options);   // InvalidStateError = already registered
  await fetch('https://your-domain.com/passkey/register-verify', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer),
  });
}
```

One-time setup values, from the app installed via Google Play:

```ts
import { passkeys } from '@/lib/appmintNative';

console.log(JSON.stringify(passkeys.assetLinks(), null, 2));  // → /.well-known/assetlinks.json
console.log(passkeys.origin());                               // → add to expected origins
```

Offer the passkey button only where it can work, and say so when it cannot:

```ts
import { passkeys } from '@/lib/appmintNative';

export function PasskeyButton({ onClick }: { onClick: () => void }) {
  const kind = passkeys.available();          // 'native' | 'web' | 'none'
  if (kind === 'none') return <p>Passkeys are not available on this device.</p>;
  return <button onClick={onClick}>{kind === 'native' ? 'Use your phone passkey' : 'Use a passkey'}</button>;
}
```

**Notes:** iOS answers `'none'` (the app is served from `capacitor://localhost`, which no relying party can be). A page bundled in the app has no domain: your server's options must set `rp.id` / `rpId` to your domain. Passkey autofill (`mediation: 'conditional'`) is not available in the app — start from a button.

### `appmintNative.ads`

**Example**

AdMob ads for AI-built apps: a rewarded "Watch ad to earn" button, interstitials at natural breaks, and the banner. The same events arrive on Android and iOS.

**Returns:** `ads.available()` → `'native' | 'none'` (`'none'` on the web and in a build with no ad format switched on). `ads.formats()` → `{rewarded, interstitial, banner}`. `ads.isRewardedAdReady()` / `ads.isInterstitialAdReady()` / `ads.isPremium()` → boolean. `ads.showRewardedAd()` / `ads.showInterstitialAd()` → `Promise<void>`; the outcome comes as the `appmint:reward`, `appmint:ad-closed` and `appmint:ad-unavailable` events. `ads.banner.show()` → `Promise<boolean>` (`true` once an ad is in the slot), `ads.banner.hide()` → `Promise<void>`. `ads.startRemoveAdsPurchase()` → void. **Needs:** AdMob ad units in the build.

A rewarded button: shown only in the app, called only from the tap, reward granted only in the event:

```ts
import { useEffect, useState } from 'react';
import { ads } from '@/lib/appmintNative';

export function WatchAdButton({ onReward }: { onReward: (amount: number) => void }) {
  const [ready, setReady] = useState(false);
  const [note, setNote] = useState('');

  useEffect(() => {
    if (ads.available() === 'none') return;
    const refresh = () => setReady(ads.isRewardedAdReady());
    const onRewardEvt = (e: Event) => onReward((e as CustomEvent<{ type: string; amount: number }>).detail.amount);
    const onClosed = (e: Event) => {
      const d = (e as CustomEvent<{ adType: string; earned: boolean }>).detail;
      if (d.adType === 'rewarded' && !d.earned) setNote('Watch to the end to get the reward.');
      refresh();
    };
    const onUnavailable = (e: Event) => {
      const d = (e as CustomEvent<{ adType: string; reason: string }>).detail;
      if (d.reason === 'ad_free') onReward(1);                 // paid to remove ads
      else if (d.reason !== 'declined') setNote('No ad right now. Try again soon.');
      refresh();
    };
    window.addEventListener('appmint:reward', onRewardEvt);
    window.addEventListener('appmint:ad-closed', onClosed);
    window.addEventListener('appmint:ad-unavailable', onUnavailable);
    refresh();
    const t = setInterval(refresh, 2000);
    return () => {
      clearInterval(t);
      window.removeEventListener('appmint:reward', onRewardEvt);
      window.removeEventListener('appmint:ad-closed', onClosed);
      window.removeEventListener('appmint:ad-unavailable', onUnavailable);
    };
  }, [onReward]);

  if (ads.available() === 'none' || !ads.formats().rewarded) return null;   // web: no ad UI
  return (
    <>
      <button disabled={!ready} onClick={() => { setNote(''); void ads.showRewardedAd(); }}>Watch an ad for 1 coin</button>
      {note && <p role="status">{note}</p>}
    </>
  );
}
```

An interstitial between levels:

```ts
import { ads } from '@/lib/appmintNative';

export async function betweenLevels(next: () => void) {
  if (ads.isInterstitialAdReady()) await ads.showInterstitialAd();   // skipped when none is loaded
  next();
}
```

**Notes:** These calls work in both of the wizard's ad-control modes. **Automatic:** the rewarded and interstitial calls go to the app's own ad pipeline (`WebToApk.showRewardedAd()`), and the app places the banner itself (`banner.show()` resolves `false`, `formats().banner` is `false`). **Developer-controlled:** they go through `window.AppMintAds`, and `banner.show()` places the banner in an element with id `ad-slot`; `banner.hide()` removes it. On every platform a rewarded call not made from the user's tap first asks "Watch an ad?"; "No thanks" answers `appmint:ad-unavailable` with reason `declined` (the example above ignores that one). On iOS the AdMob plugin is used.

### `appmintNative.setAdFreeProduct`

**Example**

Names the product that removes ads on iOS, so `ads.isPremium()` and `ads.startRemoveAdsPurchase()` work there too.

**Returns:** nothing. **Needs:** on iOS, the product created in App Store Connect with the same ID. On Android it is not needed: the build wizard's **Remove Ads (IAP)** product ID does this job, and the bridge answers `isPremium` itself.

Call it once at start, before any ad or premium UI reads `isPremium()`:

```ts
import { setAdFreeProduct, ads } from '@/lib/appmintNative';

setAdFreeProduct('remove_ads');           // same id as the "ad-free product" in your plan

export function RemoveAdsButton() {
  if (ads.available() === 'none' || ads.isPremium()) return null;
  return <button onClick={() => ads.startRemoveAdsPurchase()}>Remove ads</button>;
}

window.addEventListener('appmint:premium', (e) => {
  const { premium } = (e as CustomEvent<{ premium: boolean }>).detail;
  if (premium) document.body.classList.add('premium');
});
```

**Notes:** On iOS, without this call `ads.startRemoveAdsPurchase()` answers `appmint:purchase-failed` `{productId:'', reason:'disabled'}`. After it, `startRemoveAdsPurchase()` buys the product through `store.purchase(id)`, and `appmint:premium` `{premium:true}` fires when the purchase or the launch restore shows it is owned. It changes nothing on the web.

### `appmintNative.store`

**Example**

In-app purchases for AI-built apps: Play Billing on Android and StoreKit 2 on iOS, with the same product IDs and the same `appmint:*` events on both.

**Returns:** `store.available()` → `'native' | 'none'` (`'none'` on the web). `store.getProducts(requestId, ids)` → `Promise<StoreProduct[]>` (`{productId, type:'inapp'|'subs', title, description, price, currency, period?, owned}`), also sent as `appmint:products`. `store.purchase(id)` → `Promise<boolean>` (on Android `true` only means the sheet was asked for; the result is the event). `store.isOwned(id)` → boolean. `store.getOwnedProducts()` → `string[]`. `store.restore()` → `Promise<string[]>`. **Needs:** **💰 Sell In-App Products** and the products created in Play Console / App Store Connect.

A paywall: live price from the store, unlock only on the purchase event:

```ts
import { useEffect, useState } from 'react';
import { store, type StoreProduct } from '@/lib/appmintNative';

export function ProPaywall({ onUnlock }: { onUnlock: () => void }) {
  const [pro, setPro] = useState<StoreProduct | null>(null);
  const [busy, setBusy] = useState(false);
  const [note, setNote] = useState('');

  useEffect(() => {
    if (store.available() === 'none') return;
    if (store.isOwned('pro_unlock')) onUnlock();
    void store.getProducts('paywall', ['pro_unlock']).then((list) => setPro(list[0] ?? null));

    const onPurchase = (e: Event) => {
      if ((e as CustomEvent<{ productId: string }>).detail.productId === 'pro_unlock') onUnlock();
      setBusy(false);
    };
    const onFailed = (e: Event) => {
      const { reason } = (e as CustomEvent<{ productId: string; reason: string }>).detail;
      setBusy(false);
      setNote(reason === 'pending' ? 'Payment pending. It unlocks once the store confirms.' : `Purchase unavailable (${reason}).`);
    };
    const onOwned = (e: Event) => {
      if ((e as CustomEvent<{ products: string[] }>).detail.products.includes('pro_unlock')) onUnlock();
    };
    const onVisible = () => { if (!document.hidden) setBusy(false); };   // a cancel sends no event on Android
    window.addEventListener('appmint:purchase', onPurchase);
    window.addEventListener('appmint:purchase-failed', onFailed);
    window.addEventListener('appmint:owned-changed', onOwned);
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      window.removeEventListener('appmint:purchase', onPurchase);
      window.removeEventListener('appmint:purchase-failed', onFailed);
      window.removeEventListener('appmint:owned-changed', onOwned);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [onUnlock]);

  if (store.available() === 'none') return <p>Available in the app.</p>;
  if (!pro) return null;                          // not created in the store yet: hide the offer
  return (
    <>
      <button disabled={busy} onClick={() => { setBusy(true); void store.purchase('pro_unlock'); }}>
        Unlock Pro · {pro.price}
      </button>
      <button onClick={() => void store.restore()}>Restore purchases</button>
      {note && <p role="status">{note}</p>}
    </>
  );
}
```

**Notes:** `appmint:purchase-failed` reasons: `disabled`, `not_found`, `billing_error`, `pending`, and on iOS also `cancelled`; Android sends no event for a plain cancel. `store.restore()` re-reads the owned list on Android (it restores at every launch anyway) and asks the App Store on iOS; App Review expects a "Restore purchases" button. Show `price` as the store gives it; never store it.

