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
}
}Skip a contact that is already saved. searchContacts reads the address book, so check before you add:
import { addContact, searchContacts, can } from '@/lib/appmintNative';
export async function addIfNew(name: string, phone: string): Promise<'added' | 'exists' | 'refused' | 'app_only'> {
if (can('contactAdd') === 'none') return 'app_only';
const found = await searchContacts(name, 5);
if (found.some((c) => c.phones?.includes(phone))) return 'exists';
return (await addContact({ name, phones: [phone] })) ? 'added' : 'refused';
}Tell a build mistake from a refusal. A rejection with code 'foreign_origin' or 'not_enabled' is a build problem, false is the user's choice:
import { useState } from 'react';
import { addContact } from '@/lib/appmintNative';
export function SaveContactButton({ name, phone }: { name: string; phone: string }) {
const [msg, setMsg] = useState('');
async function onTap() {
try {
setMsg((await addContact({ name, phones: [phone] })) ? 'Saved.' : 'Contacts access was declined.');
} catch (e) {
const code = (e as { code?: string }).code;
setMsg(code === 'foreign_origin' || code === 'not_enabled' ? 'This build cannot save contacts.' : 'Try again.');
}
}
return <button onClick={onTap}>{msg || 'Save to contacts'}</button>;
}Notes: It saves at once - no contact editor opens - so confirm with the user in your own screen first. 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.alarmSupport appmintNative#
alarmSupport() — @/lib/appmintNative
Example
Whether scheduleAlarm can set an alarm right now, and what is missing if not.
Returns: 'ready', 'not_enabled' (built without Alarm clock), 'needs_exact_alarm' (Android 12+: exact alarms are off), 'needs_notification_permission' (Android 13+), or 'unavailable' (the web and iOS - do not draw alarm controls there). Needs: 🔔 Alarm clock in the Access step (Step 4) when you build.
import { alarmSupport } from '@/lib/appmintNative';
export function AlarmSetupBanner() {
const s = alarmSupport();
if (s === 'ready' || s === 'unavailable') return null;
const text = {
not_enabled: 'This app was built without alarm clocks.',
needs_exact_alarm: 'Allow "Alarms & reminders" for this app in Settings.',
needs_notification_permission: 'Allow notifications so you can stop the alarm.',
}[s];
return <p role="alert">{text}</p>;
}Set a daily alarm only when the phone is ready, and say why when it is not:
import { alarmSupport, scheduleAlarm } from '@/lib/appmintNative';
export function setMorningAlarm(hour: number, minute: number): string {
const s = alarmSupport();
if (s !== 'ready') return `Alarm not set (${s}).`; // 'not_enabled', 'needs_exact_alarm', ...
const first = new Date();
first.setHours(hour, minute, 0, 0);
if (first.getTime() <= Date.now()) first.setDate(first.getDate() + 1);
const ok = scheduleAlarm('morning', { at: first.getTime(), title: 'Wake up', repeat: 'daily', snoozeMinutes: 5 });
return ok ? 'Alarm set.' : 'The phone did not accept the alarm.';
}Hide the alarm screen on the web and react when an alarm ends, combining it with onAlarm:
import { useEffect, useState } from 'react';
import { alarmSupport, onAlarm, snoozeAlarm, stopAlarm } from '@/lib/appmintNative';
export function AlarmPanel() {
const [ringing, setRinging] = useState<string | null>(null);
useEffect(() => onAlarm((e) => {
if (e.action === 'open') setRinging(e.ringing ? e.id : null);
else setRinging(null); // dismiss, snooze, missed, error
}), []);
if (alarmSupport() === 'unavailable') return null; // web and iOS: draw nothing
if (!ringing) return null;
return (
<div role="alertdialog">
<button onClick={() => stopAlarm(ringing)}>Stop</button>
<button onClick={() => snoozeAlarm(ringing, 10)}>Snooze 10 min</button>
</div>
);
}appmintNative.App appmintNative#
App.getInfo(): Promise<{ name, id, build, version }> / App.exitApp(): void / App.addListener(event, fn)
Example
The app's name, package id and version, a way to close the app, and the app's events - in the shape of Capacitor's App plugin.
Returns: getInfo() → Promise<{ name: string; id: string; build: string; version: string }>. exitApp() → void. addListener(event, fn) → Capacitor's shape (a Promise of the handle that also has remove()). Events: appStateChange ({ isActive }), pause, resume (the page going to / coming from the background), appUrlOpen ({ url }, a deep link - same as onDeepLink), backButton (the back press or gesture; your handler decides - the app does not also close). 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();
}Show the app version in a React screen.
import { useEffect, useState } from 'react';
import { App } from '@/lib/appmintNative';
export function VersionLabel() {
const [text, setText] = useState('');
useEffect(() => {
void App.getInfo().then((i) => setText(`${i.name} v${i.version}`));
}, []);
return <small>{text}</small>;
}Notes: 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');Open the maps app with a geo: link, and say so when nothing took it:
import { appLauncher } from '@/lib/appmintNative';
export async function showOnMap(lat: number, lng: number, label: string) {
const ok = await appLauncher.open(`geo:${lat},${lng}?q=${lat},${lng}(${encodeURIComponent(label)})`);
if (!ok) showMessage('No maps app could open this place.');
}Notes: In the app it uses the Capacitor AppLauncher plugin. On Android 11+ canOpen can only see schemes the app declares (https, geo, google.navigation, tel, mailto, sms, whatsapp, intent, upi); for others it answers false even when the app is installed - call open() and check its result instead. On the web a custom scheme cannot be checked, so canOpen is true only for http(s), and open navigates to the link.
appmintNative.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 />;.
Stop the lock on sign-out, so a signed-out user is not asked to unlock:
import { appLock } from '@/lib/appmintNative';
let stopLock: (() => void) | null = null;
export function onSignedIn() {
stopLock = appLock({ timeoutSec: 120, title: 'Unlock your wallet' });
}
export function onSignedOut() {
stopLock?.(); // removes the listener and turns the privacy screen off
stopLock = null;
}Offer the lock only where the device can open it, and combine it with unlock() for a manual "Lock now" button:
import { appLock, can, unlock } from '@/lib/appmintNative';
export function LockSettings({ onLockedChange }: { onLockedChange: (locked: boolean) => void }) {
if (can('unlock') === 'none') return <p>This device has no screen lock to use.</p>; // appLock would do nothing here
return (
<>
<button onClick={() => appLock({ onLocked: onLockedChange })}>Turn on app lock</button>
<button onClick={async () => { onLockedChange(true); if (await unlock('Unlock')) onLockedChange(false); }}>
Lock now
</button>
</>
);
}Notes: If the device has nothing to unlock with (can('unlock') === 'none') it does nothing and returns a no-op - an app lock that could never open would trap the user. If the user cancels, it asks again up to five times, then stays locked (keep your lock screen up) until the app goes to the background and comes back. This is separate from the PIN Lock option in the build wizard, which the app shows natively at launch.
appmintNative.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);
});
}Compare Play's version code with the installed one, and treat null as "cannot check here":
import { appUpdate } from '@/lib/appmintNative';
export async function updateStatus(): Promise<string> {
const info = await appUpdate.check();
if (!info) return 'Update check is not available here.'; // web, no Play services, or Play failed
if (!info.updateAvailable) return `You have the latest version (${info.currentVersion}).`;
return `Update available: version code ${info.availableVersion} (you have ${info.currentVersionCode}).`;
}A React banner that checks once when the screen opens:
import { useEffect, useState } from 'react';
import { appUpdate, type AppUpdateInfo } from '@/lib/appmintNative';
export function UpdateBanner() {
const [info, setInfo] = useState<AppUpdateInfo | null>(null);
useEffect(() => { void appUpdate.check().then(setInfo); }, []);
if (!info?.updateAvailable) return null;
return <button onClick={() => void appUpdate.start()}>Update now</button>;
}Notes: 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.askForReview appmintNative#
askForReview(options): Promise<ReviewOutcome>
Example
Asks Google Play to show its own rating sheet inside the app and reports what happened.
Returns: Promise<ReviewOutcome> - { result, openedStore, message? } with result 'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable' ('unavailable' on the web). { openStore: true } opens the Play listing when the sheet did not show. Needs: nothing; the sheet only appears for an app installed from Google Play.
import { askForReview } from '@/lib/appmintNative';
export async function onRateTap() {
const r = await askForReview({ openStore: true }); // listing if Play shows no sheet
if (r.result === 'shown' || r.openedStore) toast('Thanks for rating!');
}Tell the user what happened for each outcome:
import { askForReview } from '@/lib/appmintNative';
export async function rateMessage(): Promise<string> {
const r = await askForReview();
switch (r.result) {
case 'shown': return 'Thanks for rating!';
case 'not_from_play': return 'Rating works for installs from Google Play.';
case 'quota': return 'Google limits how often the sheet shows. Try again later.';
case 'unavailable': return ''; // the web: draw no rating button at all
default: return `Could not open the rating sheet${r.message ? `: ${r.message}` : ''}.`;
}
}Ask once, after the user finishes their third task, and remember that you asked:
import { askForReview } from '@/lib/appmintNative';
export async function maybeAskAfterTask(completed: number) {
if (completed !== 3 || localStorage.getItem('asked-review')) return;
localStorage.setItem('asked-review', '1');
await askForReview();
}Notes: Ask after a success moment and rarely. A sideloaded test install always answers not_from_play.
appmintNative.backgroundLocation appmintNative#
import { backgroundLocation } from '@/lib/appmintNative'
Example
Location that keeps coming while the app is in the background: start, stop, onFix, buffered, clearBuffered, requestAlways, permissionState, available.
Returns: start(options) → Promise<BgResult> ({ ok, background, backgroundPermission, precise } or { ok: false, reason }). stop / clearBuffered → Promise<boolean>. buffered() → Promise<LocationFix[]>. requestAlways() → Promise<{ foreground, background, precise, error? }>. onFix(cb) → an unsubscribe; cb gets each appmint:location detail. Needs: Background location in the build; location permission (ask with navigator.geolocation first).
import { backgroundLocation, type LocationFix } from '@/lib/appmintNative';
export async function startRun(onPoint: (p: LocationFix) => void) {
const r = await backgroundLocation.start({ intervalMs: 5000, minDistanceM: 5, accuracy: 'high',
uploadUrl: 'https://api.example.com/run-points' });
if (!r.ok) throw new Error(r.reason);
const off = backgroundLocation.onFix((d) => { if (d.type === 'fix') onPoint(d); });
// Points recorded while the screen was off:
(await backgroundLocation.buffered()).forEach(onPoint);
await backgroundLocation.clearBuffered();
return async () => { off(); await backgroundLocation.stop(); };
}Ask for "Allow all the time" before starting, and explain a refusal:
import { backgroundLocation } from '@/lib/appmintNative';
export async function beginTracking(): Promise<string> {
if (backgroundLocation.available() === 'none') return 'Background location works in the app only.';
const p = await backgroundLocation.requestAlways();
if (p.error) return `Could not ask for permission (${p.error}).`;
if (p.background !== 'granted') return 'Choose "Allow all the time" to keep tracking with the screen off.';
const r = await backgroundLocation.start({ intervalMs: 10000, accuracy: 'balanced' });
return r.ok ? 'Tracking.' : `Could not start (${r.reason}).`;
}A React hook that shows live points and stops listening when the screen closes:
import { useEffect, useState } from 'react';
import { backgroundLocation, type LocationFix } from '@/lib/appmintNative';
export function useLastFix(): LocationFix | null {
const [fix, setFix] = useState<LocationFix | null>(null);
useEffect(() => backgroundLocation.onFix((d) => {
if (d.type === 'fix') setFix(d); // { type: 'error', reason } events are skipped here
}), []); // onFix returns its unsubscribe
return fix;
}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.backgroundTasks appmintNative#
import { backgroundTasks } from '@/lib/appmintNative'
Example
The page's own code, run periodically with the app closed (typed wrapper over AppMint.background): register, unregister, list, runNow, done, isBackgroundRun, currentRun, onRun, available.
Returns: register / runNow → Promise<BgResult>; unregister / done → Promise<boolean>; list() → the registered tasks with lastRunAt / lastResult; isBackgroundRun() / currentRun() are synchronous. Needs: Run page code while closed in the build.
import { backgroundTasks, notify } from '@/lib/appmintNative';
// In the app's entry file, before rendering:
if (backgroundTasks.isBackgroundRun()) {
backgroundTasks.onRun(async (run) => {
try {
const unread = await fetch('/api/unread').then((r) => r.json());
if (unread.count > 0) await notify({ title: 'New messages', body: `${unread.count} unread` });
await backgroundTasks.done(run.runId, { ok: true });
} catch {
await backgroundTasks.done(run.runId, { ok: false, retry: true });
}
});
} else {
void backgroundTasks.register('unread', { intervalMinutes: 30, requiresNetwork: true });
// renderApp();
}Register on sign-in, remove on sign-out, and list what is registered:
import { backgroundTasks } from '@/lib/appmintNative';
export async function onSignedIn() {
const r = await backgroundTasks.register('sync', { intervalMinutes: 60, requiresNetwork: true });
if (!r.ok) console.warn('Background sync not set up:', r.reason); // 'not_enabled' or 'unsupported'
}
export async function onSignedOut() {
await backgroundTasks.unregister('sync');
}
export async function taskCountText(): Promise<string> {
const tasks = await backgroundTasks.list();
return tasks.length ? `Registered tasks: ${tasks.length}` : 'No background tasks.';
}Run a task right now from a "Sync now" button and check the answer:
import { backgroundTasks } from '@/lib/appmintNative';
export function SyncNowButton() {
return (
<button
disabled={backgroundTasks.available() === 'none'}
onClick={async () => {
const r = await backgroundTasks.runNow('sync');
if (!r.ok) alert(`Could not run it (${r.reason}).`);
}}
>
Sync now
</button>
);
}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();
}Mirror the unread count when a push message arrives while the app is open:
import { badge, pushInbox } from '@/lib/appmintNative';
export function watchInbox(): () => void {
const refresh = () => void badge.set(pushInbox().filter((m) => !m.read).length);
window.addEventListener('appmint:push', refresh);
refresh();
return () => window.removeEventListener('appmint:push', refresh);
}Mark everything read and clear the count together; skip the badge where it does not exist:
import { badge, can, markPushRead } from '@/lib/appmintNative';
export async function markAllRead(): Promise<boolean> {
markPushRead(); // no id = every message
if (can('badge') === 'none') return false; // the web: nothing to clear
await badge.clear();
return true;
}Notes: 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)));Build a format picker that lists only what this device can read:
import { useEffect, useState } from 'react';
import { barcodeFormats } from '@/lib/appmintNative';
export function FormatPicker({ onPick }: { onPick: (format: string) => void }) {
const [formats, setFormats] = useState<string[]>([]);
useEffect(() => { void barcodeFormats().then(setFormats); }, []);
if (formats.length === 0) return <p>Barcode reading is not available on this device.</p>; // [] = none
return (
<select onChange={(e) => onPick(e.target.value)}>
{formats.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
);
}Notes:
- It asks the browser engine's
BarcodeDetector.getSupportedFormats(). The Android app's WebView has it (measured: aztec, code_128, code_39, code_93, codabar, data_matrix, ean_13, ean_8, itf, pdf417, qr_code, upc_a, upc_e); many desktop browsers and Safari do not, and answer[]. - This list is about
readBarcodeFrom()/scanBarcode().scanCode()in the app uses Google's scanner instead and does not depend on it.
appmintNative.batteryOptimization appmintNative#
import { batteryOptimization } from '@/lib/appmintNative'
Example
The battery-optimisation exemption: isIgnoring() and request().
Returns: isIgnoring() → boolean. request() → Promise<string>: 'already_ignoring', 'dialog_opened' (build option "Ask to skip battery optimisation directly"), 'settings_opened', 'unavailable', or 'unsupported' outside the Android app. Needs: nothing for the settings list.
import { batteryOptimization } from '@/lib/appmintNative';
export async function makeReliable() {
if (batteryOptimization.isIgnoring()) return true;
const how = await batteryOptimization.request();
if (how === 'settings_opened') alert('Choose this app in the list and set it to "Not optimised".');
return false;
}Show the current state on a settings row:
import { useState } from 'react';
import { batteryOptimization } from '@/lib/appmintNative';
export function BatteryRow() {
const [ignoring, setIgnoring] = useState(batteryOptimization.isIgnoring());
return (
<button
disabled={ignoring}
onClick={async () => {
const how = await batteryOptimization.request();
if (how === 'unsupported' || how === 'unavailable') alert('This phone has no setting for it.');
setIgnoring(batteryOptimization.isIgnoring()); // reads the current answer after the dialog or settings screen
}}
>
{ignoring ? 'Battery limits are off' : 'Allow background work'}
</button>
);
}Ask for the exemption before starting a long background job, together with keepAlive:
import { batteryOptimization, keepAlive } from '@/lib/appmintNative';
export async function startUpload() {
if (!batteryOptimization.isIgnoring()) await batteryOptimization.request();
const r = await keepAlive.start({ title: 'Uploading photos', type: 'dataSync' });
if (!r.ok) throw new Error(r.reason);
}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>
);
}Gate the screen on the installed app.
import { bleSensors, can } from '@/lib/appmintNative';
export function SensorsEntry() {
// 'native' only: in Chrome can('bluetooth') is 'web', which bleSensors does not use.
if (can('bluetooth') !== 'native') return <p>Heart-rate sensors work in the installed app.</p>;
return <button onClick={() => void bleSensors.startScan()}>Find sensors</button>;
}Stop the scan on the first sensor and watch cycling speed.
import { useEffect, useState } from 'react';
import { bleSensors } from '@/lib/appmintNative';
type BikeEvent = { kind: string; address?: string; type?: string; data?: { speedKmh?: number } };
export function BikeSpeed() {
const [kmh, setKmh] = useState<number | null>(null);
useEffect(() => {
bleSensors.setWheelCircumference(2096); // 700x23c
const onBle = (e: Event) => {
const d = (e as CustomEvent<BikeEvent>).detail;
if (d.kind === 'device' && d.address) {
void bleSensors.stopScan().then(() => bleSensors.connect(d.address!));
} else if (d.kind === 'data' && d.type === 'speed_cadence') {
setKmh(d.data?.speedKmh ?? null);
}
};
window.addEventListener('appmint:ble', onBle);
void bleSensors.startScan();
return () => { window.removeEventListener('appmint:ble', onBle); void bleSensors.disconnect(); };
}, []);
return <p>{kmh === null ? 'Looking for a sensor...' : `${kmh.toFixed(1)} km/h`}</p>;
}Notes: Scans last 15 seconds (stopScan() ends one early); one sensor is connected at a time. Error codes: not_enabled, permission_denied, bluetooth_unavailable, bluetooth_off, scan_failed, invalid_address. 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 />;
}Remember the level, brighten, then put it back.
import { brightness } from '@/lib/appmintNative';
export async function showBoardingPass(render: () => Promise<void>) {
const before = await brightness.get(); // -1 = following the system, null = not readable here
const ok = await brightness.set(1);
try {
await render();
} finally {
if (ok) await brightness.restore(); // returns to the system level
}
return before;
}Keep the screen bright and awake while a code is on show.
import { brightness, keepAwake, can } from '@/lib/appmintNative';
import { useEffect } from 'react';
export function useQrPresenting(active: boolean) {
useEffect(() => {
if (!active) return;
if (can('brightness') !== 'none') void brightness.set(0.95);
void keepAwake(true);
return () => { void brightness.restore(); void keepAwake(false); };
}, [active]);
}Notes: It changes only this app's window, not the phone's brightness setting, and the override ends when the app closes. restore() sets -1 (follow the system). 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.'); }
}A terms link in a React component.
import { browser, toast } from '@/lib/appmintNative';
export function TermsLink() {
return (
<button
onClick={async () => {
if (!(await browser.open('https://example.com/terms'))) toast('Could not open the terms page');
}}
>
Terms of use
</button>
);
}Close the tab from code and refresh when the user comes back.
import { useEffect } from 'react';
import { browser } from '@/lib/appmintNative';
export function usePaymentTab(onBack: () => void) {
useEffect(() => browser.onClosed(onBack), [onBack]); // the returned function unsubscribes
return {
start: (url: string) => browser.open(url),
cancel: () => browser.close(), // closes the tab in the app; does nothing on the web
};
}Notes: toolbarColor defaults to the app's --bg token. The tab is a separate browser - it does not share the app's cookies or localStorage. close() closes the tab from code in 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 the user refused calendar access - it rejects when the event itself could not be created, e.g. invalid dates). listEvents({ from, to }) → Promise<{ id, title, start, end, location?, notes?, allDay? }[]> (times in epoch ms). deleteEvent(id) / openCalendar(date?) → Promise<boolean>. Only createEvent rejects, and only for an event that could not be created. 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());Add to the calendar only when writing is possible.
import { calendar, can, toast } from '@/lib/appmintNative';
export async function addReminder(title: string, when: Date) {
if (can('calendarWrite') === 'none') { toast('Calendar is not available here'); return null; }
const id = await calendar.createEvent({ title, start: when, end: new Date(when.getTime() + 30 * 60_000) });
toast(id ? 'Added to your calendar' : 'Calendar access was refused');
return id;
}Notes: On the web createEvent downloads an .ics file the user opens in their calendar (returns its UID), listEvents returns [] (can('calendarRead') is 'none'), and deleteEvent / openCalendar return false. Reminders and repeating events are available on Capacitor.Plugins.Calendar, not through this wrapper.
appmintNative.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.
Show a hint instead of a dead control, and turn a feature into a plain yes/no with has():
import { can, has, keepAwake } from '@/lib/appmintNative';
export function KeepScreenOn({ on }: { on: boolean }) {
if (!has('keepAwake')) return <p>Keep-awake is not available on this device.</p>;
return <label><input type="checkbox" checked={on} onChange={(e) => void keepAwake(e.target.checked)} /> Keep screen on</label>;
}
export const scansInApp = can('codeScanner') === 'native';Notes:
- A Capacitor plugin baked into the app always answers
'native'; otherwise the shell bridge is checked, then the web standard. 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(); // []
}Show Clear only when there is something to clear, and report the result:
import { clearPushInbox, pushInbox, toast } from '@/lib/appmintNative';
export function InboxToolbar({ onChange }: { onChange: () => void }) {
const count = pushInbox().length;
if (count === 0) return null;
return (
<button
onClick={() => {
// false on the web, or in an app built without AppMint Push
if (clearPushInbox()) { toast('Inbox cleared'); onChange(); }
else toast('Could not clear the inbox here');
}}
>
Clear all ({count})
</button>
);
}Mark everything read instead of deleting it, which loses nothing:
import { markPushRead, pushInbox } from '@/lib/appmintNative';
export function markAllRead(): number {
const unread = pushInbox().filter((m) => !m.read).length;
markPushRead(); // no argument = every message
return unread;
}Notes: tray notifications and your send history in 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>
);
}Let the user switch the sound off in your own settings:
import { click } from '@/lib/appmintNative';
export function useTapSound(enabled: boolean) {
return () => { if (enabled) click(); }; // a no-op on the web and in apps built without Tap Sound
}Play it from a list row together with a toast:
import { click, toast } from '@/lib/appmintNative';
export function Row({ name, onPick }: { name: string; onPick: () => void }) {
return <li onClick={() => { click(); onPick(); toast(`${name} selected`); }}>{name}</li>;
}Notes: With Follow phone setting (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
}
}Copy a code and confirm with a toast:
import { Clipboard, toast } from '@/lib/appmintNative';
export async function copyCode(code: string) {
await Clipboard.write({ string: code });
toast('Code copied');
}Write a link when there is no text, and treat an empty clipboard as nothing to paste:
import { Clipboard } from '@/lib/appmintNative';
export async function copyUrl(url: string) {
await Clipboard.write({ url }); // used only because string is empty
}
export async function readCode(): Promise<string | null> {
try {
const { value } = await Clipboard.read();
return value.trim() || null; // '' = the clipboard is empty
} catch {
return null; // the browser denied access
}
}Notes: In the app it uses the Capacitor Clipboard plugin; in a browser navigator.clipboard. write sends only string (or url when string is empty) - no images. In a browser, read() can throw when the user denies access; in the app an empty clipboard is '', not an error. Ask can('clipboardRead') before you show a Paste control.
appmintNative.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); }Stop listening from plain script; this also removes --collapse and data-collapsed:
import { collapsingHeader } from '@/lib/appmintNative';
const header = document.querySelector<HTMLElement>('.app-header')!;
const stop = collapsingHeader(header); // whole-page scroll, default 64 px range
addEventListener('pagehide', stop);Notes:
- Default
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.');
});
}A reusable hook that hides the button when there is no number.
import { composeSms, can } from '@/lib/appmintNative';
export function useTextShop(phone: string) {
const available = can('smsCompose') !== 'none' && phone.trim() !== '';
return {
available,
send: (orderId: string) => composeSms(phone, `Hi, about order ${orderId}.`),
};
}Invite a friend; the user presses send.
import { composeSms, toast } from '@/lib/appmintNative';
export function inviteFriend(number: string, link: string) {
if (!composeSms(number, `Join me: ${link}`)) toast('Could not open the messaging app');
}Notes: Prefer this over sendSms() for anything the user writes or approves - nothing leaves the phone without the user pressing send. 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' });Report both outcomes, with a haptic only on success:
import { copy, haptic, toast } from '@/lib/appmintNative';
export async function copyCoupon(code: string) {
const ok = await copy(code);
if (ok) haptic('success');
toast(ok ? `Copied ${code}` : 'Copy failed, press and hold to copy');
return ok;
}Notes: In the app the Capacitor Clipboard plugin writes the text (a plugin error rejects the promise instead of returning false). In a browser it uses navigator.clipboard.writeText, which needs an https page and usually a user tap.
appmintNative.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');
}Copy a photo from the camera, then confirm it with a toast.
import { camera, copyImage, toast } from '@/lib/appmintNative';
export async function copySnapshot() {
const photo = await camera.take({ source: 'camera' });
if (!photo) return; // the user cancelled
const blob = await (await fetch(photo.dataUrl)).blob();
toast((await copyImage(blob)) ? 'Photo copied' : 'Could not copy the photo');
}Show the button only where the clipboard accepts images, and copy an image from a URL.
import { useEffect, useState } from 'react';
import { can, copyImage, toast } from '@/lib/appmintNative';
export function CopyLogoButton({ src }: { src: string }) {
const [ok, setOk] = useState(false);
useEffect(() => { setOk(can('clipboardImage') !== 'none'); }, []);
if (!ok) return null;
return (
<button onClick={async () => {
const blob = await (await fetch(src)).blob();
toast((await copyImage(blob)) ? 'Logo copied' : 'Copy failed');
}}>
Copy logo
</button>
);
}Notes: PNG is the safest type everywhere; the Android app also accepts JPEG and WebP.
appmintNative.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 }),
});Show the app version and the device model in a settings footer. The sync report gives the app version; the plugin adds the operating system.
import { deviceInfo, Device } from '@/lib/appmintNative';
export async function footerText(): Promise<string> {
const base = deviceInfo(); // sync: platform, language, model, appVersion
const rich = await Device.getInfo(); // async: adds operatingSystem, isVirtual
const app = base.appVersion ? `v${base.appVersion}` : 'web';
return `${app} · ${rich.model ?? 'browser'} · ${rich.operatingSystem}`;
}Notes: 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}%`);
}Pick the right control from the platform.
import { deviceInfo } from '@/lib/appmintNative';
export function ShareHint() {
const { platform, language } = deviceInfo();
return platform === 'android'
? <p>Tap Share to send this from your phone.</p>
: <p>Copy the link to share it ({language}).</p>;
}Notes: model and appVersion are filled in the 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 }>. confirm and prompt never reject (dismissal is an answer); alert rejects only when it cannot be shown (no message). 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());Confirm before signing out. Dismissing the dialog counts as "no".
import { Dialog, toast } from '@/lib/appmintNative';
export async function signOut(doSignOut: () => Promise<void>) {
const { value } = await Dialog.confirm({
title: 'Sign out?',
message: 'You will need to sign in again.',
okButtonTitle: 'Sign out',
cancelButtonTitle: 'Stay',
});
if (!value) return;
await doSignOut();
toast('Signed out');
}Notes: Dismissing a confirm gives { value: false }; dismissing a prompt gives { value: '', cancelled: true }. Dialogs block the screen - use them for real decisions, and toast() for "Saved"-style messages. prompt does not pass custom button titles to the plugin.
appmintNative.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());
});
}Show the inbox with a swipe handler and an undo state, so a swipe is never final.
import { useState } from 'react';
import { pushInbox, dismissPushMessage, restorePushMessage } from '@/lib/appmintNative';
export function Inbox() {
const [items, setItems] = useState(pushInbox());
const [undoId, setUndoId] = useState<string | null>(null);
const swipe = (id: string) => {
if (!dismissPushMessage(id)) return; // false on the web or when AppMint Push is off
setItems(pushInbox());
setUndoId(id);
};
const undo = () => {
if (undoId && restorePushMessage(undoId)) setItems(pushInbox());
setUndoId(null);
};
return (
<ul>
{items.map((m) => <li key={m.id} onClick={() => swipe(m.id)}>{m.title}</li>)}
{undoId && <button onClick={undo}>Undo</button>}
</ul>
);
}Hide every read message at once, and report how many were hidden.
import { pushInbox, dismissPushMessage, toast } from '@/lib/appmintNative';
export function tidyReadMessages(): number {
const hidden = pushInbox().filter((m) => m.read && dismissPushMessage(m.id)).length;
toast(hidden ? `${hidden} message(s) hidden` : 'Nothing to hide');
return hidden;
}Notes: only this phone's inbox changes; the tray notification and your send history stay.
appmintNative.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>
);
}Ask the phone first, because an older device may lack the scanner, then keep the pages.
import { useEffect, useState } from 'react';
import { docScanner, can } from '@/lib/appmintNative';
export function ScanPanel() {
const [ready, setReady] = useState(false);
const [pages, setPages] = useState<string[]>([]);
useEffect(() => {
if (can('docScanner') === 'none') return; // web: no scanner at all
docScanner.isSupported().then(setReady); // false without Google Play services
}, []);
if (!ready) return <p>Scanning is not available on this phone.</p>;
return (
<>
<button onClick={async () => {
const res = await docScanner.scan({ pageLimit: 5 });
if (!res) return; // cancelled or could not start
setPages((p) => [...p, ...res.pages]);
}}>Scan pages</button>
{pages.map((src, i) => <img key={i} src={src} alt={`Page ${i + 1}`} width={120} />)}
</>
);
}Scan, then upload the first page with the file-transfer helper.
import { docScanner, fileTransfer } from '@/lib/appmintNative';
export async function scanAndUpload(): Promise<boolean> {
const res = await docScanner.scan({ pageLimit: 1, quality: 0.7 });
if (!res || res.pages.length === 0) return false;
const blob = await (await fetch(res.pages[0])).blob();
const file = new File([blob], 'page-1.jpg', { type: 'image/jpeg' });
const r = await fileTransfer.upload('https://example.com/api/scans', file, { fieldName: 'scan' });
return r.responseCode >= 200 && r.responseCode < 300;
}Notes: pageLimit 0 (default) = no limit; quality 0.1-1 (default 0.85). With pdf: true, pdfPath is a file path in the app's cache, 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.enableNotifications appmintNative#
enableNotifications(): Promise<EnableNotificationsResult>
Example
Brings a user who denied or skipped notifications back, from a button they tapped: the permission dialog while it can still be shown, otherwise (Android app) this app's notification settings.
Returns: Promise<{ state, changed, openedSettings }> - state is 'granted' | 'prompt' | 'blocked' | 'unavailable', changed is true when it differs from before the call, openedSettings is true when the settings screen was used. Never rejects. On the web it asks through Notification.requestPermission(). Needs: notifications in the build (they are on whenever the app sends or receives notifications).
import { enableNotifications, getNotificationState } from '@/lib/appmintNative';
export async function TurnOnNotificationsBanner(el: HTMLElement) {
const state = await getNotificationState();
el.hidden = !(state === 'prompt' || state === 'blocked');
el.querySelector('button')!.addEventListener('click', async () => {
const r = await enableNotifications();
el.hidden = r.state === 'granted';
});
}Notes: show the banner only for 'prompt' and 'blocked'; hide it for 'granted' and 'unavailable'. Ask from a tap, never on launch.
appmintNative.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 }>. upload resolves whatever HTTP status the server answered (check responseCode), in the app as on the web; both reject when the transfer itself fails (no connection, a bad file). 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})`);
}Download with a progress state, then open the file, in one hook.
import { useState } from 'react';
import { fileTransfer, fileViewer } from '@/lib/appmintNative';
export function useOpenDocument() {
const [percent, setPercent] = useState(0);
const [error, setError] = useState<string | null>(null);
const run = async (url: string, name: string) => {
setError(null);
try {
const r = await fileTransfer.download(url, name, { onProgress: (p) => setPercent(Math.round(p.fraction * 100)) });
if (r.path) await fileViewer.open(r.path); // in the app; on the web the browser saved it
} catch (e) {
setError(String(e));
}
};
return { percent, error, run };
}Notes: fraction is 0 while the server sends no length. In the app a download lands in the app's CACHE folder (not the user's Downloads); to give the user a copy, use saveFile. For an upload the app first copies the file into its cache, so very large files need that much free space. method (default 'POST') and fileName are optional upload options.
appmintNative.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 webOpen a link from a button and tell the user when no viewer could open it.
import { fileViewer, toast } from '@/lib/appmintNative';
export function ManualButton({ url }: { url: string }) {
return (
<button onClick={async () => {
if (!(await fileViewer.open(url))) toast('No app can open this file');
}}>
View manual
</button>
);
}Notes: An http(s) target uses openDocumentFromUrl; anything else is treated as a local path (file:// is stripped). On the web only http(s):, blob: and data: targets open; a local path answers false.
appmintNative.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 folderBack up a note to the granted folder, and say so plainly when no folder is connected.
import { can, folder, toast } from '@/lib/appmintNative';
export async function backupNote(name: string, text: string) {
if (can('folderAccess') === 'none') return;
if (!(await folder.connected()) && !(await folder.request())) {
toast('Choose a folder to save backups');
return;
}
const ok = await folder.writeText(`backups/${name}.md`, text);
toast(ok ? 'Backed up' : 'Could not write to the folder');
}Notes: can('folderAccess') is 'none' on the web - there is no persistent-folder equivalent in a phone browser, so do not render the feature there (use pickFile() / saveFile() for single files). Call request() only from a user tap. .. and absolute paths are refused. The granted folder itself can never be removed. stat() works for files and sub-folders (a folder has isDirectory: true and size: 0); stat('') - the root - is null, use label() for it. request() can be called again after a cancel.
appmintNative.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(); };
}, []);
}Hide the system bars for a reading view and follow the state from a button.
import { useEffect, useState } from 'react';
import { fullscreen, can } from '@/lib/appmintNative';
export function useReaderMode() {
const [on, setOn] = useState(false);
useEffect(() => () => { void fullscreen.exit(); }, []); // always leave on unmount
const toggle = async () => {
if (can('fullscreen') === 'none') return;
if (fullscreen.isOn()) await fullscreen.exit(); else await fullscreen.enter();
setOn(fullscreen.isOn()); // a refused web request leaves it off
};
return { on, toggle };
}Notes:
- In the app this is the shell's own fullscreen (status and navigation bars hide); it is always available to the page.
- On the web the Fullscreen API works only from a user tap. A refused request is ignored quietly, so read
isOn()afterwards instead of assuming.
appmintNative.geofences appmintNative#
import { geofences } from '@/lib/appmintNative'
Example
Geofences that fire with the app closed and survive restarts: add, remove, list, events, clearEvents, onEvent, available.
Returns: add(spec) → Promise<BgResult> resolved once Play services accepted or refused it ({ id, ok, reason? } - background_permission_required, location_off, too_many_geofences, …). remove / clearEvents → Promise<boolean>. list() → Promise<GeofenceSpec[]>. events() → Promise<GeofenceEvent[]> (last 100, including those while the app was closed). onEvent(cb) → an unsubscribe. Needs: Geofences in the build and "Allow all the time" (backgroundLocation.requestAlways()).
import { geofences, backgroundLocation } from '@/lib/appmintNative';
export async function remindAtStore(lat: number, lng: number) {
let r = await geofences.add({ id: 'store', lat, lng, radiusM: 200, events: ['enter'],
notify: { title: 'You are near the store', body: 'Your list is ready' } });
if (!r.ok && r.reason === 'background_permission_required') {
const p = await backgroundLocation.requestAlways();
if (p.background === 'granted') r = await geofences.add({ id: 'store', lat, lng, radiusM: 200, events: ['enter'] });
}
return r.ok;
}
geofences.onEvent((e) => console.log(e.id, e.event, new Date(e.time)));List the active geofences and the transitions recorded while the app was closed.
import { geofences } from '@/lib/appmintNative';
export async function geofenceReport(): Promise<string[]> {
if (geofences.available() === 'none') return []; // web build: no geofences
const active = await geofences.list();
const events = await geofences.events(); // oldest first, last 100
return [
`${active.length} active zone(s)`,
...events.map((e) => `${e.id} ${e.event} at ${new Date(e.time).toLocaleTimeString()}`),
];
}Add a work zone for enter and exit, answer a refusal by its reason, and remove it later.
import { geofences } from '@/lib/appmintNative';
export async function watchOffice(lat: number, lng: number): Promise<string> {
const r = await geofences.add({ id: 'office', lat, lng, radiusM: 150, events: ['enter', 'exit'] });
if (r.ok) return 'Office zone is on';
if (r.reason === 'location_off') return 'Turn location on to use this';
if (r.reason === 'too_many_geofences') return 'Remove a zone first';
return `Could not add the zone (${r.reason ?? 'unknown'})`;
}
export async function stopWatchingOffice() {
await geofences.remove('office');
await geofences.clearEvents();
}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, coarseLocation }> (each 'granted' | 'denied' | 'prompt'; on Android 12+ a user who picks "Approximate" grants coarseLocation only). 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; }
}Get a one-shot position. Returns null when the user refuses.
import { Geolocation } from '@/lib/appmintNative';
export async function nearestCoords(): Promise<{ lat: number; lng: number } | null> {
try {
const pos = await Geolocation.getCurrentPosition({ timeout: 10000 });
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
} catch {
return null; // refused, timed out or location is off
}
}Notes: Plain navigator.geolocation also works in the app; use this when you want the Capacitor shape. On Android 12+ the user may allow only approximate location - you still get a position, just less accurate. On the web requestPermissions() makes one real position request to trigger the browser prompt. Location is personal data: declare it in your Play Data safety form.
appmintNative.getNotificationState appmintNative#
getNotificationState(): Promise<NotificationState>
Example
Whether notifications are on, and if not whether the app can still ask or only Settings can fix it.
Returns: Promise<'granted' | 'prompt' | 'blocked' | 'unavailable'>. On the web, Notification.permission mapped the same way (default → 'prompt', denied → 'blocked'); 'unavailable' where there are no notifications at all. Needs: nothing.
import { getNotificationState } from '@/lib/appmintNative';
export async function shouldShowNotifBanner(): Promise<boolean> {
const state = await getNotificationState();
return state === 'prompt' || state === 'blocked';
}Notes: check again when the app comes back to the foreground (visibilitychange) - the user may have changed it in Settings.
appmintNative.getPushTags appmintNative#
import { getPushTags } from '@/lib/appmintNative'
Example
Reads this install's AppMint Push tags as an object.
Returns: Record<string, string> synchronously - e.g. { plan: 'pro', cart: 'open' }; {} when there are none and on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { getPushTags, setPushTag } from '@/lib/appmintNative';
// Every setPushTag makes the install check in, so skip the calls that change nothing.
export function ensurePushTag(key: string, value: string) {
if ((getPushTags()[key] ?? '') !== value) setPushTag(key, value);
}
ensurePushTag('plan', 'pro');Set several tags in one change with setPushTags when a screen changes more than one, and empty a value to remove its tag:
import { setPushTags } from '@/lib/appmintNative';
// Checkout finished: the cart tag goes away and the plan tag is set, all saved or none.
export function onCheckoutDone(plan: string) {
const saved = setPushTags({ cart: '', plan });
if (!saved) console.warn('push tags not saved (web, AppMint Push off, or a refused key or value)');
}Read the tags back to show a hint only when this install has a tag:
import { getPushTags } from '@/lib/appmintNative';
export function OffersHint() {
const tags = getPushTags(); // {} on the web
if (Object.keys(tags).length === 0) return null;
return <p>Offers for the {tags.plan ?? 'standard'} plan are on.</p>;
}Notes: this is what the phone holds; the send center sees it after the install's next check-in.
appmintNative.getPushUser appmintNative#
import { getPushUser } from '@/lib/appmintNative'
Example
Reads the user id this install last gave AppMint Push with setPushUser.
Returns: string synchronously - the saved id, or '' when signed out, never set, or on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { getPushUser, setPushUser } from '@/lib/appmintNative';
// On app start: the session expired while the app was closed, so stop personal messages here.
export function syncPushUser(currentUserId: string | null) {
const want = currentUserId ?? '';
if (getPushUser() !== want) setPushUser(want);
}Call setPushUser with the account id after sign-in and with '' on sign-out; it returns false when the id is refused or AppMint Push is off:
import { setPushUser } from '@/lib/appmintNative';
export function onSignedIn(userId: string) {
if (!setPushUser(userId)) console.warn('push user id not saved'); // ids only: no email or phone number
}
export function onSignedOut() {
setPushUser('');
}Read the saved id in a React hook, together with a tag that belongs to that user:
import { useEffect, useState } from 'react';
import { getPushUser, setPushTag } from '@/lib/appmintNative';
export function usePushUser(plan: string) {
const [id] = useState(getPushUser);
useEffect(() => {
if (id) setPushTag('plan', plan); // only tag installs that belong to a signed-in user
}, [id, plan]);
return id;
}Notes: this is what the phone holds; the send center sees a change after the install's next check-in, a few seconds later when online.
appmintNative.getVolume appmintNative#
getVolume(stream) — @/lib/appmintNative
Example
Reads the phone's own volume level for one stream: 'media', 'alarm', 'ring', 'notification' or 'call'.
Returns: { stream, current, max, percent, muted? } (current/max in the phone's own steps, percent 0-100), or null on the web. Needs: nothing.
import { getVolume } from '@/lib/appmintNative';
export function AlarmVolumeHint() {
const v = getVolume('alarm');
if (!v || v.percent > 0) return null;
return <p role="alert">Your alarm volume is off, so the alarm will not be heard.</p>;
}getVolume answers null on the web, so draw the level only when it is an object:
import { getVolume } from '@/lib/appmintNative';
export function MediaLevel() {
const v = getVolume('media');
if (!v) return null; // web: no phone volume to show
return <progress max={100} value={v.percent} aria-label="Media volume" />;
}Follow the hardware keys with onVolumeChange, and raise a quiet level with setVolume:
import { useEffect, useState } from 'react';
import { getVolume, onVolumeChange, setVolume, requestDndAccess } from '@/lib/appmintNative';
export function useMediaPercent(): number | null {
const [pct, setPct] = useState(() => getVolume('media')?.percent ?? null);
useEffect(() => onVolumeChange((l) => { if (l.stream === 'media') setPct(l.percent); }), []);
return pct;
}
export function raiseIfQuiet() {
const v = getVolume('media');
if (!v || v.percent >= 40) return;
const r = setVolume('media', 60, true);
if (!r.ok && r.error === 'dnd_access_required') requestDndAccess(); // the user must allow Do Not Disturb access
}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(); }A like button that ticks lightly on and firmer off:
import { haptic } from '@/lib/appmintNative';
export function LikeButton({ liked, onToggle }: { liked: boolean; onToggle: () => void }) {
return (
<button aria-pressed={liked} onClick={() => { haptic(liked ? 'light' : 'medium'); onToggle(); }}>
{liked ? 'Liked' : 'Like'}
</button>
);
}Notes: In the 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
]);Combine it with haptic(): a normal tap feedback for every answer, the long pattern only for the special moment:
import { haptic, hapticPlay } from '@/lib/appmintNative';
export async function onAnswer(correct: boolean, streak: number) {
haptic(correct ? 'success' : 'error'); // every answer
if (correct && streak > 0 && streak % 5 === 0) {
await hapticPlay('drum-roll'); // every fifth in a row
}
}Notes: 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>
);
}A form that confirms with a notification buzz and adds a short vibration when a field is wrong:
import { Haptics } from '@/lib/appmintNative';
export function onSubmit(valid: boolean) {
Haptics.notification({ type: valid ? 'SUCCESS' : 'ERROR' });
if (!valid) Haptics.vibrate({ duration: 200 });
}Notes: selectionStart and selectionChanged both give one selection tick; selectionEnd does nothing. Works in the 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;
}Pick a label by where it runs: has() decides whether to draw the control, can() tells you which kind it is:
import { can, has } from '@/lib/appmintNative';
export function shareLabel(): string | null {
if (!has('share')) return null; // nothing to share with: draw no button
return can('share') === 'native' ? 'Share…' : 'Share link';
}Notes: It takes the same capability names as can() (see that entry for the full list). Use can() when you need to know WHERE it runs ('native' or 'web'), for example to change a label.
appmintNative.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>
);
}Close the keyboard when a sheet opens over the field, so the sheet is not hidden behind it:
import { useState } from 'react';
import { hideKeyboard } from '@/lib/appmintNative';
export function useSheet() {
const [open, setOpen] = useState(false);
return {
open,
show: () => { hideKeyboard(); setOpen(true); },
hide: () => setOpen(false),
};
}Close it when the user taps anywhere that is not a text field:
import type { ReactNode } from 'react';
import { hideKeyboard } from '@/lib/appmintNative';
export function Screen({ children }: { children: ReactNode }) {
return (
<div onPointerDown={(e) => {
if (!(e.target as HTMLElement).closest('input, textarea')) hideKeyboard();
}}>
{children}
</div>
);
}Notes: In the app it asks the Capacitor Keyboard plugin to hide the keyboard. On the web it blurs the focused field, which is what closes the keyboard in a browser. It is safe to call when no keyboard is open.
appmintNative.inAppCampaigns appmintNative#
import { inAppCampaigns } from '@/lib/appmintNative'
Example
The in-app messages this install holds right now, as {id, name}[]: the creator's campaigns this user is in the audience for.
Returns: { id: string; name: string }[] synchronously; [] on the web and without AppMint Push. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { inAppCampaigns } from '@/lib/appmintNative';
console.log(inAppCampaigns().map((c) => c.name));A debug list in an admin screen:
import { inAppCampaigns } from '@/lib/appmintNative';
export function InAppDebug() {
const list = inAppCampaigns();
return <ul>{list.length ? list.map((c) => <li key={c.id}>{c.name}</li>) : <li>None for this install</li>}</ul>;
}Skip your own hint when the creator already runs a message with that name:
import { inAppCampaigns } from '@/lib/appmintNative';
export const hasCampaign = (name: string) => inAppCampaigns().some((c) => c.name === name);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;
}Count this install once in your own analytics.
import { installId } from '@/lib/appmintNative';
export function reportFirstOpen() {
let id: string;
try { id = installId(); } catch { return; } // web with storage blocked: no stable id
if (localStorage.getItem('first-open-sent')) return;
navigator.sendBeacon('https://api.example.com/first-open', JSON.stringify({ install: id }));
localStorage.setItem('first-open-sent', '1');
}Show a short support code that you can look up later, with a copy button.
import { installId, copy, toast } from '@/lib/appmintNative';
export function SupportCode() {
let code = '';
try { code = installId().slice(0, 8); } catch { /* storage blocked: show nothing */ }
if (!code) return null;
return <button onClick={async () => { if (await copy(code)) toast('Copied'); }}>Support code {code}</button>;
}Notes: It stays the same across app updates and changes on uninstall or "clear data". It is not a hardware ID. It throws on the web when localStorage is blocked, on purpose - a shared blank ID would mix users up.
appmintNative.isAlarmRinging appmintNative#
isAlarmRinging() — @/lib/appmintNative
Example
Whether one of the app's alarm clocks is ringing right now.
Returns: boolean, synchronously; always false on the web. Needs: 🔔 Alarm clock in the Access step (Step 4) when you build.
import { useEffect, useState } from 'react';
import { isAlarmRinging, onAlarm } from '@/lib/appmintNative';
export function useRinging(): boolean {
const [ringing, setRinging] = useState(isAlarmRinging);
useEffect(() => onAlarm(() => setRinging(isAlarmRinging())), []);
return ringing;
}Show Stop and Snooze buttons while an alarm rings:
import { isAlarmRinging, stopAlarm, snoozeAlarm } from '@/lib/appmintNative';
export function RingingBar() {
if (!isAlarmRinging()) return null; // always false on the web
return (
<div role="alert">
<button onClick={() => stopAlarm()}>Stop</button>
<button onClick={() => snoozeAlarm('', 5)}>Snooze 5 min</button>
</div>
);
}Check support first, so the page draws alarm controls only where an alarm can ring:
import { alarmSupport, isAlarmRinging } from '@/lib/appmintNative';
export function alarmState(): 'hidden' | 'ringing' | 'idle' {
if (alarmSupport() === 'unavailable') return 'hidden'; // web or iOS: no alarm controls
return isAlarmRinging() ? 'ringing' : 'idle';
}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';
}Tag your analytics events with where they came from.
import { isNativeApp, platform } from '@/lib/appmintNative';
export function analyticsSource(): string {
return isNativeApp() ? `app-${platform()}` : 'browser'; // 'app-android' or 'browser'
}Hide the "Get the app" banner inside the app.
import { isNativeApp } from '@/lib/appmintNative';
export function InstallBanner() {
if (isNativeApp()) return null; // already installed: no banner
return <a href="https://play.google.com/store/apps/details?id=com.example.app">Get the app</a>;
}Notes: 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.keepAlive appmintNative#
import { keepAlive } from '@/lib/appmintNative'
Example
Keeps the app running in the background with a foreground-service notification (start, update, stop, isRunning, onState, available).
Returns: start / update → Promise<BgResult> ({ ok, reason?, type?, notificationShown? }; reason: 'unsupported' in a browser or on iOS, 'not_enabled' when the build did not tick Keep running in background). stop → Promise<boolean>. isRunning() → boolean. onState(cb) → an unsubscribe function; cb gets the appmint:background-service detail. Needs: Keep running in background in the build.
import { keepAlive } from '@/lib/appmintNative';
export async function startRecording() {
if (keepAlive.available() === 'none') return;
const r = await keepAlive.start({ title: 'Recording', text: 'Tap to return', stopLabel: 'Stop' });
if (!r.ok) console.warn('keep running refused:', r.reason);
}
const off = keepAlive.onState((d) => {
if (d.state === 'stopped') console.log('service stopped:', d.reason);
});
// later: await keepAlive.update({ title: 'Recording', text: '12:30' }); await keepAlive.stop(); off();Stop the service, and read why start was refused:
import { keepAlive } from '@/lib/appmintNative';
export async function toggleTracking(on: boolean) {
if (!on) { await keepAlive.stop(); return; }
const r = await keepAlive.start({ title: 'Tracking', text: 'Running', type: 'dataSync' });
if (!r.ok) {
// 'unsupported' = browser or iOS; 'not_enabled' = the build did not tick Keep running in background
alert(r.reason === 'not_enabled' ? 'Turn on Keep running in background and rebuild.' : 'Not available here.');
}
}Keep a React button in step with the service, and show progress in the notification:
import { useEffect, useState } from 'react';
import { keepAlive } from '@/lib/appmintNative';
export function useKeepAlive() {
const [running, setRunning] = useState(keepAlive.isRunning);
useEffect(() => keepAlive.onState((d) => setRunning(d.state === 'running')), []);
return running;
}
export function reportProgress(done: number, total: number) {
void keepAlive.update({ title: 'Uploading', text: `${done} of ${total}`, progress: { value: done, max: total } });
}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 />;
}Keep the screen on only while a timer is running, and tell the user when it could not be done.
import { keepAwake, toast } from '@/lib/appmintNative';
import { useEffect } from 'react';
export function useAwakeWhile(running: boolean) {
useEffect(() => {
if (!running) return;
let held = false;
void keepAwake(true).then((ok) => {
held = ok;
if (!ok) toast('Your screen may turn off while the timer runs');
});
return () => { if (held) void keepAwake(false); };
}, [running]);
}Check the capability first and take the lock again when the tab comes back to the front (the web releases it when the page is hidden).
import { keepAwake, can } from '@/lib/appmintNative';
export function holdScreenOn(): () => void {
if (can('keepAwake') === 'none') return () => {};
void keepAwake(true);
const again = () => { if (document.visibilityState === 'visible') void keepAwake(true); };
document.addEventListener('visibilitychange', again);
return () => {
document.removeEventListener('visibilitychange', again);
void keepAwake(false);
};
}Notes: In the app it uses the KeepAwake plugin (the window's keep-screen-on flag); on the web the Screen Wake Lock API, which browsers release by themselves when the tab is hidden - call keepAwake(true) again when the page becomes visible if the screen must stay on. It only works while the app is in front.
appmintNative.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) → Capacitor's shape: a Promise of the handle that also has remove() itself, so await, .then(h => h.remove()) and .remove() all work. 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(); };
}, []);Combine both to scroll the focused field into view when the keyboard opens, then close the keyboard on Enter:
import { Keyboard } from '@/lib/appmintNative';
import { useEffect } from 'react';
export function useKeyboardScroll() {
useEffect(() => {
const h = Keyboard.addListener('keyboardDidShow', () => {
document.activeElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter') void Keyboard.hide(); };
window.addEventListener('keydown', onKey);
return () => { h.remove(); window.removeEventListener('keydown', onKey); };
}, []);
}Notes: keyboardHeight is the part of the keyboard the page still has to clear, not the raw height: in the Android app the shell resizes the page for the keyboard, so it is 0 there while the keyboard is open (the show event still fires, and fires again when the number changes). The hide events fire only when the keyboard really closes, so they can track "is the keyboard open". "Will" and "did" events fire together. For padding alone, onKeyboard((px) => …) is simpler.
appmintNative.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));Treat the empty string as "opened normally" and pick the start screen from the link only when there is one. A shortcut such as appmint-shortcut://<package>/orders has the route in its path.
import { launchUrl, onDeepLink } from '@/lib/appmintNative';
export async function startRoute(): Promise<string> {
const u = await launchUrl();
if (!u) return '/'; // normal start, or the web
const { pathname, search } = new URL(u);
return pathname + search;
}
// While the app stays open, later links arrive here.
export function watchLinks(go: (route: string) => void): () => void {
return onDeepLink((u) => { const x = new URL(u); go(x.pathname + x.search); });
}Notes:
- The value is KEPT, not consumed: a component that mounts late still sees it. A newer link replaces it.
- 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
}
}Total talk time per contact from outgoing and incoming calls, longest first.
import { listCallLog, can } from '@/lib/appmintNative';
export async function talkTimeByPerson() {
if (can('callLog') === 'none') return null;
const calls = await listCallLog(1000, 0).catch(() => []);
const totals = new Map<string, number>();
for (const c of calls) {
if (c.type !== 'incoming' && c.type !== 'outgoing') continue;
const who = c.name || c.number || 'Unknown';
totals.set(who, (totals.get(who) ?? 0) + (c.duration ?? 0)); // seconds
}
return [...totals].sort((a, b) => b[1] - a[1]);
}A screen that explains the empty list, since "no calls" and "permission refused" look the same.
import { listCallLog, can } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';
export function useCalls() {
const [calls, setCalls] = useState<Awaited<ReturnType<typeof listCallLog>> | null>(null);
const supported = can('callLog') !== 'none';
useEffect(() => {
if (supported) void listCallLog(50).then(setCalls).catch(() => setCalls([]));
}, [supported]);
const hint = !supported ? 'Open the app on your phone to see calls'
: calls && calls.length === 0 ? 'No calls found. Allow call log access in Settings if you expected some.' : '';
return { calls: calls ?? [], hint };
}Notes: limit is clamped to 2000 by the app. An empty list cannot tell "no calls" from "permission refused". can('callLog') is 'none' on the web. 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
}
}Search the whole address book by reading it page by page into one list.
import { listContacts, can, type Contact } from '@/lib/appmintNative';
export async function findContacts(query: string): Promise<Contact[] | null> {
if (can('contactList') === 'none') return null;
const all: Contact[] = [];
for (let offset = 0; ; offset += 200) {
const page = await listContacts(200, offset).catch(() => []);
all.push(...page);
if (page.length < 200) break;
}
const q = query.trim().toLowerCase();
return all.filter((c) => (c.name ?? '').toLowerCase().includes(q));
}Pair it with sendSms to message the first number of a contact.
import { sendSms, type Contact } from '@/lib/appmintNative';
export async function invite(c: Contact): Promise<boolean> {
const phone = c.phones?.[0];
if (!phone) return false;
return sendSms(phone, `Hi ${c.name ?? ''}, join me on the app!`);
}Notes: An empty result cannot tell "no contacts" from "permission refused" - show a short hint ("Allow contacts in Settings") when the list is empty. 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
}
}Unread messages from one sender, newest first.
import { listSms, can } from '@/lib/appmintNative';
export async function unreadFrom(address: string) {
if (can('smsList') === 'none') return null;
const msgs = await listSms('inbox', 200, 0).catch(() => []);
return msgs.filter((m) => m.address === address && !m.read);
}Read the sent box and the drafts in one go, and sort them together by date.
import { listSms, type SmsMessage } from '@/lib/appmintNative';
export async function outgoing(): Promise<SmsMessage[]> {
const [sent, drafts] = await Promise.all([
listSms('sent', 50, 0).catch(() => []),
listSms('draft', 50, 0).catch(() => []),
]);
return [...sent, ...drafts].sort((a, b) => (b.date ?? 0) - (a.date ?? 0));
}Notes: An empty list cannot tell "no messages" from "permission refused". 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');Handle a refusal. start answers false; liveProgress.lastError says why.
import { liveProgress, toast } from '@/lib/appmintNative';
export async function startDelivery() {
const ok = await liveProgress.start({ id: 'order', title: 'Order on its way', text: 'Packing', progress: 0.1, route: '/orders' });
if (!ok) {
if (liveProgress.lastError === 'permission') toast('Allow notifications to follow your order here');
else if (liveProgress.lastError === 'channel_disabled') toast('Live status is turned off in system settings');
}
return ok;
}Notes: the same id updates one card; different ids make separate cards (the default id is 'live'). The user cannot swipe it away, so always call end. Ask for notification permission first on Android 13+ (Notification.requestPermission()): 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. A notification without schedule shows now (through notify()); one with schedule: { at } is a reminder at that time, kept across restarts.
Returns: Promise<{ notifications: { id }[] }>. A reminder that cannot be set (a browser, a build without Notifications, permission refused) rejects with the reason - it is never shown early instead. Repeating reminders: scheduleAlarm or the reminder API. 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' },
],
});
}Several notifications in one call, each replacing an earlier one that has the same tag.
import { LocalNotifications } from '@/lib/appmintNative';
export async function reportBatch(done: number, failed: number) {
await LocalNotifications.schedule({
notifications: [
{ title: 'Upload finished', body: `${done} files sent`, tag: 'upload-ok' },
...(failed ? [{ title: 'Some files failed', body: `${failed} need a retry`, tag: 'upload-failed' }] : []),
],
});
}When you need to know whether it was shown, use notify() instead and react to the answer.
import { notify, toast } from '@/lib/appmintNative';
export async function remind(text: string) {
const shown = await notify({ title: 'Reminder', body: text, tag: 'reminder' });
if (!shown) toast(text); // notifications are off: tell them in the app
}Notes: immediate only - the Capacitor fields id, schedule, at, actionTypeId and extra are not supported; each item is { title, body?, icon?, tag? }. For a notification at a later time, a hand-written page uses WebToApk.scheduleNotificationEx (Android). For buttons use notifyWithActions.
appmintNative.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');Release the lock when the screen goes away, as a hook shared by several screens.
import { useEffect } from 'react';
import { lockOrientation, type OrientationLock } from '@/lib/appmintNative';
export function useOrientation(mode: OrientationLock) {
useEffect(() => {
void lockOrientation(mode);
return () => { void lockOrientation('any'); };
}, [mode]);
}
// useOrientation('landscape') inside the game screenNotes:
- 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 });Pair it with haptic for a custom confirmation, and clean up for each list row.
import { useEffect, useRef } from 'react';
import { haptic, longPress } from '@/lib/appmintNative';
export function SelectableRow({ id, onSelect, children }: { id: string; onSelect(id: string): void; children: React.ReactNode }) {
const ref = useRef<HTMLLIElement>(null);
useEffect(() => {
if (!ref.current) return;
return longPress(ref.current, () => { haptic('heavy'); onSelect(id); }, { haptics: false });
}, [id, onSelect]);
return <li ref={ref}>{children}</li>;
}Notes:
- It fires once per hold and swallows the click that would follow, so a long press does not also "tap".
- Moving more than 8 px cancels it, so scrolling never triggers it. It also blocks the browser's own long-press menu on that element.
- A long press is invisible. Always keep a visible button for the same action.
appmintNative.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();
}Count the unread messages after marking one, for a badge in your own header.
import { pushInbox, markPushRead } from '@/lib/appmintNative';
export function openAndCount(id: string) {
const changed = markPushRead(id); // false on the web or when nothing changed
const unread = pushInbox().filter((m) => !m.read).length;
return { changed, unread };
}Mark everything read when the user opens the inbox screen and refresh when a new message arrives.
import { useEffect, useState } from 'react';
import { pushInbox, markPushRead } from '@/lib/appmintNative';
export function useInbox() {
const [items, setItems] = useState(() => pushInbox());
useEffect(() => {
markPushRead();
const refresh = () => setItems(pushInbox());
window.addEventListener('appmint:push', refresh);
return () => window.removeEventListener('appmint:push', refresh);
}, []);
return items;
}Notes: read state lives only on this phone; the tray notification is not removed.
appmintNative.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>;
}Clear the lock-screen card when playback ends, and leave it alone when the platform has no media controls.
import { can, mediaSession } from '@/lib/appmintNative';
export function publishEnded(audio: HTMLAudioElement): () => void {
if (can('mediaControls') === 'none') return () => {};
const ended = () => {
mediaSession.setPlaybackState({ playing: false, positionMs: 0, durationMs: audio.duration * 1000 || 0 });
mediaSession.clear();
};
audio.addEventListener('ended', ended);
return () => audio.removeEventListener('ended', ended);
}Notes:
- On Android the buttons come through the app's
navigator.mediaSession, which is installed when the page finishes loading. 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();Hook form with automatic cleanup for a React component that reads the phone's movement.
import { useEffect, useState } from 'react';
import { Motion, can, type AccelEvent } from '@/lib/appmintNative';
export function useAcceleration() {
const [g, setG] = useState({ x: 0, y: 0, z: 0 });
const [supported] = useState(() => can('motion') !== 'none');
useEffect(() => {
if (!supported) return;
let handle: { remove(): Promise<void> } | undefined;
let cancelled = false;
void Motion.addListener('accel', (e) => setG((e as AccelEvent).accelerationIncludingGravity)).then((h) => {
if (cancelled) void h.remove(); else handle = h;
});
return () => { cancelled = true; void handle?.remove(); };
}, [supported]);
return { g, supported };
}Notes: 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>
);
}Keep the level in step with a "Mute music" switch, remembering the volume to come back to.
import { useRef, useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';
export function MuteMusicSwitch() {
const before = useRef(Math.max(musicVolume(), 50));
const [muted, setMuted] = useState(() => musicVolume() === 0);
if (musicVolume() < 0) return null; // no music in this build
const toggle = () => {
if (muted) setMusicVolume(before.current);
else { before.current = musicVolume(); setMusicVolume(0); }
setMuted(!muted);
};
return <button onClick={toggle}>{muted ? 'Unmute music' : 'Mute music'}</button>;
}Quieten the music while a video plays, then restore it.
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';
export function duckMusicDuring(video: HTMLVideoElement) {
const level = musicVolume();
if (level < 0) return; // nothing to duck
video.addEventListener('play', () => setMusicVolume(0));
video.addEventListener('pause', () => setMusicVolume(level));
video.addEventListener('ended', () => setMusicVolume(level));
}Notes: Android 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) → Capacitor's shape: a Promise of the handle that also has remove() itself; 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;
}Read the current type once and show it in a status chip.
import { Network } from '@/lib/appmintNative';
import { useEffect, useState } from 'react';
export function ConnectionChip() {
const [label, setLabel] = useState('Checking...');
useEffect(() => {
void Network.getStatus().then((s) =>
setLabel(s.connected ? (s.connectionType === 'cellular' ? 'Mobile data' : 'Wi-Fi') : 'Offline'));
}, []);
return <span className="chip">{label}</span>;
}Hold back a large download on mobile data, and answer the offline case too.
import { Network, toast } from '@/lib/appmintNative';
export async function startBigDownload(download: () => Promise<void>) {
const s = await Network.getStatus();
if (s.connectionType === 'none' || !s.connected) { toast('You are offline.'); return; }
if (s.connectionType === 'cellular' && !window.confirm('Download over mobile data?')) return;
await download();
}Notes: The event name is ignored - every listener gets connectivity changes. connectionType is 'wifi', 'cellular', 'none' or 'unknown': in the app getStatus() and the listener use the Network plugin, on the web navigator.onLine / navigator.connection.type. connected means the phone has a network, not that your server answers.
appmintNative.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;
}Choose a lighter image size on mobile data, with the 'none' (offline) case answered too.
import { networkStatus } from '@/lib/appmintNative';
export function imageUrl(id: string): string {
const { connected, type } = networkStatus();
if (!connected) return '/img/placeholder.svg'; // offline: use the packaged picture
return `/api/images/${id}?size=${type === 'cellular' ? 'small' : 'large'}`;
}Notes:
- In the app it is the Network plugin's answer (kept current in the background, so the very first call may still read the WebView's own value). On the web it is
navigator.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>
);
}Write a link to a tag, and tell the user why it failed when it did.
import { can, nfc, toast } from '@/lib/appmintNative';
export async function writeShareTag(url: string) {
if (can('nfc') === 'none') { toast('This device cannot write NFC tags.'); return; }
toast('Hold a blank tag to the back of the phone...');
const ok = await nfc.write(url);
toast(ok ? 'Tag written.' : 'Could not write the tag. Try again.');
}Read one tag, then stop scanning.
import { nfc } from '@/lib/appmintNative';
export function readOnce(): Promise<string> {
return new Promise<string>((resolve, reject) => {
let stop: (() => Promise<void>) | undefined;
let done = false;
nfc.scan((records) => {
if (done) return;
done = true;
void stop?.();
resolve(records.map((r) => r.text).join(' '));
}).then((s) => { stop = s; if (done) void s(); }, reject);
});
}Notes: On Android isSupported() is true whenever the build has NFC - it does not say whether NFC is switched on (window.AndroidNFC.startScan() does). After the stop function, onTag is never called again. On Android the app itself keeps catching tags while it is in front (so a tap does not open another app), it just stops passing them to you. A text record's text has no language prefix; a URL record's text is the full URL. write() waits until a tag is tapped. For writing URL or mime records, and for locking a tag, use NDEFReader directly.
appmintNative.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';
}Open the right screen when the user taps the notification, combining notify with onNotificationClick.
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { notify, onNotificationClick } from '@/lib/appmintNative';
export async function announceMessage(id: string, from: string) {
await notify({ title: `New message from ${from}`, tag: `msg-${id}`, data: { messageId: id } });
}
export function NotificationRouter() {
const navigate = useNavigate();
useEffect(() => onNotificationClick((e) => {
const { messageId } = (e.data ?? {}) as { messageId?: string };
if (messageId) navigate(`/messages/${messageId}`);
}), [navigate]);
return null;
}Notes: Android uses the shell's notification bridge (icon becomes the large icon; use an https:// or data: image); 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);
});A timer finished: one button adds five minutes, the other stops it.
import { notifyWithActions, onNotificationAction, can } from '@/lib/appmintNative';
export async function timerDone() {
if (can('notificationActions') === 'none') return false;
return notifyWithActions({
title: 'Timer finished',
tag: 'timer',
actions: [{ id: 'more', title: '+5 min' }, { id: 'stop', title: 'Stop' }],
});
}
export const stopTimerButtons = onNotificationAction((e) => {
if (e.tag !== 'timer') return; // ignore other notifications' buttons
if (e.actionId === 'more') startTimer(5);
});Handle a refused permission after the call, and clean up in a component.
import { useEffect, useState } from 'react';
import { notifyWithActions, onNotificationAction } from '@/lib/appmintNative';
export function ApprovalRequest({ requestId }: { requestId: string }) {
const [note, setNote] = useState('');
useEffect(() => onNotificationAction((e) => {
if (e.tag === `req-${requestId}`) setNote(e.actionId === 'approve' ? 'Approved' : 'Rejected');
}), [requestId]);
const ask = async () => {
const posted = await notifyWithActions({
title: 'Approve request?', tag: `req-${requestId}`,
actions: [{ id: 'approve', title: 'Approve' }, { id: 'reject', title: 'Reject' }],
});
if (!posted) setNote('Notifications are off, so no buttons were shown.');
};
return <button onClick={ask}>{note || 'Ask me'}</button>;
}Notes: Each button shows its title; its id is what comes back as e.actionId. 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' });Mount the banner only for the signed-in area of the app, and remove it on sign-out.
import { useEffect } from 'react';
import { offlineBanner } from '@/lib/appmintNative';
export function useOfflineBanner(signedIn: boolean) {
useEffect(() => {
if (!signedIn) return;
return offlineBanner({ message: 'Offline: changes will sync later' });
}, [signedIn]);
}Notes:
- Defaults: "You are offline" and "Back online". The online message hides after about 1.8 seconds; the offline one stays until the connection returns.
- If the device is already offline when it mounts, the banner shows at once.
- Built on
onNetworkChange(), so it is real on every surface. Colours come from the app's tokens (--ink/--bgoffline,--accent/--accent-inkonline). It hasrole="status"for screen readers. - Mount it once. Two calls draw two banners.
appmintNative.onAlarm appmintNative#
onAlarm(cb) — @/lib/appmintNative
Example
Listens for alarm outcomes: the app opened by an alarm (action: 'open', with ringing), and how a ring ended - dismiss, snooze, missed (rang out, or replaced by another alarm) or error. Outcomes from while the app was closed are delivered when the page next loads.
Returns: a function that stops listening. The callback gets { id, action, ringing?, source?, snoozeMinutes?, snoozedUntil?, error?, soundError? }. Needs: 🔔 Alarm clock in the Access step (Step 4) when you build.
import { useEffect, useState } from 'react';
import { onAlarm } from '@/lib/appmintNative';
export function AlarmHost() {
const [ringingId, setRingingId] = useState<string | null>(null);
useEffect(() => onAlarm((e) => {
if (e.action === 'open' && e.ringing) setRingingId(e.id);
if (e.action === 'dismiss' || e.action === 'snooze' || e.action === 'missed') setRingingId(null);
}), []);
return ringingId ? <RingScreen id={ringingId} onClose={() => setRingingId(null)} /> : null;
}Set an alarm, answering alarmSupport() first.
import { alarmSupport, scheduleAlarm } from '@/lib/appmintNative';
export function setWakeUp(at: Date): string {
const support = alarmSupport();
if (support === 'unavailable') return 'Alarms are only available in the Android app.';
if (support !== 'ready') return `Alarm needs attention first (${support}).`;
const ok = scheduleAlarm('wake-up', { at: at.getTime(), title: 'Wake up', repeat: 'weekdays', snoozeMinutes: 10 });
return ok ? 'Alarm set.' : 'Could not set the alarm.';
}Stop or snooze the ringing alarm from your own screen, and react to the outcome.
import { useEffect, useState } from 'react';
import { onAlarm, stopAlarm, snoozeAlarm, isAlarmRinging } from '@/lib/appmintNative';
export function RingControls() {
const [ringing, setRinging] = useState(() => isAlarmRinging());
const [last, setLast] = useState('');
useEffect(() => onAlarm((e) => {
if (e.action === 'open') setRinging(!!e.ringing);
else { setRinging(false); setLast(e.action === 'error' ? `Alarm error: ${e.error}` : `Alarm ${e.action}`); }
}), []);
if (!ringing) return <p>{last}</p>;
return (
<div>
<button onClick={() => stopAlarm()}>Stop</button>
<button onClick={() => snoozeAlarm('', 5)}>Snooze 5 min</button>
</div>
);
}Notes: ordinary reminders that open the app also arrive here with action: 'open' and ringing: false.
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]);
}Close a lightbox with the back gesture, keeping the registration tied to the image being open.
import { useEffect } from 'react';
import { onBackGesture } from '@/lib/appmintNative';
export function Lightbox({ src, onClose }: { src: string | null; onClose: () => void }) {
useEffect(() => {
if (!src) return;
return onBackGesture(() => { onClose(); return true; });
}, [src, onClose]);
return src ? <img src={src} alt="" onClick={onClose} /> : null;
}Notes:
- Handlers form a stack: the one registered last (the sheet on top) is asked first. A handler that throws consumes nothing.
- On Android it goes through the shell's back handler. Without it, swiping back with a sheet open closes the whole app.
- On the web it pushes one history entry while any handler is registered and removes it when the last one unsubscribes.
- 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);
});Open a specific product when a widget or shortcut is tapped, ignoring links that are not ours.
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { onDeepLink } from '@/lib/appmintNative';
export function ProductLinks() {
const navigate = useNavigate();
useEffect(() => onDeepLink((url) => {
const m = new URL(url).pathname.match(/^\/product\/(\w+)$/);
if (m) navigate(`/products/${m[1]}`);
}), [navigate]);
return null;
}Notes:
- Android: listens to the shell's
appmint:deep-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.onInAppMessage appmintNative#
import { onInAppMessage } from '@/lib/appmintNative'
Example
Calls you for every in-app message (the creator's campaigns) that is shown, clicked, dismissed or answered on this install. Returns the unsubscribe function.
Returns: () => void. The callback gets {type: 'shown' | 'clicked' | 'dismissed' | 'answered', id, name, template?, presented?, message?, button?, link?, question?, value?}. Never fires on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { useEffect } from 'react';
import { onInAppMessage } from '@/lib/appmintNative';
export function useInAppAnalytics(log: (event: string, name: string) => void) {
useEffect(() => onInAppMessage((e) => log('inapp_' + e.type, e.name)), [log]);
}Draw the messages yourself (with setInAppPresentation('none'), presented is false and message carries the design):
import { useEffect, useState } from 'react';
import { onInAppMessage, setInAppPresentation, type InAppMessageEvent } from '@/lib/appmintNative';
export function useOwnInAppDialog() {
const [msg, setMsg] = useState<InAppMessageEvent['message'] | null>(null);
useEffect(() => {
setInAppPresentation('none');
const off = onInAppMessage((e) => { if (e.type === 'shown' && !e.presented && e.message) setMsg(e.message); });
return () => { off(); setInAppPresentation('auto'); };
}, []);
return { msg, close: () => setMsg(null) };
}React to a button the user tapped in the app's own drawing:
import { onInAppMessage } from '@/lib/appmintNative';
onInAppMessage((e) => {
if (e.type === 'clicked' && e.link === 'appmint://notifications/enable') refreshNotificationBadge();
});Notes: button is -1 for a tap on the message itself, else the button index.
Answers (type: 'answered'). A message with a star row (Star survey, Rate us) or 0-10 score chips (NPS score) reports the answer once, when it closes: question is 'stars' (value 1-5) or 'nps' (value 0-10), and template names the gallery template it came from. The send center shows the average or the NPS score; your code can follow up:
import { onInAppMessage } from '@/lib/appmintNative';
onInAppMessage((e) => {
if (e.type === 'answered' && e.question === 'nps' && (e.value ?? 10) <= 6) openFeedbackSheet();
});Keep your own record of star ratings:
import { onInAppMessage } from '@/lib/appmintNative';
onInAppMessage((e) => {
if (e.type !== 'answered' || e.question !== 'stars') return;
void fetch('/api/ratings', { method: 'POST', body: JSON.stringify({ stars: e.value, campaign: e.name }) });
});Thank people who rated highly, in a React component:
import { useEffect, useState } from 'react';
import { onInAppMessage } from '@/lib/appmintNative';
export function useHappyUser() {
const [happy, setHappy] = useState(false);
useEffect(() => onInAppMessage((e) => {
if (e.type === 'answered' && ((e.question === 'stars' && (e.value ?? 0) >= 4) || (e.question === 'nps' && (e.value ?? 0) >= 9))) setHappy(true);
}), []);
return happy;
}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>
);
}Keep a chat list scrolled to the newest message while the keyboard opens:
import { useEffect, useRef } from 'react';
import { onKeyboard } from '@/lib/appmintNative';
export function MessageList({ children }: { children: React.ReactNode }) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => onKeyboard((inset) => {
if (inset > 0) endRef.current?.scrollIntoView({ block: 'end' }); // keyboard just opened
}), []);
return <div className="overflow-y-auto">{children}<div ref={endRef} /></div>;
}A reusable hook, with a safe-area gap that disappears while the keyboard is open:
import { useEffect, useState } from 'react';
import { onKeyboard } from '@/lib/appmintNative';
export function useKeyboardInset(): number {
const [inset, setInset] = useState(0); // 0 until the keyboard first opens
useEffect(() => onKeyboard(setInset), []);
return inset;
}
// In a component: the home-indicator gap is only needed when the keyboard is closed.
// const kb = useKeyboardInset();
// <footer style={{ paddingBottom: kb > 0 ? kb : 'var(--safe-bottom)' }}>...</footer>Notes:
- In the app it listens to the Capacitor Keyboard plugin and reports only the part of the keyboard the layout did not already absorb. The shell usually resizes the page for the keyboard, so the value is often
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]);
}Show the current state at once, then follow changes (the callback is not called on subscribe):
import { useEffect, useState } from 'react';
import { networkStatus, onNetworkChange } from '@/lib/appmintNative';
export function useOnline(): boolean {
const [online, setOnline] = useState(() => networkStatus().connected);
useEffect(() => onNetworkChange((s) => setOnline(s.connected)), []);
return online;
}Hold large downloads back on mobile data, and treat 'none' as offline:
import { useEffect } from 'react';
import { onNetworkChange, toast } from '@/lib/appmintNative';
export function useDataSaver(setHeavyAllowed: (ok: boolean) => void) {
useEffect(() => onNetworkChange((s) => {
if (s.type === 'none' || !s.connected) { toast('You are offline'); setHeavyAllowed(false); return; }
setHeavyAllowed(s.type !== 'cellular'); // 'wifi' and 'unknown' may download freely
}), [setHeavyAllowed]);
}Notes:
- It does NOT call you with the current state when you subscribe. Read
networkStatus()for the starting value. - In the app it listens to the Capacitor Network plugin (
networkStatusChange);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);
});Post a notification with buttons only where the build supports them, and handle the tap by the notification's tag:
import { useEffect } from 'react';
import { can, notifyWithActions, onNotificationAction, toast } from '@/lib/appmintNative';
export async function remindWithButtons(taskId: string, title: string) {
if (can('notificationActions') === 'none') { toast(title); return; } // no buttons here
await notifyWithActions({
title, body: 'Tap a button', tag: `task-${taskId}`,
actions: [{ id: 'done', title: 'Done' }, { id: 'later', title: 'Later' }],
});
}
export function useTaskActions(complete: (id: string) => void, snooze: (id: string) => void) {
useEffect(() => onNotificationAction(({ actionId, tag }) => {
const id = tag.replace(/^task-/, '');
if (actionId === 'done') complete(id);
else if (actionId === 'later') snooze(id);
}), [complete, snooze]);
}Notes: 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 } });
}Open the right screen after the tap, even when it started the app from cold, and ignore notifications that carry no data:
import { useEffect } from 'react';
import { onNotificationClick } from '@/lib/appmintNative';
export function useNotificationRouting(navigate: (path: string) => void) {
useEffect(() => onNotificationClick(({ tag, data }) => {
const d = data as { screen?: string; id?: string } | null;
if (!d?.screen) { console.log('plain notification tapped:', tag); return; }
navigate(d.id ? `/${d.screen}/${d.id}` : `/${d.screen}`);
}), [navigate]);
}Send a notification only where notifications exist, with data for the tap (the data needs a tag):
import { can, notify, toast } from '@/lib/appmintNative';
export async function announceMessage(chatId: string, from: string, text: string) {
if (can('notify') === 'none') return; // nothing to tap on this surface
const shown = await notify({ title: from, body: text, tag: `chat-${chatId}`, data: { screen: 'chat', id: chatId } });
if (!shown) toast('Notifications are turned off');
}Notes: 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 });Refresh a list through a data hook, with a haptic tick when the data has arrived:
import { useEffect, useRef } from 'react';
import { onPullToRefresh, haptic } from '@/lib/appmintNative';
export function useListRefresh(refetch: () => Promise<unknown>) {
const ref = useRef<HTMLUListElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
return onPullToRefresh(el, async () => {
await refetch();
haptic('success'); // the spinner stays until this promise settles
}, { threshold: 80 });
}, [refetch]);
return ref; // <ul ref={ref} className="h-full overflow-y-auto">
}Notes:
- It starts only when the list is scrolled to the very top. The default
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.onVolumeChange appmintNative#
onVolumeChange(cb) — @/lib/appmintNative
Example
Calls cb whenever one of the phone's volume levels changes - the hardware keys, the system slider, another app.
Returns: a function that stops listening (the last one to stop turns the native watch off). The callback gets { stream, current, max, percent, muted? } for each stream that changed. Does nothing on the web. Needs: nothing.
import { useEffect, useState } from 'react';
import { getVolume, onVolumeChange } from '@/lib/appmintNative';
export function MediaVolumeBadge() {
const [pct, setPct] = useState(() => getVolume('media')?.percent ?? null);
useEffect(() => onVolumeChange((v) => { if (v.stream === 'media') setPct(v.percent); }), []);
return pct === null ? null : <span>🔊 {pct}%</span>;
}Warn when the media volume is turned all the way down while a video plays, and stop listening on unmount:
import { useEffect } from 'react';
import { onVolumeChange, toast } from '@/lib/appmintNative';
export function useQuietWarning(playing: boolean) {
useEffect(() => {
if (!playing) return;
return onVolumeChange((v) => {
if (v.stream === 'media' && (v.percent === 0 || v.muted)) toast('Volume is off');
});
}, [playing]);
}Mirror the phone's level on a slider and write the user's change back (the web has no volume, so getVolume is null there):
import { useEffect, useState } from 'react';
import { getVolume, setVolume, onVolumeChange } from '@/lib/appmintNative';
export function MediaVolumeSlider() {
const [pct, setPct] = useState<number | null>(() => getVolume('media')?.percent ?? null);
useEffect(() => onVolumeChange((v) => { if (v.stream === 'media') setPct(v.percent); }), []);
if (pct === null) return null; // not the Android app
return (
<input type="range" min={0} max={100} value={pct}
onChange={(e) => { const r = setVolume('media', Number(e.target.value)); if (r.ok) setPct(Number(e.target.value)); }} />
);
}appmintNative.openNotificationSettings appmintNative#
openNotificationSettings(): Promise<boolean>
Example
Opens this app's own notification settings screen (Android app).
Returns: Promise<boolean> - true when the screen opened; false on the web and wherever there is no such screen. Check can('notificationSettings') before drawing the control. Needs: nothing.
import { can, openNotificationSettings } from '@/lib/appmintNative';
export function NotificationSettingsRow(row: HTMLElement) {
row.hidden = can('notificationSettings') === 'none';
row.addEventListener('click', () => { void openNotificationSettings(); });
}Notes: for a one-tap "Turn on notifications" use enableNotifications(), which opens this screen only when the permission dialog can no longer be shown.
appmintNative.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 originsOffer the passkey button only where it can work, and say so when it cannot:
import { passkeys } from '@/lib/appmintNative';
export function PasskeyButton({ onClick }: { onClick: () => void }) {
const kind = passkeys.available(); // 'native' | 'web' | 'none'
if (kind === 'none') return <p>Passkeys are not available on this device.</p>;
return <button onClick={onClick}>{kind === 'native' ? 'Use your phone passkey' : 'Use a passkey'}</button>;
}Notes: 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();Paste into a text box with a message for each outcome, and pair it with copy:
import { copy, paste, toast } from '@/lib/appmintNative';
export async function pasteInto(setValue: (s: string) => void) {
try {
const text = await paste();
if (text) setValue(text);
else toast('Nothing to paste');
} catch {
toast('Allow clipboard access to paste');
}
}
export async function shoutClipboard() {
const text = await paste().catch(() => '');
if (text && await copy(text.toUpperCase())) toast('Converted and copied');
}Notes: In the app the Clipboard plugin reads the text; an empty clipboard gives '', not an error. In a browser it is navigator.clipboard.readText() (Chromium only), which asks the user for permission - call it from a tap.
appmintNative.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
}
}A "Choose a contact" button that shows the pick, and a plain number box where the picker does not exist:
import { useState } from 'react';
import { can, pickContact, type Contact } from '@/lib/appmintNative';
export function InvitePicker() {
const [who, setWho] = useState<Contact | null>(null);
if (can('contactPick') === 'none') return <input placeholder="Phone number" />; // type it by hand
return (
<div>
<button onClick={async () => setWho((await pickContact()) ?? who)}>Choose a contact</button>
{who && <p>{who.name} {who.phones?.[0]}</p>}
</div>
);
}Hand the chosen number to the texting app, with a message when the contact has none:
import { pickContact, toast } from '@/lib/appmintNative';
export async function textAFriend(message: string) {
const c = await pickContact();
if (!c) return; // cancelled
const phone = c.phones?.[0];
if (!phone) { toast(`${c.name ?? 'That contact'} has no phone number`); return; }
window.location.href = `sms:${encodeURIComponent(phone)}?body=${encodeURIComponent(message)}`;
}Notes: 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)}`);
}
}Import a JSON backup and handle each way pickFile can fail:
import { pickFile, toast } from '@/lib/appmintNative';
export async function restoreBackup(apply: (data: unknown) => void) {
try {
const f = await pickFile('application/json');
if (!f) return; // cancelled
apply(JSON.parse(await f.text()));
toast(`Restored ${f.name}`);
} catch (e) {
const code = (e as { code?: string }).code;
if (code === 'no-picker') toast('This phone has no file picker app');
else if (e instanceof SyntaxError) toast('That file is not a valid backup');
else toast('Could not open the file');
}
}Choose a text file and put it in an editor, next to the file's details:
import { useState } from 'react';
import { pickFile } from '@/lib/appmintNative';
export function OpenText() {
const [doc, setDoc] = useState<{ name: string; kb: number; text: string } | null>(null);
return (
<div>
<button onClick={async () => {
const f = await pickFile('text/*');
if (f) setDoc({ name: f.name, kb: Math.ceil(f.size / 1024), text: await f.text() });
}}>Open a text file</button>
{doc && <><h3>{doc.name} ({doc.kb} KB)</h3><textarea defaultValue={doc.text} /></>}
</div>
);
}Notes: In the app text() reads the file in 4 MB pieces until the end and decodes it as UTF-8, so large files are read in full - keep your own size limit 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);Enter picture-in-picture automatically when the user leaves a playing video, only where it is supported:
import { useEffect, type RefObject } from 'react';
import { can, pip } from '@/lib/appmintNative';
export function useAutoPip(videoRef: RefObject<HTMLVideoElement>) {
useEffect(() => {
if (can('pip') === 'none') return;
const onHide = () => {
const v = videoRef.current;
if (document.hidden && v && !v.paused) void pip.enter(v);
};
document.addEventListener('visibilitychange', onHide);
return () => document.removeEventListener('visibilitychange', onHide);
}, [videoRef]);
}Notes:
- Android (8+): the WHOLE app shrinks into the floating window, and the
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';Show app-only controls.
import { platform } from '@/lib/appmintNative';
export function InstallBanner() {
// In the installed app there is nothing to install.
if (platform() === 'android') return null;
return <a href="https://play.google.com/store/apps/details?id=com.example.app">Get the Android app</a>;
}Tag analytics events with the surface.
import { platform } from '@/lib/appmintNative';
export function track(event: string, data: Record<string, unknown> = {}) {
return fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, surface: platform(), ...data }),
});
}Notes: platform() never answers 'web' inside 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' : '';
}Turn protection on for one sensitive view and tell the user if it could not be applied:
import { privacyScreen, toast } from '@/lib/appmintNative';
export async function openStatement(show: () => void) {
const protectedNow = await privacyScreen.enable(); // false where nothing can block capture
if (!protectedNow) toast('Screenshots cannot be blocked here');
show();
}
export async function closeStatement(hide: () => void) {
hide();
await privacyScreen.disable();
}Notes: 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 };
}Show an unread badge count that follows new arrivals:
import { useEffect, useState } from 'react';
import { pushInbox } from '@/lib/appmintNative';
export function UnreadBadge() {
const [n, setN] = useState(() => pushInbox().filter((m) => !m.read).length);
useEffect(() => {
const recount = () => setN(pushInbox().filter((m) => !m.read).length);
window.addEventListener('appmint:push', recount);
return () => window.removeEventListener('appmint:push', recount);
}, []);
return n > 0 ? <span aria-label={`${n} unread`}>{n}</span> : null;
}Render the list, with an empty state for the web and for builds without AppMint Push, and a "Mark all read" button:
import { useState } from 'react';
import { pushInbox, markPushRead } from '@/lib/appmintNative';
export function NotificationsScreen() {
const [items, setItems] = useState(() => pushInbox());
if (items.length === 0) return <p>No notifications yet.</p>;
return (
<section>
<button onClick={() => { markPushRead(); setItems(pushInbox()); }}>Mark all read</button>
<ul>
{items.map((m) => (
<li key={m.id} style={{ fontWeight: m.read ? 400 : 700 }}>
{m.title} <small>{new Date(m.receivedAt).toLocaleString()}</small>
<p>{m.body}</p>
</li>
))}
</ul>
</section>
);
}Notes: the inbox is the saved truth - read it on mount; the appmint:push event only fires while the app is on screen. Dismissed messages are left out.
appmintNative.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);Read a code from a canvas frame, checking what the device supports first:
import { barcodeFormats, readBarcodeFrom } from '@/lib/appmintNative';
export async function scanCanvas(canvas: HTMLCanvasElement): Promise<string | null> {
const supported = await barcodeFormats();
if (supported.length === 0) return null; // no reader on this device
const wanted = ['qr_code', 'ean_13'].filter((f) => supported.includes(f)) as ('qr_code' | 'ean_13')[];
const [hit] = await readBarcodeFrom(canvas, wanted);
return hit?.value ?? null;
}Notes:
- Uses the browser engine's
BarcodeDetector(present in the Android app's WebView; missing in Safari and many desktop browsers - then it returns[]). - A plain
<input type="file" accept="image/*">opens the Android photo picker and needs no storage permission. - Narrow
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" />
);
}Handle the "no code" answer, and offer a resend when the wait ends without one:
import { useState } from 'react';
import { can, readOtp } from '@/lib/appmintNative';
export function VerifyStep({ resend, submit }: { resend: () => void; submit: (c: string) => void }) {
const [waited, setWaited] = useState(false);
async function start() {
resend();
if (can('otp') === 'none') return; // readOtp would throw here — the user types it
const code = await readOtp(); // null: declined, 5 minutes passed, or aborted
if (code) submit(code); else setWaited(true);
}
return (
<div>
<button onClick={start}>Send code</button>
{waited && <p>No code detected. <button onClick={start}>Send again</button></p>}
</div>
);
}Catch the throw when you cannot check can('otp') first:
import { readOtp } from '@/lib/appmintNative';
export async function tryAutoFill(): Promise<string | null> {
try {
return await readOtp({ signal: AbortSignal.timeout(60_000) });
} catch {
return null; // one-time codes unavailable: the user types it
}
}Notes: it is an accelerator - always keep the field typeable.
appmintNative.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>
);
}Handle the "no recognizer" and failure cases explicitly:
import { can, recognizeText } from '@/lib/appmintNative';
export async function readLabel(file: Blob): Promise<string> {
if (can('ocr') === 'none') return '';
try {
const { text } = await recognizeText(file);
return text.trim();
} catch (e) {
if ((e as { code?: string }).code === 'unavailable') return '';
throw e; // a real recognition failure
}
}Read a script other than Latin, and list each line with its position:
import { recognizeText } from '@/lib/appmintNative';
export async function readHindiSign(img: HTMLImageElement) {
const { blocks } = await recognizeText(img, { script: 'devanagari' });
return blocks.flatMap((b) => b.lines).map((l) => ({ text: l.text, box: l.box }));
}Notes: Latin scripts only. The first call on a fresh phone can take a few seconds while Play services fetches the model - show progress.
appmintNative.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)Show the maintenance text yourself on the web, and leave the app to its own screen:
import { useEffect, useState } from 'react';
import { remoteConfig } from '@/lib/appmintNative';
export function useMaintenance(url: string): string | null {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
if (!remoteConfig.handledHere()) return; // the Android app shows its own screen
void remoteConfig.fetch(url).then((cfg) => {
setText(cfg?.maintenanceMode ? cfg.maintenanceText : null);
});
}, [url]);
return text;
}Notes:
RemoteConfig={ version, announcementText, announcementId, announcementImageUrl, announcementBgColor, announcementTextColor, maintenanceMode, maintenanceText }.handledHere()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.requestDndAccess appmintNative#
requestDndAccess() — @/lib/appmintNative
Example
Asks for Do Not Disturb access, which Android requires before the app may change the ring or notification volume while Do Not Disturb is on.
Returns: 'granted', 'opened' (the settings screen was shown - check again when the user returns), 'not_declared', or 'unavailable' on the web. Needs: nothing to tick - calling it in your code makes the build declare the permission.
import { requestDndAccess, setVolume } from '@/lib/appmintNative';
export function silenceRinger(): string {
const r = setVolume('ring', 0);
if (r.ok) return 'done';
if (r.error === 'dnd_access_required') return requestDndAccess(); // 'opened' → ask the user to allow it
return r.error ?? 'failed';
}Ask once, then retry the volume change after the user comes back from settings:
import { useEffect, useRef } from 'react';
import { requestDndAccess, setVolume } from '@/lib/appmintNative';
export function useRingerMute() {
const pending = useRef(false);
useEffect(() => {
const onShow = () => {
if (pending.current && document.visibilityState === 'visible') {
pending.current = false;
setVolume('ring', 0); // allowed now, or the error comes back again
}
};
document.addEventListener('visibilitychange', onShow);
return () => document.removeEventListener('visibilitychange', onShow);
}, []);
return () => {
if (requestDndAccess() === 'opened') pending.current = true;
};
}Branch on every answer it can give:
import { requestDndAccess, toast } from '@/lib/appmintNative';
export function explainDnd() {
switch (requestDndAccess()) {
case 'granted': toast('Do Not Disturb access is already on'); break;
case 'opened': toast('Allow this app, then come back'); break;
case 'not_declared': toast('This build was made without that permission'); break;
default: toast('Only available in the Android app'); // 'unavailable' on the web
}
}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 only when Play showed the sheet; false on the web, for an install that did not come from Google Play, when Play declined (its quota) or failed. Use askForReview() to learn which. 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();
}
}Learn why the sheet did not show, without sending the user away:
import { askForReview } from '@/lib/appmintNative';
export async function maybeAsk() {
const { result } = await askForReview();
// 'shown' | 'not_from_play' | 'quota' | 'error' | 'unavailable'
if (result !== 'shown') console.info('review sheet not shown:', result);
}Only ask after a success moment, and at most once a month:
import { requestReview } from '@/lib/appmintNative';
const KEY = 'lastReviewAsk';
export async function afterSuccess() {
const last = Number(localStorage.getItem(KEY) ?? 0);
if (Date.now() - last < 30 * 24 * 3600 * 1000) return;
localStorage.setItem(KEY, String(Date.now()));
await requestReview();
}Notes: 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 (use askForReview({ openStore: true }) for a button). It never leaves the app for the Play listing.
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();
}Offer an Undo right after a swipe-away, in a small React row:
import { useState } from 'react';
import { dismissPushMessage, restorePushMessage } from '@/lib/appmintNative';
export function SwipeRow({ id, onChange }: { id: string; onChange: () => void }) {
const [undoable, setUndoable] = useState(false);
return undoable ? (
<button onClick={() => { if (restorePushMessage(id)) { setUndoable(false); onChange(); } }}>
Undo
</button>
) : (
<button onClick={() => { if (dismissPushMessage(id)) { setUndoable(true); onChange(); } }}>
Dismiss
</button>
);
}Check the answer, because an unknown or never-dismissed id gives false:
import { restorePushMessage, pushInbox } from '@/lib/appmintNative';
export function undoOrReport(id: string): string {
if (!restorePushMessage(id)) return 'Nothing to restore';
return `Back in your inbox (${pushInbox().length} messages)`;
}Notes: messages removed with clearPushInbox() or retracted by you cannot be restored.
appmintNative.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;
}Keep a scrolling list clear of the bars by sizing its padding from the numbers:
import { useEffect, useState, type ReactNode } from 'react';
import { safeArea } from '@/lib/appmintNative';
export function PaddedList({ children }: { children: ReactNode }) {
const [inset, setInset] = useState({ top: 0, bottom: 0 });
useEffect(() => {
const read = () => { const s = safeArea(); setInset({ top: s.top, bottom: s.bottom }); };
read();
window.addEventListener('resize', read);
return () => window.removeEventListener('resize', read);
}, []);
return <div style={{ paddingTop: inset.top + 8, paddingBottom: inset.bottom + 8 }}>{children}</div>;
}Notes:
- Measured from CSS
env(safe-area-inset-*). The value is cached and re-measured after a window resize or rotation. - Before
document.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);Save the result of a fetch, and say so when it could not start:
import { saveFile, toast } from '@/lib/appmintNative';
export async function downloadInvoice(id: string) {
const res = await fetch(`/api/invoices/${id}.pdf`);
if (!res.ok) { toast('Could not get the invoice'); return; }
const pdf = await res.blob(); // the Blob keeps its own type
if (!saveFile(`Invoice-${id}.pdf`, pdf)) toast('Could not start the download');
}Notes: It is a plain <a download> under the hood, which the app turns into the "Save as…" sheet (see downloads). Always pass a name with an extension; without one the file is saved as download. The default MIME type for a string is text/plain.
appmintNative.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>
);
}Save a video clip and handle the failure codes it can reject with:
import { saveToGallery, toast } from '@/lib/appmintNative';
export async function saveClip(url: string) {
try {
await saveToGallery(url, { kind: 'video', album: 'My Clips', fileName: 'clip.mp4' });
toast('Saved to your Movies folder');
} catch (e) {
const code = (e as { code?: string }).code;
toast(code === 'download-failed' ? 'Check your connection and try again' : 'Could not save the clip');
}
}Save an image from a data URL, and hand it to the share sheet only when it was saved:
import { can, saveToGallery, share } from '@/lib/appmintNative';
export async function saveAndShare(dataUrl: string) {
if (can('gallery') !== 'none') await saveToGallery(dataUrl, { album: 'Receipts' });
await share({ title: 'My receipt', text: 'Here is my receipt' });
}Notes: source may also be a data: URL or an http(s) URL; pass { kind: 'video' } for a video URL without a video MIME type.
appmintNative.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' });Close the scanner after the first hit and offer a typed code when the camera is unavailable:
import { useRef, useState } from 'react';
import { can, scanBarcode } from '@/lib/appmintNative';
export function TicketCheck({ onCode }: { onCode: (v: string) => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const ctl = useRef<AbortController | null>(null);
const [typed, setTyped] = useState(false);
async function start() {
if (!videoRef.current || can('barcodeScan') === 'none') { setTyped(true); return; }
ctl.current = new AbortController();
const hit = await scanBarcode(videoRef.current, { signal: ctl.current.signal, formats: ['ean_13', 'code_128'] });
if (hit) onCode(hit.value);
else if (!ctl.current.signal.aborted) setTyped(true); // null and not cancelled: no camera
}
return (
<div>
<video ref={videoRef} muted playsInline />
<button onClick={start}>Scan ticket</button>
<button onClick={() => ctl.current?.abort()}>Cancel</button>
{typed && <input placeholder="Type the ticket number" onBlur={(e) => onCode(e.target.value)} />}
</div>
);
}Notes:
- It calls
getUserMedia(so the user sees the camera permission prompt) and decodes 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);Snooze from a button on your own alarm screen, using onAlarm to know when it is ringing.
import { useEffect, useState } from 'react';
import { onAlarm, snoozeAlarm, stopAlarm } from '@/lib/appmintNative';
export function RingingScreen() {
const [ringing, setRinging] = useState(false);
useEffect(() => onAlarm((e) => setRinging(e.action === 'open' && e.ringing === true)), []);
if (!ringing) return null;
return (
<div>
<button onClick={() => { if (snoozeAlarm('', 10)) setRinging(false); }}>Snooze 10 min</button>
<button onClick={() => { stopAlarm(); setRinging(false); }}>Stop</button>
</div>
);
}snoozeAlarm answers false when nothing is ringing, so check before you promise the user a second ring.
import { snoozeAlarm } from '@/lib/appmintNative';
export function snoozeOrExplain(minutes = 0) {
return snoozeAlarm('', minutes)
? `Ringing again in ${minutes || 'a few'} minutes`
: 'No alarm is ringing right now.';
}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.scheduleAlarm appmintNative#
scheduleAlarm(id, input) — @/lib/appmintNative
Example
Sets a real alarm clock from an AI-built app: it rings on the phone's alarm volume (through silent mode, and through Do Not Disturb when the user allows alarms), keeps ringing until Dismiss / Snooze or ringSeconds, and puts the app on screen over the lock screen.
Returns: boolean, synchronously - true when set; false on the web, on iOS, or when alarmSupport() is not 'ready'. The same id replaces an older alarm. Needs: 🔔 Alarm clock in the Access step (Step 4) when you build.
import { alarmSupport, scheduleAlarm } from '@/lib/appmintNative';
export function setWeekdayAlarm(hour: number, minute: number): string {
const support = alarmSupport();
if (support !== 'ready') return support; // show the matching fix-it step
const first = new Date();
first.setHours(hour, minute, 0, 0);
if (first.getTime() <= Date.now()) first.setDate(first.getDate() + 1);
const ok = scheduleAlarm('wake', {
at: first.getTime(),
title: 'Wake up',
repeat: 'weekdays',
days: [1, 2, 3, 4, 5], // ISO: Monday = 1 … Sunday = 7
snoozeMinutes: 9,
ringSeconds: 300,
});
return ok ? 'set' : 'failed';
}Offer the alarm only when the app can ring one, and explain what is missing otherwise:
import { alarmSupport, scheduleAlarm } from '@/lib/appmintNative';
const HELP: Record<string, string> = {
not_enabled: 'Alarm clock is switched off in this build.',
needs_exact_alarm: 'Allow "Alarms & reminders" for this app in Settings.',
needs_notification_permission: 'Allow notifications for this app in Settings.',
unavailable: 'Alarms work only in the Android app.',
};
export function setTimer(minutes: number): string {
const s = alarmSupport();
if (s !== 'ready') return HELP[s] ?? 'Alarm not available';
const ok = scheduleAlarm('timer', {
at: Date.now() + minutes * 60_000,
title: 'Timer done',
snoozeMinutes: 0, // hides the Snooze button
ringSeconds: 60,
});
return ok ? `Timer set for ${minutes} min` : 'Could not set the timer';
}React to how a ringing alarm ended, and stop it from your own screen:
import { useEffect, useState } from 'react';
import { onAlarm, stopAlarm, snoozeAlarm, isAlarmRinging } from '@/lib/appmintNative';
export function useAlarmScreen() {
const [ringing, setRinging] = useState(isAlarmRinging());
useEffect(() => onAlarm((e) => {
if (e.action === 'open') setRinging(!!e.ringing);
else if (e.action === 'dismiss' || e.action === 'snooze' || e.action === 'missed') setRinging(false);
else if (e.action === 'error') console.warn('Alarm error', e.error);
}), []);
return {
ringing,
stop: () => stopAlarm(), // '' = whichever alarm is ringing
snooze: () => snoozeAlarm('', 5), // ring again in 5 minutes
};
}Notes: Android app only. repeat daily / weekly / weekdays / monthly / yearly keep the same clock time across daylight-saving changes. Cancel with cancelNotification(id) on the bridge; outcomes arrive through onAlarm.
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. lock rejects, like Capacitor's, when orientation is not a lock type or the platform refuses the lock. 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" />;
}Lock for one screen only when a lock is allowed, and let the user rotate back:
import { useEffect, useState } from 'react';
import { ScreenOrientation } from '@/lib/appmintNative';
export function ChartView() {
const [locked, setLocked] = useState(true);
useEffect(() => {
if (locked) void ScreenOrientation.lock({ orientation: 'landscape-primary' }); // any 'landscape*' value
else void ScreenOrientation.unlock();
return () => { void ScreenOrientation.unlock(); };
}, [locked]);
return <button onClick={() => setLocked((v) => !v)}>{locked ? 'Free rotation' : 'Lock landscape'}</button>;
}When you need to know whether the lock took, use lockOrientation() directly (it answers false where locking is refused, such as desktop browsers):
import { lockOrientation, toast } from '@/lib/appmintNative';
export async function openSignaturePad() {
const ok = await lockOrientation('landscape');
if (!ok) toast('Turn your phone sideways for more room');
}Notes: orientation is one of Capacitor's lock types - 'any', 'natural', 'portrait', 'landscape', 'portrait-primary', 'portrait-secondary', 'landscape-primary', 'landscape-secondary' - and is applied as given. 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');
}Announce a result only to people using the screen reader, and leave visual toasts for everyone else:
import { screenReader, toast } from '@/lib/appmintNative';
export async function notifySaved() {
if (await screenReader.isEnabled()) await screenReader.speak('Note saved', 'en');
else toast('Note saved');
}Remove an animation while TalkBack is on, and stop listening when the component goes away:
import { useEffect, useState } from 'react';
import { screenReader } from '@/lib/appmintNative';
export function Banner({ text }: { text: string }) {
const [reduce, setReduce] = useState(false);
useEffect(() => {
let alive = true;
void screenReader.isEnabled().then((v) => { if (alive) setReduce(v); });
const off = screenReader.onChange(setReduce); // returns an unsubscribe function
return () => { alive = false; off(); };
}, []);
return <div className={reduce ? '' : 'animate-pulse'} role="status" aria-live="polite">{text}</div>;
}Notes: In the app speak goes through the ScreenReader plugin; on the web (or if the plugin fails) it is spoken with text-to-speech. Prefer good markup (aria-label, aria-live regions) for most announcements; use speak for short, important changes.
appmintNative.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);
}Show a clear state when Contacts is not available and when nothing matches:
import { useState } from 'react';
import { can, searchContacts, type Contact } from '@/lib/appmintNative';
export function ContactPicker({ onPick }: { onPick: (c: Contact) => void }) {
const [rows, setRows] = useState<Contact[] | null>(null);
if (can('contactList') === 'none') return <p>Choosing a contact works only in the Android app.</p>;
return (
<div>
<input placeholder="Search contacts" onChange={async (e) => setRows(await searchContacts(e.target.value, 10))} />
{rows && rows.length === 0 && <p>No contacts match, or access was not allowed.</p>}
{rows?.map((c) => <button key={c.id ?? c.name} onClick={() => onPick(c)}>{c.name} {c.phones?.[0]}</button>)}
</div>
);
}Look up a person by name, then pre-fill a text message to them:
import { searchContacts, composeSms } from '@/lib/appmintNative';
export async function textPerson(name: string, message: string): Promise<boolean> {
const [first] = await searchContacts(name, 1);
const number = first?.phones?.[0];
return number ? composeSms(number, message) : false; // the user presses send
}Notes: 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;
}
}
}Turn the code into a short message for the user in one place:
import { SecureKeyError } from '@/lib/appmintNative';
export function secureKeyMessage(e: unknown): string {
if (!(e instanceof SecureKeyError)) return 'Something went wrong';
switch (e.code) {
case 'auth-required': return 'Unlock your phone to continue';
case 'key-invalidated': return 'Your screen lock changed, so this data was erased';
case 'too-large': return 'That is too big to protect in one piece';
case 'foreign-origin': return 'This page is not allowed to use secure storage';
default: return 'Secure storage failed (' + e.code + ')';
}
}Treat a tampered or wrong payload as a normal outcome and clear it, but keep a missing key as an error you show:
import { secureKeys, SecureKeyError } from '@/lib/appmintNative';
export async function readNote(sealed: string): Promise<string | null> {
try {
return await secureKeys.decrypt('notes', sealed);
} catch (e) {
if (e instanceof SecureKeyError && (e.code === 'bad-payload' || e.code === 'failed')) {
localStorage.removeItem('note'); // the stored text cannot be read any more
return null;
}
throw e;
}
}Notes: e.message is code or "code: detail"; branch on e.code, never on the message. The platforms' own error words are turned into these codes, and their original text is kept in the message. A changed or foreign payload is 'bad-payload' on the web and 'failed' 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');
}List the keys the app holds and show whether each one is protected by hardware (the web always answers false):
import { secureKeys, can } from '@/lib/appmintNative';
export async function describeKeys(): Promise<string[]> {
if (can('secureKeys') === 'none') return [];
const aliases = await secureKeys.list();
return Promise.all(aliases.map(async (alias) =>
`${alias}: ${(await secureKeys.isHardwareBacked(alias)) ? 'secure hardware' : 'software'}`));
}Notes: can('secureKeys') is 'native' in the app and 'web' in a browser, where isHardwareBacked() is always false - never say "hardware" there. A requireAuth key: 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';
}
}Ask for a confirmation first, since the message goes out with no screen to cancel it:
import { sendSms, composeSms, can, toast } from '@/lib/appmintNative';
export async function sendCheckIn(to: string) {
if (can('smsSend') === 'none') { composeSms(to, 'I am safe'); return; } // the user presses send
if (!window.confirm('Send "I am safe" to ' + to + '? This may cost money.')) return;
toast((await sendSms(to, 'I am safe')) ? 'Sent' : 'Could not send. Check the SMS permission.');
}Send the same alert to several people, one at a time, and count what went out:
import { sendSms } from '@/lib/appmintNative';
export async function alertAll(numbers: string[], text: string) {
let sent = 0;
for (const n of numbers) {
if (await sendSms(n, text)) sent += 1; // waits for the phone's answer for each one
}
return { sent, failed: numbers.length - sent };
}Notes: The wrapper resolves on the FIRST answer, which is sent (the message left the phone), not delivered. For delivery reports use the raw WebToApk.sendSms with the appmint:sms event. can('smsSend') is 'none' on the web (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.setInAppPresentation appmintNative#
import { setInAppPresentation } from '@/lib/appmintNative'
Example
Chooses who draws an in-app message (the creator's campaigns): the app ('auto', the default: the creator's banner, card, sheet or full-screen design) or your own UI ('none', via onInAppMessage).
Returns: boolean synchronously: true when saved (it stays on the phone). Always false on the web and without AppMint Push. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { setInAppPresentation } from '@/lib/appmintNative';
// The app draws its own messages from now on.
setInAppPresentation('none');A settings switch:
import { setInAppPresentation } from '@/lib/appmintNative';
export function onNativePopupsChange(on: boolean) {
if (!setInAppPresentation(on ? 'auto' : 'none')) console.info('In-app messages are not available here');
}Take over while one screen is open, then give it back:
import { useEffect } from 'react';
import { setInAppPresentation } from '@/lib/appmintNative';
export function useQuietScreen() {
useEffect(() => {
setInAppPresentation('none');
return () => { setInAppPresentation('auto'); };
}, []);
}Notes: a message handed to your code counts as shown. Setting 'none' also closes one on screen now.
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 appA volume slider that appears only when the app ships music:
import { useState } from 'react';
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';
export function MusicSlider() {
const [level, setLevel] = useState(musicVolume());
if (level < 0) return null; // no music in this app: draw no control
return (
<input type="range" min={0} max={100} value={level}
onChange={(e) => { const v = Number(e.target.value); setLevel(v); setMusicVolume(v); }} />
);
}Quieten the music while a video plays and put it back afterwards:
import { musicVolume, setMusicVolume } from '@/lib/appmintNative';
let before = -1;
export function duckMusic() { before = musicVolume(); if (before > 0) setMusicVolume(10); }
export function restoreMusic() { if (before >= 0) setMusicVolume(before); before = -1; }Notes: 0 makes the music silent but it keeps playing. The level is saved on the phone (until the user clears the app's data). Android 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();
}Check the answer, since it is false on the web or when AppMint Push is off:
import { setPushPresentation } from '@/lib/appmintNative';
export function useOwnBanners(on: boolean): boolean {
const saved = setPushPresentation(on ? 'none' : 'auto');
if (!saved) console.info('Push banners are not controllable here');
return saved;
}Draw your own banner for each message and keep the inbox in step:
import { useEffect, useState } from 'react';
import { setPushPresentation, markPushRead, pushInbox, type PushMessage } from '@/lib/appmintNative';
export function useCustomBanner() {
const [msg, setMsg] = useState<PushMessage | null>(null);
useEffect(() => {
setPushPresentation('none'); // the shell draws nothing; the page does
const onPush = (e: Event) => {
const d = (e as CustomEvent).detail;
if (d.type === 'received') setMsg(d.message);
if (d.type === 'revoked') setMsg((m) => (m?.id === d.id ? null : m));
};
window.addEventListener('appmint:push', onPush);
return () => { window.removeEventListener('appmint:push', onPush); setPushPresentation('auto'); };
}, []);
return { msg, unread: pushInbox().filter((m) => !m.read).length, dismiss: () => { if (msg) markPushRead(msg.id); setMsg(null); } };
}Notes: the inbox and the appmint:push event work the same in both modes. Setting 'none' also closes a message the shell is showing right now.
appmintNative.setPushTag appmintNative#
import { setPushTag } from '@/lib/appmintNative'
Example
Sets one AppMint Push tag on this install. The send center can send to everyone with a tag, and a tag automation can send a message a set time after the tag is set. '' removes the tag.
Returns: boolean synchronously - true when saved; false when the key is not 1-32 letters, digits or _, the value is over 128 characters or the install would pass 50 tags. Always false on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build; tag targeting and automations reach apps built from 30 Sep 2026 on.
import { useEffect } from 'react';
import { setPushTag } from '@/lib/appmintNative';
// Abandoned cart: a tag automation on "cart" (say 60 minutes) reminds only phones whose cart is
// still open when the time is up. Setting 'open' again does not restart the clock.
export function useCartPushTag(itemCount: number) {
const hasItems = itemCount > 0;
useEffect(() => {
setPushTag('cart', hasItems ? 'open' : '');
}, [hasItems]);
}
export function onCheckoutComplete() {
setPushTag('cart', '');
}Check the result: false means the tag was refused (bad key, value over 128 characters, more than 50 tags) or this is the web, so keep your own record of the state.
import { setPushTag } from '@/lib/appmintNative';
export function markNewsletterChoice(subscribed: boolean) {
const saved = setPushTag('newsletter', subscribed ? 'yes' : '');
if (!saved) console.warn('Push tag not saved (web, Push off, or invalid key/value)');
return saved;
}Tag a phone once it reaches a milestone, so the send center can target everyone at that level.
import { useEffect } from 'react';
import { setPushTag } from '@/lib/appmintNative';
export function useLevelTag(level: number) {
useEffect(() => {
if (level >= 10) setPushTag('veteran', 'yes');
setPushTag('level', String(level));
}, [level]);
}Notes: tags belong to the install, not the signed-in user. Never put private data in a tag.
appmintNative.setPushTags appmintNative#
import { setPushTags } from '@/lib/appmintNative'
Example
Sets several AppMint Push tags in one change; a '' value removes that tag, tags you do not name are kept.
Returns: boolean synchronously - true when saved; false when any key or value breaks the setPushTag rules (then nothing is saved). Always false on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build; tag targeting and automations reach apps built from 30 Sep 2026 on.
import { setPushTags, getPushTags, setPushUser } from '@/lib/appmintNative';
export function tagProfile(profile: { plan: 'free' | 'pro'; level: number; team?: string }) {
return setPushTags({
plan: profile.plan,
level: String(profile.level), // values are text
team: profile.team ?? '', // '' removes the tag
});
}
// Sign-out: clear every tag and the user id.
export function signOutOfPush() {
const cleared = Object.fromEntries(Object.keys(getPushTags()).map((k) => [k, '']));
setPushTags(cleared);
setPushUser('');
}Tag a phone with its chosen language and topics when the user saves their settings, and check that every tag was accepted.
import { setPushTags } from '@/lib/appmintNative';
export function savePreferences(prefs: { lang: string; topics: string[] }) {
const ok = setPushTags({
lang: prefs.lang,
topics: prefs.topics.join(',').slice(0, 128), // a value is at most 128 characters
onboarded: 'yes',
});
if (!ok) console.warn('Push tags not saved: nothing was changed');
return ok;
}Keep tags in step with the signed-in account in a React effect, removing the ones that no longer apply.
import { useEffect } from 'react';
import { setPushTags, setPushUser } from '@/lib/appmintNative';
export function usePushProfile(user: { id: string; plan: 'free' | 'pro'; trialEnds?: string } | null) {
useEffect(() => {
if (!user) return;
setPushUser(user.id);
setPushTags({ plan: user.plan, trial: user.plan === 'pro' ? '' : (user.trialEnds ?? '') });
}, [user?.id, user?.plan, user?.trialEnds]);
}Notes: all of one call is saved or none of it.
appmintNative.setPushUser appmintNative#
import { setPushUser } from '@/lib/appmintNative'
Example
Tells AppMint Push which of your users is signed in on this install, so the send center can send a message to that one user. Pass your own stable account id after sign-in and '' on sign-out.
Returns: boolean synchronously - true when the id was saved; false for an id with anything other than letters, digits and _ @ . : + - (up to 128). Always false on the web. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build; sending to a user reaches apps built from 30 Sep 2026 on.
import { useEffect } from 'react';
import { setPushUser, getPushUser } from '@/lib/appmintNative';
import { supabase } from '@/lib/supabase';
// Keeps AppMint Push in step with the signed-in account: the auth user id, never the email.
export function usePushUser() {
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange((_event, session) => {
const id = session?.user.id ?? '';
if (getPushUser() !== id) setPushUser(id);
});
return () => data.subscription.unsubscribe();
}, []);
}Handle a refused id: setPushUser answers false for an email address or any id with characters outside the allowed set, so send a stable id instead.
import { setPushUser } from '@/lib/appmintNative';
export function linkPushToAccount(account: { id: string; email: string }) {
const ok = setPushUser(account.id); // the id, never the email
if (!ok) console.warn('Push user id not saved (web, Push off, or invalid characters)');
return ok;
}Clear the id when the user signs out, in a plain click handler.
import { setPushUser, getPushUser } from '@/lib/appmintNative';
export function SignOutButton({ signOut }: { signOut: () => Promise<void> }) {
return (
<button
onClick={async () => {
await signOut();
if (getPushUser()) setPushUser(''); // this install stops receiving "send to this user" messages
}}
>
Sign out
</button>
);
}Combine it with a tag so one sign-in step identifies the user and their plan together.
import { setPushUser, setPushTag } from '@/lib/appmintNative';
export function onSignedIn(userId: string, plan: 'free' | 'pro') {
setPushUser(userId);
setPushTag('plan', plan);
}Notes: the id belongs to the install, so sign-out must clear it. It does not clear tags - call setPushTags with '' values for user-specific ones.
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.setVolume appmintNative#
setVolume(stream, percent, showUi?) — @/lib/appmintNative
Example
Sets the phone's own volume level for one stream, 0-100. showUi shows the phone's volume slider as it moves.
Returns: { ok, stream?, current?, max?, percent?, error? } - error is unknown_stream, bad_percent, fixed_volume, dnd_access_required (Do Not Disturb blocks ring / notification - call requestDndAccess()), muted (Do Not Disturb "Total silence" mutes the stream, so the level did not change), not_applied, foreign_origin, failed, or unavailable on the web. Needs: nothing for media, alarm and call.
import { useState } from 'react';
import { getVolume, setVolume, requestDndAccess } from '@/lib/appmintNative';
export function RingVolume() {
const [level, setLevel] = useState(() => getVolume('ring')?.percent ?? null);
if (level === null) return null; // web: no phone volume to set
return (
<input
type="range" min={0} max={100} value={level}
onChange={(e) => {
const r = setVolume('ring', Number(e.target.value));
if (r.ok) setLevel(r.percent ?? level);
else if (r.error === 'dnd_access_required') requestDndAccess();
}}
/>
);
}Show the media volume as a slider that follows the hardware keys, using onVolumeChange.
import { useEffect, useState } from 'react';
import { getVolume, setVolume, onVolumeChange } from '@/lib/appmintNative';
export function MediaVolume() {
const [pct, setPct] = useState<number | null>(() => getVolume('media')?.percent ?? null);
useEffect(() => onVolumeChange((l) => { if (l.stream === 'media') setPct(l.percent); }), []);
if (pct === null) return null; // web: no phone volume
return <input type="range" min={0} max={100} value={pct} onChange={(e) => setVolume('media', Number(e.target.value))} />;
}Turn the alarm stream up before an alarm screen rings, and report why it did not change.
import { setVolume } from '@/lib/appmintNative';
export function louderAlarm() {
const r = setVolume('alarm', 90, true); // true shows the phone's volume slider
if (!r.ok) {
switch (r.error) {
case 'fixed_volume': return 'This phone has a fixed volume.';
case 'unavailable': return 'Volume control needs the Android app.';
default: return `Could not change the volume (${r.error}).`;
}
}
return `Alarm volume is now ${r.percent}%`;
}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,
)}
</>
);
}A sheet that stays open until the user chooses, using dismissible: false and the closed promise.
import { sheet } from '@/lib/appmintNative';
export async function confirmDelete(name: string): Promise<boolean> {
let answer = false;
const h = sheet({
title: 'Delete this item?',
dismissible: false, // only the buttons below close it
content: (host) => {
const p = document.createElement('p');
p.textContent = `"${name}" will be removed.`;
const yes = document.createElement('button');
yes.textContent = 'Delete';
yes.onclick = () => { answer = true; h.close(); };
const no = document.createElement('button');
no.textContent = 'Keep it';
no.onclick = () => h.close();
host.append(p, yes, no);
},
});
await h.closed; // resolves however the sheet closed
return answer;
}Notes:
- Drawn in the app's tokens (
--surface,--ink,--muted,--border), above the safe area, with a light haptic on open. - Drag down closes it when dragged past 120 px or flicked; the body scrolls first when it is not at the top.
- With
dismissible: 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();Open the matching screen when a shortcut is tapped while the app is already running, with onDeepLink.
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { onDeepLink } from '@/lib/appmintNative';
export function useShortcutRouting() {
const navigate = useNavigate();
useEffect(() => onDeepLink((url) => navigate(new URL(url).pathname)), [navigate]);
}Show shortcuts only for a signed-in user, and handle the web, where set() answers false.
import { useEffect } from 'react';
import { shortcuts } from '@/lib/appmintNative';
export function useAccountShortcuts(signedIn: boolean) {
useEffect(() => {
if (!signedIn) { void shortcuts.clear(); return; }
void shortcuts.set([
{ id: 'inbox', title: 'Inbox', route: '/inbox' },
{ id: 'compose', title: 'Write a message', route: 'compose' }, // gets a leading "/"
]).then((handed) => { if (!handed) console.info('No launcher shortcuts on this platform'); });
}, [signedIn]);
}Notes:
- At most 4 items; extra items are dropped. A
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.snoozeAlarm appmintNative#
snoozeAlarm(id?, minutes?) — @/lib/appmintNative
Example
Stops the ringing alarm and rings it again after minutes (0 or omitted = the alarm's own snoozeMinutes, default 10). Tomorrow's ring of a repeating alarm does not move.
Returns: boolean - true when it was ringing and is snoozed. Needs: 🔔 Alarm clock in the Access step (Step 4) when you build.
import { isAlarmRinging, snoozeAlarm } from '@/lib/appmintNative';
// A shake-to-snooze handler:
window.addEventListener('devicemotion', (e) => {
const a = e.accelerationIncludingGravity;
if (a && Math.abs(a.x ?? 0) > 25 && isAlarmRinging()) snoozeAlarm('', 5);
});Snooze from a button on your own alarm screen, using onAlarm to know when it is ringing.
import { useEffect, useState } from 'react';
import { onAlarm, snoozeAlarm, stopAlarm } from '@/lib/appmintNative';
export function RingingScreen() {
const [ringing, setRinging] = useState(false);
useEffect(() => onAlarm((e) => setRinging(e.action === 'open' && e.ringing === true)), []);
if (!ringing) return null;
return (
<div>
<button onClick={() => { if (snoozeAlarm('', 10)) setRinging(false); }}>Snooze 10 min</button>
<button onClick={() => { stopAlarm(); setRinging(false); }}>Stop</button>
</div>
);
}snoozeAlarm answers false when nothing is ringing, so check before you promise the user a second ring.
import { snoozeAlarm } from '@/lib/appmintNative';
export function snoozeOrExplain(minutes = 0) {
return snoozeAlarm('', minutes)
? `Ringing again in ${minutes || 'a few'} minutes`
: 'No alarm is ringing right now.';
}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 = normalSpeak a short confirmation after an action, and tell the user in text too.
import { speak, toast } from '@/lib/appmintNative';
export function announceSaved(title: string) {
toast('Saved');
speak(`${title} saved`, { rate: 1.1 });
}Read a list one item after another; speak() queues, so call it in a loop and stop everything with stopSpeaking() when the screen closes.
import { useEffect } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';
export function ReadHeadlines({ items }: { items: string[] }) {
useEffect(() => () => stopSpeaking(), []); // leaving the screen stops the voice
return <button onClick={() => items.forEach((t) => speak(t))}>Read all</button>;
}Notes: 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>
);
}Dictate into a text field and say so when the microphone was refused, since start() answers false.
import { useState } from 'react';
import { speech } from '@/lib/appmintNative';
export function DictateField() {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const dictate = async () => {
setError('');
const started = await speech.start((text, isFinal) => { if (isFinal) setValue((v) => (v ? `${v} ${text}` : text)); }, { partial: false });
if (!started) setError('Voice input is not available. Check the microphone permission.');
};
return (
<div>
<textarea value={value} onChange={(e) => setValue(e.target.value)} />
<button onClick={dictate}>Dictate</button>
{error && <p role="alert">{error}</p>}
</div>
);
}Stop listening when the screen closes, and read the result back with speak.
import { useEffect, useState } from 'react';
import { speech, speak } from '@/lib/appmintNative';
export function VoiceCommand() {
const [heard, setHeard] = useState('');
useEffect(() => () => { void speech.stop(); }, []);
return (
<button
onClick={() => void speech.start((t, isFinal) => {
setHeard(t);
if (isFinal) speak(`You said ${t}`);
}, { lang: 'en-US' })}
>
{heard || 'Say something'}
</button>
);
}Notes:
- With
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>;
}Hold the splash while a signed-in session is restored, and release it in a finally so a failure cannot keep it up.
// main.tsx
import { splash } from '@/lib/appmintNative';
import { supabase } from '@/lib/supabase';
splash.hold();
supabase.auth.getSession().finally(() => splash.ready());Release it once, from a hook, after the first screen's data is on screen.
import { useEffect } from 'react';
import { splash } from '@/lib/appmintNative';
export function useReleaseSplash(loaded: boolean) {
useEffect(() => {
if (loaded) splash.ready(); // calling it again later does nothing harmful
}, [loaded]);
}Notes:
- 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 iconsSwitch the bar with the app's own light and dark theme, using the Capacitor-shaped calls together.
import { useEffect } from 'react';
import { StatusBar } from '@/lib/appmintNative';
export function useStatusBarForTheme(dark: boolean) {
useEffect(() => {
void StatusBar.setBackgroundColor({ color: dark ? '#0B0F1A' : '#FFFFFF' });
void StatusBar.setStyle({ style: dark ? 'DARK' : 'LIGHT' }); // 'DARK' = dark bar, light icons
}, [dark]);
}Notes: style follows Capacitor: 'DARK' means a dark background (light icons), anything else means dark icons. In the Android app the shell bridge paints the bar (the plugin's colour call does nothing on Android 15+), and setStyle() flips the icons on the bar's current colour. On the web the colour becomes <meta name="theme-color">.
appmintNative.stopAlarm appmintNative#
stopAlarm(id?) — @/lib/appmintNative
Example
Stops the ringing alarm clock, like its Dismiss button. A repeating alarm stays set for its next time.
Returns: boolean - true when it was ringing and is now stopped. id omitted = whichever is ringing. Needs: 🔔 Alarm clock in the Access step (Step 4) when you build.
import { stopAlarm, snoozeAlarm } from '@/lib/appmintNative';
export function RingScreen({ id, onClose }: { id: string; onClose: () => void }) {
return (
<div className="ring">
<button onClick={() => { snoozeAlarm(id, 10); onClose(); }}>Snooze 10 min</button>
<button onClick={() => { stopAlarm(id); onClose(); }}>Stop</button>
</div>
);
}Stop the alarm from the screen the app opens when the alarm rings, and react to every way it ends:
import { useEffect, useState } from 'react';
import { onAlarm, isAlarmRinging, stopAlarm } from '@/lib/appmintNative';
export function useRingingAlarm() {
const [ringingId, setRingingId] = useState<string | null>(null);
useEffect(() => onAlarm((e) => {
if (e.action === 'open' && e.ringing) setRingingId(e.id);
else setRingingId(null); // dismiss, snooze, missed or error: it is no longer ringing
}), []);
return {
ringingId,
stop: () => { if (isAlarmRinging()) stopAlarm(ringingId ?? ''); },
};
}Check the answer: false means nothing was ringing (or this is the web), so do not claim it stopped:
import { stopAlarm, alarmSupport } from '@/lib/appmintNative';
export function stopWithFeedback(id: string, say: (m: string) => void) {
if (alarmSupport() === 'unavailable') return say('Alarms only work in the Android app');
say(stopAlarm(id) ? 'Alarm stopped' : 'That alarm is not ringing');
}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>
);
}Read a long text aloud with the language and speed the user picked, and stop it from one button:
import { useState } from 'react';
import { speak, stopSpeaking } from '@/lib/appmintNative';
export function ReadAloud({ text, lang }: { text: string; lang: string }) {
const [rate, setRate] = useState(1);
return (
<div>
<input type="range" min={0.5} max={2} step={0.1} value={rate} onChange={(e) => setRate(Number(e.target.value))} />
<button onClick={() => { stopSpeaking(); speak(text, { lang, rate }); }}>Read aloud</button>
<button onClick={() => stopSpeaking()}>Stop</button>
</div>
);
}Cut the voice off when the user starts typing a reply:
import { stopSpeaking } from '@/lib/appmintNative';
export function ReplyBox({ onSend }: { onSend: (text: string) => void }) {
return <textarea onFocus={() => stopSpeaking()} onBlur={(e) => onSend(e.currentTarget.value)} />;
}Notes: Safe to call when nothing is speaking. 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();Dismiss a card by swiping it up, with the handler removed on unmount:
import { useEffect, useRef } from 'react';
import { swipeable } from '@/lib/appmintNative';
export function Banner({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => (ref.current ? swipeable(ref.current, { onUp: onClose, threshold: 40 }) : undefined), [onClose]);
return <div ref={ref} className="banner">{children}</div>;
}Notes:
- Built on Pointer Events, so the same code works with touch, pen and a mouse drag.
- It sets
touch-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']);
}Use the wallpaper colours only when the phone offers them, and re-read them when the app comes back to the screen:
import { useEffect, useState } from 'react';
import { systemPalette } from '@/lib/appmintNative';
export function useWallpaperAccent(enabled: boolean) {
const [accent, setAccent] = useState<string | null>(null);
useEffect(() => {
if (!enabled) { setAccent(null); return; }
const read = () => systemPalette().then((p) => setAccent(p.available ? p.accent1?.['600'] ?? null : null));
void read();
document.addEventListener('visibilitychange', read);
return () => document.removeEventListener('visibilitychange', read);
}, [enabled]);
return accent; // null = no wallpaper palette on this device (Android 11 and older, or the web)
}Show the "Match my wallpaper" switch only when a palette exists, using { available: false } as the answer to "not here":
import { useEffect, useState } from 'react';
import { can, systemPalette } from '@/lib/appmintNative';
export function WallpaperToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
const [ok, setOk] = useState(false);
useEffect(() => { if (can('systemPalette') !== 'none') systemPalette().then((p) => setOk(p.available)); }, []);
if (!ok) return null;
return <label><input type="checkbox" checked={value} onChange={(e) => onChange(e.target.checked)} /> Match my wallpaper</label>;
}Notes: Check contrast before you use a wallpaper tone for text or buttons - --accent must still reach 3:1 on your background. Offer it as an option ("Match my wallpaper"), not a forced theme. Call it again when the app resumes; the user may have changed the wallpaper.
appmintNative.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 once the text has been spoken (or stop() cut it short) and rejects when it could not be 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();
}Read out a list of lines one after the other; each speak resolves when queued, so the engine plays them in order:
import { TextToSpeech } from '@/lib/appmintNative';
export async function readSteps(steps: string[]) {
await TextToSpeech.stop(); // drop anything still waiting from before
for (const [i, s] of steps.entries()) {
await TextToSpeech.speak({ text: `Step ${i + 1}. ${s}`, lang: 'en-US' });
}
}A read-aloud button that stops speaking when the screen is left:
import { useEffect } from 'react';
import { TextToSpeech } from '@/lib/appmintNative';
export function ArticleReader({ text }: { text: string }) {
useEffect(() => () => { void TextToSpeech.stop(); }, []);
return <button onClick={() => void TextToSpeech.speak({ text, rate: 0.9 })}>Listen</button>;
}Notes: await TextToSpeech.speak(…) is the end signal: the next step after speaking can follow it directly. 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, and it rejects for a value that is not a number. 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));
}A text-size stepper that reads the current value, keeps it inside the allowed range, and shows it in percent:
import { useEffect, useState } from 'react';
import { textZoom } from '@/lib/appmintNative';
export function TextSizeStepper() {
const [z, setZ] = useState(1);
useEffect(() => { textZoom.get().then(setZ); }, []);
const change = async (d: number) => {
const next = Math.min(3, Math.max(0.5, Math.round((z + d) * 10) / 10));
await textZoom.set(next);
setZ(next);
};
return (
<div>
<button onClick={() => change(-0.1)}>A-</button>
<span>{Math.round(z * 100)}%</span>
<button onClick={() => change(0.1)}>A+</button>
</div>
);
}Notes: The app already follows the phone's font-size setting; this is an extra, in-app control. Test the layout at large sizes - fixed heights on text controls clip.
appmintNative.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>
);
}Switch the light off when the user leaves the screen and show the error the phone reports when it cannot be switched:
import { useEffect, useState } from 'react';
import { torch } from '@/lib/appmintNative';
export function LightSwitch() {
const [on, setOn] = useState(false);
const [err, setErr] = useState('');
useEffect(() => () => { void torch.off().catch(() => {}); }, []);
const flip = async () => {
try { setOn(await torch.toggle()); setErr(''); }
catch (e) {
const code = (e as { code?: string }).code;
setErr(code === 'CAMERA_IN_USE' ? 'Close the camera first' : 'No flashlight on this device');
}
};
return <div><button onClick={flip}>{on ? 'Off' : 'On'}</button>{err && <p role="alert">{err}</p>}</div>;
}Blink the light as a signal, then leave it off:
import { can, torch } from '@/lib/appmintNative';
export async function blink(times = 3) {
if (can('torch') === 'none') return;
for (let i = 0; i < times; i++) {
await torch.on();
await new Promise((r) => setTimeout(r, 200));
await torch.off();
await new Promise((r) => setTimeout(r, 200));
}
}Notes: switched off when the app closes. While the app's own camera stream is open, light it with the track's torch constraint instead.
appmintNative.trackEvent appmintNative#
import { trackEvent } from '@/lib/appmintNative'
Example
Reports a moment in your app so the creator's in-app messages with the trigger "When an event happens" (same name) show. The app downloads the creator's campaigns from the AppMint Push send center and shows them itself.
Returns: boolean synchronously: false for an invalid name (letters, digits and _ . : -, up to 64), on the web, and in apps built without AppMint Push. Needs: Android: AppMint Push turned on in the Integrate step (Step 3) when you build.
import { trackEvent } from '@/lib/appmintNative';
export async function completeCheckout(cart: { total: number }) {
await saveOrder(cart);
trackEvent('checkout_done', { total: cart.total }); // props are optional and not stored
}Report a view change the URL does not show, so "On a page" messages see it:
import { trackEvent } from '@/lib/appmintNative';
export function selectTab(tab: 'home' | 'cart' | 'profile') {
setActiveTab(tab);
trackEvent('appmint:route', { path: '/' + tab });
}A hook that tracks the first time a screen is opened:
import { useEffect } from 'react';
import { can, trackEvent } from '@/lib/appmintNative';
export function useTrackOnce(name: string) {
useEffect(() => {
if (can('inAppMessages') === 'none') return;
const key = 'tracked:' + name;
if (localStorage.getItem(key)) return;
if (trackEvent(name)) localStorage.setItem(key, '1');
}, [name]);
}Notes: URL changes (page loads, pushState, #hash routes) are already seen; only report what the address does not change.
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' });Use a fade for a tab change, in one handler per tab:
import { transition } from '@/lib/appmintNative';
export function Tabs({ tab, setTab }: { tab: 'home' | 'stats'; setTab: (t: 'home' | 'stats') => void }) {
return (
<nav>
<button onClick={() => void transition(() => setTab('home'), { type: 'fade' })} aria-current={tab === 'home'}>Home</button>
<button onClick={() => void transition(() => setTab('stats'), { type: 'fade' })} aria-current={tab === 'stats'}>Stats</button>
</nav>
);
}Notes:
- It uses the View Transitions API when the WebView has it. Otherwise it applies the update and plays a short enter animation on
#root(or the first child of<body>). - When the user asked the system for reduced motion, it only applies the update - no animation.
- Animations are about 240 ms.
appmintNative.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'Guard a delete with a fresh unlock, then confirm with a haptic and a toast:
import { unlock, haptic, toast } from '@/lib/appmintNative';
export async function deleteAccountData(wipe: () => Promise<void>) {
const ok = await unlock('Confirm delete', 'This removes your saved data');
if (!ok) { haptic('warning'); toast('Not unlocked, nothing was deleted'); return; }
await wipe();
haptic('success');
toast('Data deleted');
}Notes: 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';Show the running content version in a settings row, with the built-in label when the page has no staged bundle:
import { useMemo } from 'react';
import { updates } from '@/lib/appmintNative';
export function AboutRow() {
const label = useMemo(() => {
const c = updates.current();
return c ? `Content ${c.version} (app ${c.binaryVersion})` : 'Built-in content';
}, []);
return <p>{label}</p>;
}Notes:
- 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>
);
}Show an away state and stop watching on unmount; a refused permission is reported with its code:
import { useEffect, useRef, useState } from 'react';
import { can, watchIdle } from '@/lib/appmintNative';
export function useIdleWatch() {
const stop = useRef<(() => void) | null>(null);
const [state, setState] = useState<'off' | 'active' | 'idle' | 'denied'>('off');
useEffect(() => () => stop.current?.(), []);
// Call this from a tap: the first permission request needs a gesture.
const start = async () => {
if (can('idle') === 'none') return;
try {
stop.current = await watchIdle((s) => setState(s.user === 'idle' || s.screen === 'locked' ? 'idle' : 'active'));
} catch (e) {
if ((e as { code?: string }).code === 'permission-denied') setState('denied');
}
};
return { state, start };
}Pause a polling timer while the user is away and refresh when they are back:
import { watchIdle } from '@/lib/appmintNative';
export async function pausePollingWhenIdle(refresh: () => void) {
let timer: ReturnType<typeof setInterval> | undefined = setInterval(refresh, 30000);
const stop = await watchIdle((s) => {
const away = s.user === 'idle' || s.screen === 'locked';
if (away && timer) { clearInterval(timer); timer = undefined; }
if (!away && !timer) { refresh(); timer = setInterval(refresh, 30000); }
});
return () => { stop(); if (timer) clearInterval(timer); };
}Notes: the threshold is at least one minute (the spec's floor).
appmintNative.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, a caption, up to twelve lines (each with an optional colour, marker and route), 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 agenda that fills a resized widget, with a see-through background and an overdue line in red:
import { widget, type WidgetLine } from '@/lib/appmintNative';
export async function updateAgendaWidget(events: { day: string; time: string; title: string; overdue: boolean }[]) {
const lines: WidgetLine[] = events.slice(0, 12).map((e) => e.overdue
? { text: `Overdue: ${e.title}`, color: '#FF5252', marker: '#FF5252', bold: true, route: `/day/${e.day}` }
: { text: `${e.time} ${e.title}`, marker: '#29B6F6', route: `/day/${e.day}` });
await widget.set({
title: 'Agenda', value: 'Today', caption: `${events.length} events`,
bg: 'rgba(20, 38, 74, 0.6)', textSize: 'auto', lines, route: '/agenda',
});
}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>
);
}Empty the card when the user signs out, and show how many copies are on the home screen:
import { useEffect, useState } from 'react';
import { can, widget } from '@/lib/appmintNative';
export function WidgetStatus({ signedIn }: { signedIn: boolean }) {
const [placed, setPlaced] = useState(0);
useEffect(() => {
if (can('widget') === 'none') return;
if (!signedIn) void widget.clear();
widget.placedCount().then(setPlaced);
}, [signedIn]);
return <p>{placed === 0 ? 'Not on your home screen yet' : `On your home screen (${placed})`}</p>;
}Notes:
- The card is saved on the device, survives a reboot, and every placed copy is refreshed.
placedCount()of0means no copy is on the home screen yet. - Card fields:
title(empty = the app name),value(shown large inaccent, hidden when empty),caption(small text under the value),lines(max 12; six always show, more when the widget is tall enough; a line is a string or{ text, color?, marker?, bold?, wrap?, route? }),route(default/),textSize('auto'grows the text with the widget as far as every line still fits, wrapping long lines to two rows;'small' | 'normal' | 'large' | 'xl'or a number 0.6-3),align('start' | 'center' | 'end'),wrap(long lines take two rows), andbg/ink/accent. The colours default to the CSS tokens--bg,--ink,--accent; they must be#RRGGBB,#AARRGGBB(alpha FIRST:#9914264Ais 60 % opaque),#RGB,rgb()/rgba()or a colour name - anhsl()/oklch()value falls back to the default colours.set()returnsfalsefor an unknowntextSize/alignor a bad line colour. - 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.