JavaScript bridge API
appmintNative (AI-built apps)
The typed wrappers every AI-built project imports from @/lib/appmintNative. Each picks the native path inside the app and a web path, or an honest none, in a browser - one call works everywhere.
appmintNative.actionSheet appmintNative#
actionSheet(title: string, options: { title: string; destructive?: boolean }[]): Promise<number | null>
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.
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:
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:
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;
destructiveuses 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
optionslist resolvesnullat once. For any content other than a list of actions, usesheet().
appmintNative.ActionSheet appmintNative#
import { ActionSheet } from '@/lib/appmintNative'
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.
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:
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:
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;
destructiveuses 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
optionslist resolvesnullat once. For any content other than a list of actions, usesheet().
appmintNative.addContact appmintNative#
addContact(contact: Contact): Promise<boolean>
Example
Saves a new contact into the phone's address book. Android app only: the shell's bridge saves it directly (no Contacts screen opens).
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: 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.
Save a customer from a form.
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
}
}Notes: It saves at once - no contact editor opens - so confirm with the user in your own screen first. It 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.App appmintNative#
App.getInfo(): Promise<{ name, id, build, version }> / App.exitApp(): void
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.
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:
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();
}Notes: In the app getInfo reads the Capacitor App plugin; 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 in the app and does nothing on the web, so never make Quit the only way forward. For pause/resume events and deep links see Capacitor.Plugins.App, launchUrl() and onDeepLink().
appmintNative.appLauncher appmintNative#
appLauncher.canOpen(url): Promise<boolean> / appLauncher.open(url): Promise<boolean>
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.
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):
import { appLauncher } from '@/lib/appmintNative';
await appLauncher.open('tel:+15551234567');
await appLauncher.open('mailto:[email protected]?subject=Order%2042');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.appLock appmintNative#
appLock({ timeoutSec?, title?, onLocked? }) — from '@/lib/appmintNative'
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.
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 />;.
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.appUpdate appmintNative#
appUpdate.check(): Promise<AppUpdateInfo | null> / appUpdate.start(): Promise<boolean>
Example
Checks Google Play for a newer version and starts Play's in-app update (downloads and installs in place).
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>: true only when Play's update completed, false when the user cancelled, it failed or none was available. Needs: nothing; the app must be installed from Google Play.
import { appUpdate } from '@/lib/appmintNative';
async function offerUpdate() {
const info = await appUpdate.check();
if (!info?.inPlace) return; // Play can install an update now
showBanner('A new version is ready', async () => {
const done = await appUpdate.start(); // Play's full-screen flow / the App Store page
if (!done) showBanner('Update later from the store.', null);
});
}Notes: updateAvailable is Play's own answer; inPlace adds that Play can run its in-app update right now. availableVersion is Play's version CODE (Play gives no name) - compare it with currentVersionCode, never with currentVersion, which is the version name.
appmintNative.backgroundSync appmintNative#
import { backgroundSync } from '@/lib/appmintNative'
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 in the Android app (it uses WorkManager). On the web can('backgroundSync') is 'none' and enqueue / schedule resolve false.
Durable auto-save: the same id replaces the save that has not been sent yet.
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:
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):
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.badge appmintNative#
import { badge } from '@/lib/appmintNative'
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 (it uses the built-in Badge plugin).
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();
}Notes: on Android the look 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.barcodeFormats appmintNative#
barcodeFormats(): Promise<string[]>
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:
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:
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)));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.bleSensors appmintNative#
import { bleSensors } from '@/lib/appmintNative'
Example
Standard Bluetooth fitness sensors - heart rate, cycling speed/cadence, power, running speed/cadence and sensor battery - with the numbers already parsed, in the Android app.
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.
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>
);
}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. Speed needs two packets and the wheel size (default 2096 mm). The same messages also go to window.onAppMintBle; listening to the event is simpler. For any other Bluetooth device use navigator.bluetooth.
appmintNative.brightness appmintNative#
brightness.get() / brightness.set(value) / brightness.restore()
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.
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 />;
}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). get() reads the window's override, so it is -1 until you call set() - it is not the system level.
appmintNative.browser appmintNative#
browser.open(url, { toolbarColor? }) / browser.close() / browser.onClosed(cb)
Example
Opens a page in an in-app browser tab (Chrome Custom Tabs) - 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.
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.'); }
}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 the app; on the web it does nothing. onClosed never fires on the web.
appmintNative.calendar appmintNative#
calendar.requestPermission / createEvent / listEvents / deleteEvent / openCalendar
Example
Adds, lists and deletes events in the user's OWN phone calendar (Android's calendar provider), 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 in Step 4 (Access) when you build - the build ticks it for you when it sees calendar.createEvent(, calendar.listEvents( or calendar.requestPermission( in the code.
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:
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());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.camera appmintNative#
camera.take(opts?) / camera.pick(opts?)
Example
Takes a photo with the camera or picks photos from the gallery: the Capacitor Camera plugin in the app, 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: Camera in Step 4 (Access) - the build turns it on when your code calls camera.take.
Take a profile photo with the front camera.
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.
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.
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.can appmintNative#
can(cap: Capability): 'native' | 'web' | 'none'
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:
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:
import { can } from '@/lib/appmintNative';
const contacts = can('contactList') !== 'none'; // 'none' on the web
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.
Notes:
- A Capacitor plugin baked into the app always answers
'native'; otherwise the shell bridge is checked, then the web standard. toast,actionSheet,dialog,statusBarandswipehave a real web version, so they are not'none'in a browser.contactList,contactAdd,smsSend,smsList,callLog,shortcuts,shareTargetandwidgetare'none'on the web.- Some features exist only when the build switched them on (folder access, secure keys, media controls, SMS, contacts). Ask
can(), notisNativeApp(). - An unknown name returns
'none'. For a plain yes/no usehas(cap).
appmintNative.clearPushInbox appmintNative#
import { clearPushInbox } from '@/lib/appmintNative'
Example
Empties the AppMint Push inbox on this phone. Permanent - there is no undo.
Returns: boolean synchronously - true when cleared; always false on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { clearPushInbox, pushInbox } from '@/lib/appmintNative';
export async function onClearAll(confirmDelete: () => Promise<boolean>) {
if (!(await confirmDelete())) return pushInbox();
clearPushInbox();
return pushInbox(); // []
}Notes: tray notifications and your send history in AppMint are not touched; new messages still arrive.
appmintNative.click appmintNative#
click() — @/lib/appmintNative
Example
Plays the app's tap sound once, for your own buttons.
Returns: void. Needs: Tap Sound switched on in Step 3 (Integrate) when you build. On the web, and in apps built without Tap Sound, it does nothing.
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>
);
}Notes: With Follow phone setting (recommended) 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.Clipboard appmintNative#
Clipboard.write({ string?, url? }) / Clipboard.read(): Promise<{ value, type }>
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.
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
}
}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.collapsingHeader appmintNative#
collapsingHeader(header: HTMLElement, opts?: { scroller?: HTMLElement | Window; range?: number }): () => void
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:
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>
);
}.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); }Notes:
- Default
scrolleris the window (a page that scrolls as a whole); defaultrangeis 64 px. - The scroll listener is passive and updates once per animation frame, so it does not slow scrolling.
appmintNative.composeSms appmintNative#
composeSms(phoneNumber: string, message: string): boolean
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.
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.');
});
}Notes: Prefer this over sendSms() for anything the user writes or approves - nothing leaves the phone without the user pressing send. 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.copy appmintNative#
copy(text: string): Promise<boolean>
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.
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:
import { Clipboard } from '@/lib/appmintNative';
await Clipboard.write({ string: 'ABC-123' });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.copyImage appmintNative#
import { copyImage } from '@/lib/appmintNative'
Example
Copies an image to the clipboard so it pastes into chat and mail apps: the app's clipboard in the Android app, the Async Clipboard API on the web.
Returns: Promise<boolean> - true once copied. Needs: nothing; gate the control on can('clipboardImage').
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');
}Notes: PNG is the safest type everywhere; the Android app also accepts JPEG and WebP.
appmintNative.Device appmintNative#
Device.getInfo() / Device.getId() / Device.getLanguageCode()
Example
The @capacitor/device shape: phone model, OS version, a stable ID and the language. It uses the Capacitor Device plugin in the app and the browser's answer on the web.
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.
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.
import { Device } from '@/lib/appmintNative';
const { identifier } = await Device.getId(); // Capacitor ID in the app, installId() on the web
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 }),
});Notes: In the app the sync deviceInfo() fills model and osVersion too; Device.getInfo() adds operatingSystem, isVirtual and the plugin's extra fields. 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.deviceInfo appmintNative#
deviceInfo(): 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, language } - platform is 'android' in the app and 'web' in a browser. 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 the web it adds osVersion (the user agent). Needs: nothing.
Branch on the platform during render.
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.
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}%`);
}Notes: model and appVersion are filled in the app only. 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.Dialog appmintNative#
Dialog.alert / Dialog.confirm / Dialog.prompt
Example
Real system dialogs: a message, a yes/no question, a one-line text question. The app uses the Capacitor Dialog plugin; the web uses 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.
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:
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());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.dismissPushMessage appmintNative#
import { dismissPushMessage } from '@/lib/appmintNative'
Example
Hides one message from the AppMint Push inbox (swipe-away). Undo it with restorePushMessage(id).
Returns: boolean synchronously - true if it was hidden. Always false on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
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());
});
}Notes: only this phone's inbox changes; the tray notification and your send history stay.
appmintNative.docScanner appmintNative#
docScanner.scan({ pageLimit?, pdf?, quality? }): Promise<{ pages, pdfPath? } | null> / docScanner.isSupported(): Promise<boolean>
Example
Opens the system document scanner - edge detection, straightening, several pages - Google's ML Kit scanner in the Android app. 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); false on the web. Needs: nothing to switch on (on Android the scanner runs in Google Play services and needs no camera permission).
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>
);
}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, 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.fileTransfer appmintNative#
fileTransfer.download / fileTransfer.upload from '@/lib/appmintNative'
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:
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">:
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})`);
}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.fileViewer appmintNative#
fileViewer.open(target) from '@/lib/appmintNative'
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:
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):
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 webNotes: 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.folder appmintNative#
import { folder } from '@/lib/appmintNative'
Example
A whole folder the user grants once and the app keeps across launches, with real file operations inside it (in the app: the shell's folder bridge).
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:
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):
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 folderNotes: 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.fullscreen appmintNative#
fullscreen.enter() / fullscreen.exit() / fullscreen.isOn()
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.
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:
import { useEffect } from 'react';
import { fullscreen } from '@/lib/appmintNative';
export function useFullscreenWhileOpen() {
useEffect(() => {
void fullscreen.enter();
return () => { void fullscreen.exit(); };
}, []);
}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.Geolocation appmintNative#
Geolocation.getCurrentPosition / watchPosition / clearWatch / checkPermissions / requestPermissions
Example
The @capacitor/geolocation shape: the Capacitor Geolocation plugin in the app, 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: GPS / Location in Step 4 (Access) - the build turns both on when your code calls getCurrentPosition or watchPosition.
Where am I, once.
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.
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; }
}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.haptic appmintNative#
haptic(kind?) — @/lib/appmintNative
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.
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':
import { haptic } from '@/lib/appmintNative';
function onTabChange(tab: string) { haptic('selection'); setTab(tab); }
function onConfirmDelete() { haptic('heavy'); deleteItem(); }Notes: In the app 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.hapticPlay appmintNative#
hapticPlay(pattern) — @/lib/appmintNative
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'.
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):
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
]);Notes: The app uses the AppwrightHaptics Capacitor plugin (strength per beat where the phone supports it). The web gets the timing only. sharpness is accepted but has no effect on Android. haptic.play(pattern) is the same function.
appmintNative.Haptics appmintNative#
Haptics — @/lib/appmintNative
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.
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 300A picker that ticks as the value changes:
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>
);
}Notes: selectionStart and selectionChanged both give one selection tick; selectionEnd does nothing. Works in the app (Capacitor Haptics plugin); on the web it uses navigator.vibrate where available and otherwise does nothing.
appmintNative.has appmintNative#
has(cap: Capability): boolean
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.
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:
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;
}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.hideKeyboard appmintNative#
hideKeyboard(): void
Example
Closes the on-screen keyboard - for example when a form is submitted or a sheet opens over the field.
Returns: nothing. Needs: nothing.
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>
);
}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.installId appmintNative#
installId(): string
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.
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;
}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.
appmintNative.isNativeApp appmintNative#
isNativeApp(): boolean
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. Needs: nothing.
Show an "Android app" badge, and a download link in the browser.
import { isNativeApp } from '@/lib/appmintNative';
export function appBadgeText(): string {
if (isNativeApp()) return 'Android app';
return 'Get the app on Google Play';
}Notes: platform() gives the same answer as a string ('android' in the app, 'web' in a browser). To decide whether a feature can be shown, use can('<capability>') instead: it knows which platform has which feature.
appmintNative.keepAwake appmintNative#
keepAwake(on: boolean): Promise<boolean>
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.
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 />;
}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.Keyboard appmintNative#
Keyboard.hide() / Keyboard.addListener(event, handler): { remove() }
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:
import { Keyboard } from '@/lib/appmintNative';
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
await Keyboard.hide();
await save();
}Listen (remove the handle on unmount):
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(); };
}, []);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.launchUrl appmintNative#
launchUrl(): Promise<string>
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:
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:
import { onDeepLink } from '@/lib/appmintNative';
const off = onDeepLink((url) => console.log('opened with', url));Notes:
- The value is KEPT, not consumed: a component that mounts late still sees it. A newer link replaces it.
- It is read from the shell (
WebToApk.getLaunchUrl()); never call that directly in an AI-built app. Shortcuts and widget taps arrive asappmint-shortcut://<package><route>. - Parse with
new URL(u)and navigate to its path; do not load the URL itself.
appmintNative.listCallLog appmintNative#
listCallLog(limit = 100, offset = 0, sinceMillis = 0): Promise<CallLogEntry[]>
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.
import { listCallLog, can } from '@/lib/appmintNative';
export async function missedThisWeek() {
if (can('callLog') === 'none') return null; // web: 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
}
}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. Call log is a Google Play restricted permission: you need an approved Permissions Declaration in Play Console, or the app is rejected.
appmintNative.listContacts appmintNative#
listContacts(limit = 200, offset = 0): Promise<Contact[]>
Example
Reads the address book, a page at a time. Android app only, through the shell's bridge.
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: the Contacts switch in Step 4 (Access) - the build turns it on when your code calls this - and the user's permission on first use.
A contact list screen. Render it only where it can work. Each contact is { id?, name?, phones?: string[], emails?: string[], photoUri? }.
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
}
}Notes: An empty result cannot tell "no contacts" from "permission refused" - show a short hint ("Allow contacts in Settings") when the list is empty. Contacts come sorted 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.listSms appmintNative#
listSms(box: 'inbox' | 'sent' | 'draft' = 'inbox', limit = 100, offset = 0): Promise<SmsMessage[]>
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.
import { listSms, can, type SmsMessage } from '@/lib/appmintNative';
export async function latestInbox(): Promise<SmsMessage[] | null> {
if (can('smsList') === 'none') return null; // web: 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
}
}Notes: An empty list cannot tell "no messages" from "permission refused". limit is clamped to 500 by the app. can('smsList') is 'none' on the web. 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.liveProgress appmintNative#
import { liveProgress } from '@/lib/appmintNative'
Example
A sticky, silent status card in the Android notification shade that you update in place - a delivery, an upload, a timer, a workout. start, update, end.
Returns: start / update → Promise<boolean> (false when not available, when notifications are not allowed, or when the user turned off its "Live status" category); end → Promise<void>. Needs: the Android app, and the notification permission on Android 13+. On the web can('liveProgress') is 'none' - show the same state inside your app there.
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.
await liveProgress.start({ id: 'run', title: 'Morning run', text: '5 km goal', startedAt: Date.now() });
// ...later
await liveProgress.end('run');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()): without it start answers false and nothing is shown.
appmintNative.LocalNotifications appmintNative#
import { LocalNotifications } from '@/lib/appmintNative'
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 Access step (Step 4) when you build.
import { LocalNotifications } from '@/lib/appmintNative';
export async function announceSync(count: number) {
await LocalNotifications.schedule({
notifications: [
{ title: 'Sync complete', body: `${count} items updated`, tag: 'sync' },
],
});
}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.lockOrientation appmintNative#
lockOrientation(to: 'portrait' | 'landscape' | 'any'): Promise<boolean>
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:
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:
import { lockOrientation, toast } from '@/lib/appmintNative';
const ok = await lockOrientation('portrait');
if (!ok) toast('Turn your phone upright for the best view');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 expectfalsethere. - Always give the user a way out of a locked view.
appmintNative.longPress appmintNative#
longPress(el: HTMLElement, handler: (e: PointerEvent) => void, opts?: { ms?: number; haptics?: boolean }): () => void
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:
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:
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 });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.markPushRead appmintNative#
import { markPushRead } from '@/lib/appmintNative'
Example
Marks one AppMint 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. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
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();
}Notes: read state lives only on this phone; the tray notification is not removed.
appmintNative.mediaSession appmintNative#
mediaSession — @/lib/appmintNative
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: Media Notification in Step 4 (Access) when you build; AI builds switch it on for you when the code uses mediaSession.
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:
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>;
}Notes:
- On Android the buttons come through the app's
navigator.mediaSession, which is installed when the page finishes loading. AnonActionregistered earlier (for example in a mount effect) is lost, so register it on the first play. - The
seekaction (withpositionMs) is delivered on the web only; the Android notification has no seek. artworkUrlmust be a fullhttps://address on the internet. On Android,cast(url)opens the "Cast to..." chooser (a hand-off; it resolvestrueonce the chooser is asked for). On the web it uses the Remote Playback API.
appmintNative.Motion appmintNative#
Motion.addListener('accel' | 'orientation', cb): Promise<{ remove() }>
Example
The @capacitor/motion shape over the standard devicemotion and deviceorientation events: movement and tilt of the phone. The same code runs in the app 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.
Shake to refresh.
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.
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();Notes: Some browsers ask for motion access first, so start it from a tap there; if the 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.musicVolume appmintNative#
musicVolume() — @/lib/appmintNative
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 3 (Integrate) when you build.
Draw a music slider only when there is music:
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>
);
}Notes: Android app only; the web answers -1. A level set with setMusicVolume is kept for the next launch; until the page sets one, it is the build level.
appmintNative.Network appmintNative#
Network.getStatus(): Promise<{ connected, connectionType }> / Network.addListener(event, handler): { remove() }
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.
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;
}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.networkStatus appmintNative#
networkStatus(): { connected: boolean; type: string }
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:
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:
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;
}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.onLineplusnavigator.connection.typewhere 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.nfc appmintNative#
import { nfc } from '@/lib/appmintNative'
Example
One-call helpers over Web NFC (NDEFReader): check support, read tags, write a text tag. The Android app uses its own NDEFReader; on the web it is Chrome's own Web NFC (Chrome on Android).
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.
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>
);
}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.notify appmintNative#
import { notify } from '@/lib/appmintNative'
Example
Shows one notification right now from an AI-built app, in the Android app and on the web.
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+ and the web). Needs: Android: Notifications turned on in the Access step (Step 4) when you build.
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:
async function enableAlerts() {
if (typeof Notification === 'undefined') return false;
const perm = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission();
return perm === 'granted';
}Notes: Android uses the shell's notification bridge (icon becomes the large icon; use an https:// or data: image); 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.notifyWithActions appmintNative#
import { notifyWithActions } from '@/lib/appmintNative'
Example
Shows a notification with up to 3 buttons ("Mark done", "Snooze"). 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 Access step (Step 4) when you build, and the user's permission on Android 13+. On the web can('notificationActions') is 'none'.
import { notifyWithActions, onNotificationAction, can } from '@/lib/appmintNative';
export async function remindTask(taskId: string, title: string) {
if (can('notificationActions') === 'none') return false; // web: hide the buttons
return notifyWithActions({
title,
body: 'Due now',
tag: `task-${taskId}`, // comes back as e.tag
actions: [
{ id: 'done', title: 'Mark done' },
{ id: 'snooze', title: 'Snooze' },
],
});
}
// 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);
});Notes: Each button shows its title; its id is what comes back as e.actionId. The input option (inline reply box) is not supported in this app - it is ignored, the button has no reply box and e.text is always ''. A tap reaches the page only while the app is running. On the web the wrapper would post a plain notification without buttons, which is why the example checks can() first.
appmintNative.offlineBanner appmintNative#
offlineBanner(opts?: { message?: string; onlineMessage?: string }): () => void
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:
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):
import { offlineBanner } from '@/lib/appmintNative';
const off = offlineBanner({ message: 'Sin conexión', onlineMessage: 'Conectado de nuevo' });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/--bgoffline,--accent/--accent-inkonline). It hasrole="status"for screen readers. - Mount it once. Two calls draw two banners.
appmintNative.onBackGesture appmintNative#
onBackGesture(handler: () => boolean): () => void
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:
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:
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]);
}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.
- Still draw a visible back / close control: many users never use the back gesture.
- 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.onDeepLink appmintNative#
onDeepLink(cb: (url: string) => void): () => void
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.
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:
window.addEventListener('appmint:deep-link', function (e) {
var url = e.detail && e.detail.url;
if (url) console.log('deep link', url);
});Notes:
- Android: listens to the shell's
appmint:deep-linkevent (detail.url); the shell also callswindow.onAppMintDeepLink(detail)when a page defines it. After a link arrives,launchUrl()returns it too. - Web: never fires (the callback is not called) and the unsubscribe does nothing.
appmintNative.onKeyboard appmintNative#
onKeyboard(cb: (insetPx: number) => void): () => void
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.
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>
);
}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
0there - 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 as0). - In the app the first call comes when the keyboard first opens, so start your state at
0.
appmintNative.onNetworkChange appmintNative#
onNetworkChange(cb: (status: { connected: boolean; type: string }) => void): () => void
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:
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]);
}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);typeis'wifi','cellular','none'or'unknown'. - On the web it listens to the
online/offlineevents and reportsnetworkStatus(). - For a ready-made banner, use
offlineBanner(), which is built on this.
appmintNative.onNotificationAction appmintNative#
import { onNotificationAction } from '@/lib/appmintNative'
Example
Listens for taps on notification buttons (from notifyWithActions).
Returns: a function that stops listening. The callback gets { actionId, tag } - the button's id and the notification's tag. The Android app sends no reply text (inline reply is not supported), so do not rely on e.text. Needs: the same as notifyWithActions.
import { useEffect } from 'react';
import { onNotificationAction } from '@/lib/appmintNative';
export function useNotificationActions(openChat: (chatId: string) => void) {
useEffect(() => {
const stop = onNotificationAction(({ actionId, tag }) => {
if (actionId === 'Open') openChat(tag);
if (actionId === 'Mute') muteChat(tag);
});
return stop; // remove on unmount
}, [openChat]);
}Raw event in a hand-written page (Android app):
window.addEventListener('appmint:notification-action', function (e) {
// e.detail = { actionId: 'Mute', tag: 'chat-7' }
console.log(e.detail.actionId, e.detail.tag);
});Notes: in the Android app this is the appmint:notification-action event (also window.onAppMintNotificationAction(detail)); on the web it does nothing. A tap is delivered only while the app is running - tapping a button does not open the app.
appmintNative.onNotificationClick appmintNative#
import { onNotificationClick } from '@/lib/appmintNative'
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.
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 } });
}Notes: The tap travels through the splash and the PIN screen to the page. data must be JSON (under 16 KB) and needs a tag.
appmintNative.onPullToRefresh appmintNative#
onPullToRefresh(el: HTMLElement, onRefresh: () => void | Promise<void>, opts?: { threshold?: number }): () => void
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:
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:
import { onPullToRefresh } from '@/lib/appmintNative';
declare function reloadFeed(): Promise<void>;
const off = onPullToRefresh(document.documentElement, reloadFeed, { threshold: 96 });Notes:
- It starts only when the list is scrolled to the very top. The default
thresholdis 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 (Enable Pull-to-Refresh in Step 3 (Integrate)) is off by default - keep it off when you use this, so a pull does not also reload the page.
- Built on Pointer Events, so it is the same on every surface. It sets
overscroll-behavior-y: containoneland restores it on unsubscribe.
appmintNative.passkeys appmintNative#
passkeys — available / create / get / origin / assetLinks
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.
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:
import { passkeys } from '@/lib/appmintNative';
console.log(JSON.stringify(passkeys.assetLinks(), null, 2)); // → /.well-known/assetlinks.json
console.log(passkeys.origin()); // → add to expected originsNotes: On the web it needs a secure (https://) page. 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.paste appmintNative#
paste(): Promise<string>
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.
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:
import { Clipboard } from '@/lib/appmintNative';
const { value } = await Clipboard.read();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.pickContact appmintNative#
pickContact(): Promise<Contact | null>
Example
Opens the system contact picker so the user chooses one person: Android's picker in the app, the Contact Picker API in Chrome on Android.
Returns: Promise<Contact | null> - null when the user cancels or access is refused. Needs: nothing (the system picker needs no permission).
Pick a person and fill a form. The contact has the same shape in the app and on the web: { id?, name?, phones?: string[], emails?: string[], photoUri? }.
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
}
}Notes: In the app 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.
appmintNative.pickFile appmintNative#
pickFile(accept?) from '@/lib/appmintNative'
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.
import { pickFile } from '@/lib/appmintNative';
async function importCsv() {
const f = await pickFile('text/csv'); // default '*/*' = any file
if (!f) return; // cancelled (or picker not enabled in the build)
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)}`);
}
}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 for what the screen can handle. A picker that cannot open rejects, so wrap pickFile() in try/catch as well. On the web text() is the browser's own File.text(). For binary files or writing back, hand-written pages use WebToApkFS.
appmintNative.pip appmintNative#
pip.enter(video?) / pip.exit(video?)
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 in Step 3 (Integrate) - it is switched on automatically when your code calls pip.enter(.
A "minimise" button on a player:
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):
import { pip } from '@/lib/appmintNative';
declare const video: HTMLVideoElement;
await pip.exit(video);Notes:
- Android (8+): the WHOLE app shrinks into the floating window, and the
videoargument is ignored. It returnsfalseon older Android or in a build without the PiP option. The user expands the window to come back;exit()does nothing there. - The web: the
<video>element's own picture-in-picture. The video must be loaded (usually playing); a call without avideoreturnsfalse. - Call it from a tap.
appmintNative.platform appmintNative#
platform(): 'android' | 'ios' | 'web'
Example
Tells you which surface the code runs on: the installed Android app, or a web browser.
Returns: a string, right away: 'android' inside the app, 'web' in a browser. It asks Capacitor first (Capacitor.getPlatform()), then checks for the Android shell bridge, else 'web'. The type also lists 'ios', but an AppMint app never gets it - AppMint builds Android apps only. Needs: nothing.
Pick the right link and copy.
import { platform } from '@/lib/appmintNative';
const inApp = platform() === 'android';
const rateLink = inApp
? 'https://play.google.com/store/apps/details?id=com.example.app'
: '/feedback';
const backHint = inApp ? 'Press Back to go back' : 'Use the browser\'s back button';Notes: platform() never answers 'web' inside the installed app. Do not use it to decide whether a feature exists - many features also depend on build switches; can('<capability>') answers that.
appmintNative.privacyScreen appmintNative#
privacyScreen — enable() / disable() / isEnabled()
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. In the Android app it uses the shell's screenshot block (screenshots, screen recording, casting and the recents thumbnail).
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:
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' : '';
}Notes: In the app 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.pushInbox appmintNative#
import { pushInbox } from '@/lib/appmintNative'
Example
Returns the AppMint 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 and when AppMint Push is off. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
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 };
}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.readBarcodeFrom appmintNative#
readBarcodeFrom(source: Blob | CanvasImageSource, formats?: BarcodeFormat[]): Promise<{ value, format }[]>
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:
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:
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);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
formatswhen you know them: it is faster and misreads less.barcodeFormats()lists what the device supports.
appmintNative.readOtp appmintNative#
import { readOtp } from '@/lib/appmintNative'
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 on the web.
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).
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" />
);
}Notes: it is an accelerator - always keep the field typeable.
appmintNative.recognizeText appmintNative#
import { recognizeText } from '@/lib/appmintNative'
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.
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>
);
}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.remoteConfig appmintNative#
remoteConfig.start() / fetch() / apply() / manifestUrl() / handledHere() / dismissed() / dismiss()
Example
Draws the announcement strip and the maintenance screen that the creator publishes from Remote Update - for the page when it runs in a web browser. In the Android app the shell already does this itself, so these calls do nothing there.
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 (the content of <meta name="appmint-remote-manifest">, '' when there is none); handledHere() → boolean; dismissed(id) → boolean; dismiss(id) → void. Needs: nothing in the app - Remote Update is native there.
Safe to call at startup; in the app it returns at once:
import { useEffect } from 'react';
import { remoteConfig } from '@/lib/appmintNative';
// Your Remote Update manifest URL. AppMint does not write the meta tag into the page,
// so pass the URL when the same page also runs on the web.
const MANIFEST = 'https://example.com/remote-update/manifest.json';
export function App() {
useEffect(() => { void remoteConfig.start(MANIFEST); }, []); // does nothing in the Android app
return <main>{/* … */}</main>;
}Or read the values and draw your own UI (web only):
import { remoteConfig, type RemoteConfig } from '@/lib/appmintNative';
export async function loadNotice(url: string): Promise<RemoteConfig | null> {
if (!remoteConfig.handledHere()) return null; // the Android app draws its own
const cfg = await remoteConfig.fetch(url);
if (!cfg || !cfg.announcementText) return cfg;
return remoteConfig.dismissed(cfg.announcementId) ? null : cfg;
}
// when the user closes your banner: remoteConfig.dismiss(cfg.announcementId)Notes:
RemoteConfig={ version, announcementText, announcementId, announcementImageUrl, announcementBgColor, announcementTextColor, maintenanceMode, maintenanceText }.handledHere()isfalsein the Android app: the shell reads the manifest natively and draws the announcement itself, so you never see two banners.start()without an argument usesmanifestUrl(). An AppMint build does not add that meta tag, so without an argument (or your own<meta name="appmint-remote-manifest" content="…">) nothing is fetched.fetch()returnsnullfor a paused or reverted publish, a manifest whoseplatformslist does not includeweb, a newer manifest format, or any network error.- Maintenance mode wins:
apply()then covers the whole page with the maintenance text. A dismissed announcement stays hidden until a newannouncementIdis published.
appmintNative.requestReview appmintNative#
requestReview(): Promise<boolean>
Example
Asks Google Play to show its own rating sheet inside the app (Play In-App Review).
Returns: Promise<boolean> - true when the store was asked, false where nothing could be asked (the web, a build without the review plugin, or the request failed). It never tells you whether the sheet appeared or what the user chose. Needs: nothing; the sheet only appears for an app installed from Google Play.
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();
}
}Notes: Google Play 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. In the app it uses the InAppReview plugin. It never leaves the app for the Play listing - for a "Rate us on Play" link, open the listing yourself.
appmintNative.restorePushMessage appmintNative#
import { restorePushMessage } from '@/lib/appmintNative'
Example
Undoes dismissPushMessage(id) - the message returns to the AppMint 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). Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
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();
}Notes: messages removed with clearPushInbox() or retracted by you cannot be restored.
appmintNative.safeArea appmintNative#
safeArea(): { top: number; right: number; bottom: number; left: number }
Example
Gives the space taken by the status bar, camera cut-out and navigation bar, 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:
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:
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;
}Notes:
- Measured from CSS
env(safe-area-inset-*). The value is cached and re-measured after a window resize or rotation. - Before
document.bodyexists (very early at import time) it returns all zeros - call it from an effect. - On a desktop browser all four are usually
0.
appmintNative.saveFile appmintNative#
saveFile(fileName, data, mime?) from '@/lib/appmintNative'
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.
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):
import { saveFile } from '@/lib/appmintNative';
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
saveFile('Backup.json', blob);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.saveToGallery appmintNative#
import { saveToGallery } from '@/lib/appmintNative'
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+.
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>
);
}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.scanBarcode appmintNative#
scanBarcode(video: HTMLVideoElement, opts?: { signal?, formats?, facingMode? }): Promise<{ value, format } | null>
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 turn on Camera in Step 4 (Access) when you build.
Use it only when the viewfinder must sit inside your own screen; otherwise scanCode() is simpler.
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:
import { scanBarcode } from '@/lib/appmintNative';
declare const video: HTMLVideoElement;
const hit = await scanBarcode(video, { facingMode: 'user' });Notes:
- It calls
getUserMedia(so the user sees the camera permission prompt) and decodes withBarcodeDetectoron 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 getnull. - Start it from a user action (a Scan button or opening a scan screen), never on app launch. Handle
nullas a normal outcome with a manual-entry option.
appmintNative.scanCode appmintNative#
scanCode(opts?: { formats?: BarcodeFormat[]; manualInput?: boolean }): Promise<ScanCodeResult | null>
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:
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:
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.typeis'url','wifi','text','phone','email','sms','geo','contact','calendar','isbn','product'or'driver_license', and the matching field is filled.manualInputlets 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; onlyvalueandformatare filled there, and the browser asks for camera permission. nullalways means the user closed the scanner; every other outcome rejects with acode, so show a message incatch.- Call it from a tap, never on page load. Narrow
formatswhen you know them.
appmintNative.ScreenOrientation appmintNative#
ScreenOrientation.lock({ orientation }) / ScreenOrientation.unlock()
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.
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" />;
}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.screenReader appmintNative#
screenReader.isEnabled() / screenReader.speak(text, lang?) / screenReader.onChange(cb)
Example
Tells the app whether TalkBack 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.
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');
}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.searchContacts appmintNative#
searchContacts(query: string, limit = 50): Promise<Contact[]>
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: Contacts in Step 4 (Access) (turned on by the build when your code calls this), and the user's permission on first use.
Search box with a small delay while typing.
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);
}Notes: The match is done by the phone's contacts search on names. Each contact is { id?, name?, phones?: string[], emails?: string[], photoUri? }. On the web can('contactList') is 'none' - there is no web API to read the address book. Contacts is personal data: declare it in your Play Data safety form and privacy policy.
appmintNative.SecureKeyError appmintNative#
class SecureKeyError extends Error { code } — from '@/lib/appmintNative'
Example
The error secureKeys throws. Its code is one short word you can branch on, the same in the app and on 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), 'foreign-origin' (a page from another site asked), 'failed' (anything else). Needs: nothing.
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;
}
}
}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' in the app, which cannot tell it apart from other cipher errors.
appmintNative.secureKeys appmintNative#
secureKeys — generate / encrypt / decrypt / remove / has / list / isHardwareBacked
Example
Encryption keys the device holds and the app can never export: the Android Keystore in the app, 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: Secure Keys (encryption) in Step 4 (Access) when you build.
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:
import { secureKeys } from '@/lib/appmintNative';
export async function wipeDiary() {
await secureKeys.remove('diary');
localStorage.removeItem('diary');
}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: in the app encrypt/decrypt throw auth-required without showing anything once the unlock window has passed - call unlock() and try again; on the web the runtime shows unlock() itself. Encrypt one record at a time (the app caps a call at 4 MB). Aliases: letters, digits, _ - ., max 64.
appmintNative.sendSms appmintNative#
sendSms(phoneNumber: string, message: string): Promise<boolean>
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.
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';
}
}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 (no browser can send an SMS). 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.setMusicVolume appmintNative#
setMusicVolume(percent) — @/lib/appmintNative
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 3 (Integrate) when you build. Does nothing when musicVolume() is -1.
The app keeps the level for the next launch, so a mute toggle is all you need:
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';
export const hasMusic = () => musicVolume() >= 0;
export const toggleMute = () => setMusicVolume(musicVolume() > 0 ? 0 : 40); // remembered by the appNotes: 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 app only; on the web nothing happens.
appmintNative.setPushPresentation appmintNative#
import { setPushPresentation } from '@/lib/appmintNative'
Example
Chooses who draws an AppMint 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. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
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();
}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.setStatusBar appmintNative#
setStatusBar(backgroundColor: string | null, style?: 'light' | 'dark' | null): void
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:
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:
import { setStatusBar } from '@/lib/appmintNative';
setStatusBar('#FFFFFF', 'dark');The Capacitor-shaped form (the style is named after the BACKGROUND: 'DARK' = light icons):
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 the web it sets
<meta name="theme-color">; the browser picks the icon colour itself.
appmintNative.sheet appmintNative#
sheet(opts: { title?, content, dismissible? }): { close(), element, closed }
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:
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:
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,
)}
</>
);
}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: falseonly your ownclose()closes it - give the user a button. - For a plain list of actions use
actionSheet().
appmintNative.shortcuts appmintNative#
shortcuts.set(items: { id?, title, route }[]) / shortcuts.clear()
Example
Adds shortcuts to the app icon's long-press menu (Android dynamic shortcuts). 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:
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):
import { shortcuts } from '@/lib/appmintNative';
await shortcuts.clear();Notes:
- At most 4 items; extra items are dropped. A
routewithout a leading/gets one.idis optional. - Android 7.1+; on older Android the call does nothing. Titles are cut to 25 characters (short label) and 50 (long label).
- A tap opens the app with the launch link
appmint-shortcut://<package><route>, sonew URL(u).pathnameis your route. Read it withlaunchUrl()on mount andonDeepLink()while the app runs. can('shortcuts')is'none'on the web: a browser has no app icon.
appmintNative.speak appmintNative#
speak(text, opts?) — @/lib/appmintNative
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.
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:
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 = normalNotes: In the Android app: the app's native engine. On the 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.speech appmintNative#
speech — @/lib/appmintNative
Example
Dictation: turns the user's speech into text. Android's speech recognizer in the Android 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.
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>
);
}Notes:
- With
partialon (the default)onResult(text, false)arrives as words come in, then onceonResult(text, true)with the final text. Withpartial: falseyou get only the final one, when the user stops talking. - The permission prompt is shown on the first
start(). - If the user says nothing,
onResultis not called; listening just ends. Keep a Stop button or a timeout in your UI. - Listening stops by itself after a pause.
langdefaults to the phone's language.
appmintNative.splash appmintNative#
splash.hold() / splash.ready()
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:
// main.tsx
import { splash } from '@/lib/appmintNative';
splash.hold(); // at import time, before the first render// 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>;
}Notes:
- In the Android app
hold()tells the shell to keep its splash;ready()hides it (the creator's minimum splash time still applies). - On the web both do nothing - there is no splash.
- The shell caps the hold at about 6 seconds, so a page that never calls
ready()still opens.
appmintNative.StatusBar appmintNative#
StatusBar.setBackgroundColor({ color }) / StatusBar.setStyle({ style })
Example
The status bar in the shape of Capacitor's StatusBar plugin, built on setStatusBar().
Returns: Promise<void> for both. Needs: nothing.
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:
import { setStatusBar } from '@/lib/appmintNative';
setStatusBar('#0B0F1A', 'light'); // dark bar, light icons
setStatusBar('#FFFFFF', 'dark'); // light bar, dark iconsNotes: 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.stopSpeaking appmintNative#
stopSpeaking() — @/lib/appmintNative
Example
Stops text-to-speech now and drops anything still waiting to be spoken.
Returns: void. Needs: nothing.
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>
);
}Notes: Safe to call when nothing is speaking. In the Android app: the native engine's stop. On the web: speechSynthesis.cancel().
appmintNative.swipeable appmintNative#
swipeable(el: HTMLElement, opts: SwipeOptions): () => void
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:
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:
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();Notes:
- Built on Pointer Events, so the same code works with touch, pen and a mouse drag.
- It sets
touch-actionfor you:pan-yfor horizontal handlers (vertical scrolling still works),pan-xfor vertical ones,nonefor 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.systemPalette appmintNative#
systemPalette(): Promise<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 and on the web. Needs: nothing.
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']);
}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.TextToSpeech appmintNative#
TextToSpeech — @/lib/appmintNative
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.
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();
}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. In the app: the shell's native Android text-to-speech engine; on the web: speechSynthesis.
appmintNative.textZoom appmintNative#
textZoom.get() / textZoom.getPreferred() / textZoom.set(value)
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.
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:
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));
}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.toast appmintNative#
toast(message: string, opts?: { duration?: 'short' | 'long' }): void
Example
Shows a brief message that needs no answer - "Copied", "Saved", "Added to list".
Returns: nothing (fire and forget). Needs: nothing.
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:
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>:
import { Toast } from '@/lib/appmintNative';
await Toast.show({ text: 'Added to list' });For a message the user MUST see, use a dialog instead:
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 (
--inkon--bg), above the safe-area inset, withrole="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.Toast appmintNative#
import { Toast } from '@/lib/appmintNative'
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.
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:
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>:
import { Toast } from '@/lib/appmintNative';
await Toast.show({ text: 'Added to list' });For a message the user MUST see, use a dialog instead:
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 (
--inkon--bg), above the safe-area inset, withrole="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.torch appmintNative#
import { torch } from '@/lib/appmintNative'
Example
The phone's flashlight: torch.on(), off(), toggle(), isOn(), isAvailable(). can('torch') is 'none' on 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.
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>
);
}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.transition appmintNative#
transition(update: () => void | Promise<void>, opts?: { type?: 'push' | 'pop' | 'fade' }): Promise<void>
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.
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:
import { transition } from '@/lib/appmintNative';
declare function setTab(t: 'list' | 'map'): void;
await transition(() => setTab('map'), { type: 'fade' });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.unlock appmintNative#
unlock(title?, subtitle?) — from '@/lib/appmintNative'
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: in the app, Fingerprint in Step 4 (Access) when you build. On the web: the browser's platform authenticator (WebAuthn).
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:
import { can } from '@/lib/appmintNative';
const showLockButton = can('unlock') !== 'none'; // 'native' | 'web' | 'none'Notes: In the app the device PIN, pattern or password is allowed 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.updates appmintNative#
updates.supported() / download() / stage() / confirm() / revertIfUnconfirmed() / current()
Example
A page-side updater for web-bundle updates. Page code does not need it: in an AppMint app, Remote Update is done natively by the Android app, 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 AppMint.
The only safe use in app code is to check, and do nothing when it is off:
import { updates } from '@/lib/appmintNative';
if (updates.supported()) {
// Not reached today: the Android app's own updater owns the whole update lane.
const staged = await updates.download(
'https://example.com/bundle-1.2.0.zip',
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', // sha256, required
'1.2.0',
'1',
);
console.log('update staged:', staged);
}Show which bundle is running (a settings "About" row):
import { updates } from '@/lib/appmintNative';
const active = updates.current(); // null = no page-staged bundle (always, today)
const label = active ? `Content ${active.version}` : 'Built-in content';Notes:
- In the Android app the shell's Remote Update manager downloads, verifies and serves updates, and rolls back a bundle that fails to load. The page is served by the shell itself, so a bundle staged from page code would never be shown - that is why
supported()isfalse. - On the web there is nothing to update; the page is whatever the server sends.
- Updates can change HTML, CSS and JavaScript only; a new native feature still needs a new app build.
appmintNative.watchIdle appmintNative#
import { watchIdle } from '@/lib/appmintNative'
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.
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>
);
}Notes: the threshold is at least one minute (the spec's floor).
appmintNative.widget appmintNative#
widget.set(card) / widget.clear() / widget.placedCount() / widget.requestPin()
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:
import { can, widget } from '@/lib/appmintNative';
export async function updateTodayWidget(tasks: { title: string; done: boolean }[]) {
if (can('widget') === 'none') return; // on the web
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:
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>
);
}Notes:
- The card is saved on the device, survives a reboot, and every placed copy is refreshed.
placedCount()of0means no copy is on the home screen yet. - Card fields:
title(empty = the app name),value(shown large inaccent, hidden when empty),lines(max 6),route(default/), andbg/ink/accent. The colours default to the CSS tokens--bg,--ink,--accent; they must be colours Android can read, such as#RRGGBB- anhsl()/oklch()value falls back to the default colours. - A tap opens the app with
appmint-shortcut://<package><route>; read it withlaunchUrl()/onDeepLink(). requestPin()needs Android 8+ and a launcher that supports it.clear()shows the empty "Nothing to show yet" card.- Android app only:
can('widget')is'none'on the web, whereset()andrequestPin()returnfalse.