# Writing HTML for your app

> How a page inside the app differs from a page in a browser tab, and the rules that keep it working.

- **Applies to:** AppMint
- **Source:** the Integration Guide shipped inside the app; this page is generated from it.
- **HTML:** https://freewebtoapk.com/docs/writing-html-for-your-app

What the app runtime exposes to your page - contacts, SMS, notifications, device info, biometrics - and the rules for calling it correctly

### 1. Rule 1 - always check first

window.WebToApk only exists inside your built app. In a browser it is missing.

So always check before you call. If you do not, your page breaks everywhere except inside the app.

Some things need no check at all: navigator.vibrate(), speechSynthesis and Notification already work - the app connects them for you. Just use the normal web code.

```
// GOOD — safe everywhere
if (window.WebToApk) {
  WebToApk.playClick();
}

// BAD — this breaks your page in a browser
WebToApk.playClick();
```

### 2. Rule 2 - some answers come back later

Reading contacts, SMS or the call log takes time. So these do not answer straight away. If they did, your app would freeze.

You give the call a name (an id). The answer arrives later as an event. You listen for that event.

Event names: appmint:contacts, appmint:calllog, appmint:sms, appmint:sms-received, appmint:notification-action.

```
// 1. Listen for the answer
window.addEventListener('appmint:contacts', function (e) {
  if (e.detail.error) {
    alert('Problem: ' + e.detail.error);
    return;
  }
  console.log(e.detail.contacts);
});

// 2. Ask the question ('my1' is any name you choose)
WebToApk.listContacts('my1', 50, 0);
```

### 3. Rule 3 - two switches must be ON

A phone feature needs TWO things:

1. You turned it on in Step 3 (Permissions) when you built the app. If not, you get error 'not_enabled' and the user sees nothing.

1. The user said Yes on the phone. If they said No, you get error 'permission_denied'.

Always handle both errors so the user knows what happened.

Easier way: pickContact() and composeSms() need NO permission at all. Use them when they fit - they do the same job with less trouble.

```
window.addEventListener('appmint:contacts', function (e) {
  if (e.detail.error === 'not_enabled') {
    alert('Turn on Contacts when you build the app');
  } else if (e.detail.error === 'permission_denied') {
    alert('Please allow contacts access');
  } else {
    showContacts(e.detail.contacts);
  }
});
```

### 4. Notifications with pictures and buttons

notify() takes a list of options. You can add a big picture, an icon, buttons, and a progress bar.

channel can be: urgent, default, quiet, or ongoing.

Use ongoing:true for a notification the user cannot swipe away. Note: they can still remove it from Android settings. No app can make one that is impossible to remove.

Use tag to give it a name. Sending again with the same tag replaces the old one instead of adding a new one.

```
WebToApk.notify(JSON.stringify({
  title: 'Order shipped',
  body: 'Arriving Tuesday',
  channel: 'urgent',
  image: 'https://mysite.com/box.jpg',
  actions: [{ id: 'track', label: 'Track' }],
  tag: 'order-482'
}));

// Know which button the user pressed
window.addEventListener('appmint:notification-action', function (e) {
  if (e.detail.actionId === 'track') showTracking();
});
```

### 5. Phone information and fingerprint

getDeviceInfo() needs no permission. It gives you the Android version, the version ID (buildId), the phone model, screen size, battery level, and more.

About device ID: there is NO IMEI or serial number. Android blocked this for every app from Android 10. Use getInstallId() instead - a fixed ID for this install that stays the same after updates.

```
var info = JSON.parse(WebToApk.getDeviceInfo());
info.android.release;      // "14"
info.android.buildId;      // "TQ3A.230805.001"  <- version ID
info.hardware.model;       // "Pixel 7"
info.runtime.batteryLevel; // 82

// Fingerprint
window.__webToApkAuth = window.__webToApkAuth || {};
window.__webToApkAuth['unlock'] = function (json) {
  var r = JSON.parse(json);
  if (r.ok) showMyApp();
};
WebToApk.authenticateBiometricEx('unlock', JSON.stringify({
  title: 'Unlock',
  allowDeviceCredential: true
}));
```

### 6. Make your page fill the screen

When you turn on Fullscreen, the app hides the Android bars for you. But add this CSS so your content is not hidden under the phone's notch.

Important: Fullscreen hides the ANDROID bars (clock, battery, back buttons). Your app's own coloured bar at the top is a different switch called 'Show top bar'. If you still see a bar after turning on Fullscreen, that is the one to turn off.

Also: test your app rotated. The notch moves to the side in landscape.

```
body {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  min-height: 100vh;   /* old phones */
  min-height: 100dvh;  /* correct when bars are hidden */
}
```

### 7. Downloads keep the filename you chose

Your normal download code already works. When your page saves a file - a PDF report, a CSV export, a backup - the app catches it and opens the Android 'Save as…' sheet with YOUR filename already filled in.

This covers every usual way of doing it: <a download="...">, a.click() from code, html2pdf / jsPDF, FileSaver.js saveAs(), and window.open() on a blob URL. A File object brings its own name with it.

If you would rather ask directly instead of building an <a> tag, use AppMint.downloadFile().

One thing to know: the name lives only in your page's JavaScript - Android never sees it on its own. So set download= (or pass a name) every time, or the file is saved as 'download'.

```
// The usual way — saves as Site_Report_2026-08.pdf
const blob = await html2pdf().from(el).outputPdf('blob');
const a = document.createElement('a');
a.download = 'Site_Report_2026-08.pdf';
a.href = URL.createObjectURL(blob);
a.click();

// Or ask directly
AppMint.downloadFile(base64String, 'Ledger_Q3.csv', 'text/csv');
AppMint.saveBlob(myBlob, 'Backup.json');
```

### 8. Make the Back button do what YOUR app expects

If your app changes screens by showing and hiding elements (no URL changes, no history.pushState), Android's Back button cannot see those screens - its history is empty, so Back exits the app from anywhere.

Register a back handler and decide yourself: return true when you handled the press (closed a menu, went back a screen), return false to let the normal behaviour run - page history back, then the exit confirmation, then exit.

Apps that use history.pushState for every screen do not need this: Back already walks their history. And don't worry about freezing the app - if your handler ever hangs, the phone's Back keeps working natively after a short moment.

```
AppMint.setBackHandler(() => {
  if (isMenuOpen)   { closeMenu();  return true; }   // consumed
  if (screen !== 'home') { goTo('home'); return true; }
  return false;   // nothing open — normal exit behaviour
});

// Or listen instead: e.preventDefault() consumes the press
window.addEventListener('appmint:back', e => {
  if (closeTopmost()) e.preventDefault();
});
```

### 9. Receive what people share to your app - links, photos, videos, files

Turn on 'Receive shares from other apps' in the build wizard and tick what your app accepts: text and links, data files (JSON, CSV, TXT, XML, Markdown, GPX, TCX, FIT), photos, videos, audio, documents (PDF, Word, Excel, PowerPoint). Your app then appears in the Share menu of every other app for exactly those kinds - a notes app for text, a player for videos.

A link or text (sharing a YouTube video from the YouTube app sends its link) is read with WebToApk.getSharedText() and announced as appmint:shared. A file is asked for with AppMint.getOpenedFile() - ask whenever you are ready, the file waits for you. This matters for React, Vue and Angular apps: they finish starting AFTER the page loads, so an app that only listened for the event used to miss the file and just show its home screen.

f.kind tells you what arrived: image, video, audio, document or data. Photos, videos and audio come as f.url - a streamable, seekable address you put straight into <video src>, <img src> or <audio src>, so a 300 MB clip never has to fit in memory; f.getFile() fetches the File when you really need the bytes. Data files and documents also come with f.file, plus f.text for text formats and f.base64 for small binaries as shortcuts. A photo shared with a caption delivers the caption as shared text too.

```
// A link or text someone shared
const shared = JSON.parse(WebToApk.getSharedText() || 'null');
if (shared) saveLink(shared.url || shared.text);
window.addEventListener('appmint:shared', e => saveLink(e.detail.url || e.detail.text));

// A photo, video, audio clip, document or data file — ask on startup, however late
const f = await AppMint.getOpenedFile();   // null if opened normally
if (f) {
  if (f.kind === 'video')         player.src = f.url;                 // streams, seekable
  else if (f.kind === 'image')    img.src = f.url;
  else if (f.kind === 'document') await upload(await f.getFile());   // a real File
  else importActivity(f.text ?? await f.file.text());               // data files
}

// Or listen, if you prefer
window.addEventListener('appmint:fileopen', e => handle(e.detail));
```

### 10. Bluetooth sensors - standard Web Bluetooth

Heart-rate straps, cadence and speed pods, power meters, smart trainers, scales - anything that speaks Bluetooth LE.

Use navigator.bluetooth, exactly the same code you would write for Chrome on a desktop. AppMint provides it inside the app; a plain Android WebView has none, which is why this code does nothing in other app builders.

The device chooser is drawn by the app, like the browser's - your page can only reach the device the user picked.

Turn on the Bluetooth permission in Step 3. If your code mentions navigator.bluetooth, AppMint turns it on for you when you pick your ZIP.

Not available: getDevices(), watchAdvertisements() and requestLEScan() - they reject with NotSupportedError, so check before using them.

```
const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: ['heart_rate'] }]
});
const server  = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const chr     = await service.getCharacteristic('heart_rate_measurement');

chr.addEventListener('characteristicvaluechanged', e => {
  const v = e.target.value;   // a DataView
  const bpm = (v.getUint8(0) & 1) ? v.getUint16(1, true) : v.getUint8(1);
  document.getElementById('bpm').textContent = bpm;
});
await chr.startNotifications();

device.addEventListener('gattserverdisconnected', () => showReconnect());
```

### 11. Photo upload - the Android photo picker, no permission

Use a normal file input. Nothing else to add.

In the app, <input type="file" accept="image/*"> opens the Android photo picker: the user's photos in a grid, and your page gets only the ones they choose. No storage permission, no 'Allow access to photos?' popup. It works on old phones too - Google Play services installs the picker there.

- accept="image/*" - photos only
- accept="video/*" - videos only
- accept="image/*,video/*" - both
- add multiple - the user can pick several
- add capture="environment" - opens the camera instead (needs Camera ticked in Step 3)

Mix in other files (accept="image/*,.pdf") and the normal file picker opens instead, so the PDF can be chosen too.

```
<input type="file" id="photos" accept="image/*" multiple>

<script>
document.getElementById('photos').onchange = function (e) {
  for (const file of e.target.files) {
    const img = document.createElement('img');
    img.src = URL.createObjectURL(file);
    document.body.appendChild(img);
  }
};
</script>
```

### 12. Scan a QR code or barcode - one line

AppMint.scanCode() opens Google's scanner screen. The user points the camera, and you get the code back.

No camera permission. No video element. Nothing to draw. The scanner zooms in by itself for small codes. The first time on a phone, Google Play services downloads the scanner (a few seconds).

The answer has value (the text in the code) and format (qr_code, ean_13, …). For common QR codes you also get the parts ready: url, wifi (ssid, password), phone, email, sms, geo, contact.

If ok is false, code tells you why: cancelled (the user closed it), unavailable (no Google Play services, or the download failed), busy (a scan is already open).

AppMint.scanCode only exists inside the app - check for it first.

```
async function scan() {
  if (!window.AppMint || !AppMint.scanCode) return;   // in a browser

  const r = await AppMint.scanCode({ formats: ['qr_code'] });
  if (!r.ok) return;                                  // closed, or no scanner

  console.log(r.value);          // the text inside the code
  if (r.url) location.href = r.url.url;
}

// Any code type: AppMint.scanCode()
// Let the user type it when it will not scan: AppMint.scanCode({ manualInput: true })
```

### 13. Passkeys - sign in with fingerprint or face, no password

If your website already has passkeys, they now work inside your app too. A normal Android WebView cannot do passkeys; AppMint connects navigator.credentials to Android's passkey manager, so libraries like SimpleWebAuthn, Hanko, Corbado or Auth0 work unchanged.

You need a server that makes passkey options and checks the answer (for example SimpleWebAuthn on your backend). A passkey cannot be checked inside the page.

Two things to set up once:

1. Your website must say it trusts the app. Put the file from AppMint.passkey.assetLinks() at https://YOUR-DOMAIN/.well-known/assetlinks.json. Take it from the app installed FROM Google Play - Play signs your app with its own key, so the file from a test APK will not match Play users. The domain must be your server's rpID, exactly (www and no-www are different).

1. Your server must accept the app's origin. The answer from the app says origin 'android:apk-key-hash:…' instead of https://… - get that value from AppMint.passkey.origin() and add it to your server's allowed origins.

Errors: NotAllowedError = the user cancelled or has no passkey on this phone. InvalidStateError = this passkey already exists. SecurityError = assetlinks.json does not list the app.

Passkeys need Android 9 or newer with Google Play services - AppMint.passkey.isAvailable() tells you. Passkey autofill in a text field (mediation: 'conditional') is not available in apps; start sign-in from a button.

```
// Your normal passkey code works as it is:
const cred = await navigator.credentials.get({ publicKey: options });

// Or with your server's JSON options (SimpleWebAuthn style):
const options = await (await fetch('/passkey/login-options')).json();
const answer = await AppMint.passkey.get(options);
await fetch('/passkey/login-verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(answer)
});

// Once, on the Play-installed app — copy these to your server setup:
console.log(JSON.stringify(AppMint.passkey.assetLinks(), null, 2));
console.log(AppMint.passkey.origin());
```

### 14. {exampleCount} ready-made examples - open them here

Working code you can copy: send an SMS, read contacts, show a notification with a picture, fingerprint lock, phone information, saving files, Bluetooth sensors, and more. Each one is short and explained in simple words.

Opens inside AppMint. You can search it, and you can select the text to copy it into your page.

The same screen also has 'All methods' - the full list of all {methodCount} things your app can call. That list is built from the app runtime itself, so it always matches exactly what your app can do.

[Open examples & method list](appmint:api-reference)

### 15. Save the examples to your phone or computer

Saves three files to Downloads/AppMint/Guides:

- Examples - the {exampleCount} examples above
- Method list - all {methodCount} methods
- index.html - a ready test page

The test page has buttons for device info, vibration, notifications and the contact picker. Build it as an app to see everything working, then change it into your own app. It also opens fine in a normal browser - nothing breaks, it just says it is not inside the app.

**Download** (in the app these are saved to your phone by the button on this step):

- [AppMint-examples.md](https://freewebtoapk.com/docs/files/hybrid_examples.md) - 32 KB
- [AppMint-all-methods.md](https://freewebtoapk.com/docs/files/hybrid_api_reference.md) - 59 KB
- [index.html](https://freewebtoapk.com/docs/files/hybrid_starter.html) - 6 KB

