JavaScript bridge API
Contacts
Reading, searching, picking and writing - the picker needs no permission at all.
addContact bridge#
window.WebToApk.addContact(requestId: String, contactJson: String)
The system picker (no permission needed) and writing a new contact (WRITE_CONTACTS). The read side - listing and searching - is under the Contacts group above.
Described by its group, Contacts: pick and add, rather than on its own.
Example
Saves a new contact straight into the phone's address book, without opening the Contacts app.
Returns: nothing now. The answer is the appmint:contacts event (and window.onAppMintContacts) with detail = { requestId, ok: true, id: '123' } on success, { requestId, ok: false, id: null } when the save failed, or { requestId, error }. Needs: turn on Edit Contacts in Step 4 (Access) when you build; the user is asked for permission on the first call.
Save a customer. The contact is passed as a JSON string.
function saveCustomer(name, phone, email) {
if (!(window.WebToApk && window.WebToApk.addContact)) return;
var id = 'add-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error === 'not_enabled') { showMessage('Turn on Edit Contacts when you build'); return; }
if (e.detail.error) { showMessage('Not saved: ' + e.detail.error); return; }
showMessage(e.detail.ok ? 'Saved to contacts' : 'Could not save the contact');
});
WebToApk.addContact(id, JSON.stringify({
displayName: name,
phones: [{ number: phone, type: 'mobile' }], // type: mobile | home | work
emails: [email]
}));
}Short form. Single values are accepted too: { name: 'Ali', phone: '+911234567890', email: '[email protected]' }.
A "Save to contacts" button on a business card page, using the short form and a Promise:
function addOne(contact) {
return new Promise(function (resolve, reject) {
var id = 'card-' + Date.now() + '-' + Math.random().toString(36).slice(2);
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) reject(new Error(e.detail.error));
else if (!e.detail.ok) reject(new Error('save_failed'));
else resolve(e.detail.id);
});
WebToApk.addContact(id, JSON.stringify(contact));
});
}
document.getElementById('save-card').addEventListener('click', function () {
if (!(window.WebToApk && window.WebToApk.addContact)) return;
addOne({ name: 'Priya Sharma', phone: '+919876543210', email: '[email protected]' })
.then(function () { showMessage('Priya is in your contacts now'); })
.catch(function (err) {
showMessage(err.message === 'permission_denied' ? 'Allow Contacts to save this card.' : 'Not saved: ' + err.message);
});
});Import a team list one person at a time, with work and home numbers, and report how many were saved:
async function importTeam(team) { // [{ name, work, home, email }]
if (!(window.WebToApk && window.WebToApk.addContact)) return;
var saved = 0;
for (var i = 0; i < team.length; i++) {
var m = team[i];
try {
await addOne({ // addOne() from the example above
displayName: m.name,
phones: [{ number: m.work, type: 'work' }, { number: m.home, type: 'home' }],
emails: m.email ? [m.email] : []
});
saved++;
} catch (err) {
if (err.message === 'not_enabled' || err.message === 'permission_denied') break; // the rest would fail too
}
}
showMessage(saved + ' of ' + team.length + ' saved');
}Notes: Errors: not_enabled, permission_denied. The returned id is the new raw-contact id. A phone type other than home or work is saved as mobile. The contact is saved on the device (no account). Writing contacts is a sensitive permission: your Play Store listing must declare it in the Data safety form and privacy policy.
getContact bridge#
window.WebToApk.getContact(requestId: String, contactId: String)
All async: cursor reads can be thousands of rows, and a blocking @JavascriptInterface return would run that on the JS thread - an ANR. Results arrive as `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: pass a STRING ('pick-' + Date.now()). A raw NUMBER also works - the bridge coerces it through a double ("1.787491234567E12"), and every reply canonicalizes it back (BridgeRequestId), so `res.requestId === Date.now()` strict-compares true either way. WebToApk.listContacts(id, limit, offset) WebToApk.searchContacts(id, query, limit) WebToApk.getContact(id, contactId) WebToApk.pickContact(id) <- NO permission needed (system picker) WebToApk.addContact(id, json) <- needs the Contacts-write permission
Described by its group, Contacts, rather than on its own.
Example
Reads one contact again by its id (the id from listContacts or searchContacts).
Returns: nothing now. The answer is the appmint:contacts event (and window.onAppMintContacts) with detail = { requestId, contact: { id, displayName, photoUri, phones: [{ number, type }], emails: [...] } }. contact is null when that contact no longer exists. On failure: { requestId, error }. Needs: turn on Contacts in Step 4 (Access) when you build.
Refresh a saved contact using the page hook instead of an event listener.
var pendingId = '';
window.onAppMintContacts = function (detail) {
if (detail.requestId !== pendingId) return;
if (detail.error) { showMessage('Could not read the contact: ' + detail.error); return; }
if (!detail.contact) { showMessage('This contact was deleted.'); return; }
var c = detail.contact;
document.getElementById('name').textContent = c.displayName;
document.getElementById('phones').textContent =
c.phones.map(function (p) { return p.type + ': ' + p.number; }).join(', ');
document.getElementById('emails').textContent = c.emails.join(', ');
};
function openContact(contactId) {
if (!(window.WebToApk && window.WebToApk.getContact)) return;
pendingId = 'one-' + Date.now();
WebToApk.getContact(pendingId, String(contactId));
}Refresh a saved list of favourite contact ids and drop the ones deleted from the phone since:
function getOne(contactId) {
return new Promise(function (resolve, reject) {
var id = 'fav-' + contactId + '-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) reject(new Error(e.detail.error)); else resolve(e.detail.contact);
});
WebToApk.getContact(id, String(contactId));
});
}
async function loadFavourites() {
if (!(window.WebToApk && window.WebToApk.getContact)) return;
var ids = JSON.parse(localStorage.getItem('favIds') || '[]');
var people = [];
for (var i = 0; i < ids.length; i++) {
var c = await getOne(ids[i]);
if (c) people.push(c); // null = deleted from the phone
}
localStorage.setItem('favIds', JSON.stringify(people.map(function (c) { return c.id; })));
renderFavourites(people);
}Check whether the number your app stored for a customer is still on their contact card:
function checkStoredNumber(customer) { // { contactId, phone }
if (!(window.WebToApk && window.WebToApk.getContact)) return;
var id = 'check-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) { showMessage('Could not check: ' + e.detail.error); return; }
if (!e.detail.contact) { showMessage('This customer is no longer in your contacts.'); return; }
var digits = function (s) { return String(s).replace(/\D/g, ''); };
var still = e.detail.contact.phones.some(function (p) {
return digits(p.number).slice(-10) === digits(customer.phone).slice(-10);
});
if (!still) showMessage('The number for ' + e.detail.contact.displayName + ' has changed.');
});
WebToApk.getContact(id, String(customer.contactId));
}Notes: window.onAppMintContacts receives every contacts answer (list, search, pick, add), so always check requestId. You can use it or addEventListener('appmint:contacts', ...) - both fire. Errors: not_enabled, permission_denied, foreign_origin. The id from pickContact is a phone-row id, not a contact id, so it does not work here. Contacts is personal data: your Play Store listing must declare it in the Data safety form and privacy policy.
listContacts bridge#
window.WebToApk.listContacts(requestId: String, limit: Int, offset: Int)
All async: cursor reads can be thousands of rows, and a blocking @JavascriptInterface return would run that on the JS thread - an ANR. Results arrive as `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: pass a STRING ('pick-' + Date.now()). A raw NUMBER also works - the bridge coerces it through a double ("1.787491234567E12"), and every reply canonicalizes it back (BridgeRequestId), so `res.requestId === Date.now()` strict-compares true either way. WebToApk.listContacts(id, limit, offset) WebToApk.searchContacts(id, query, limit) WebToApk.getContact(id, contactId) WebToApk.pickContact(id) <- NO permission needed (system picker) WebToApk.addContact(id, json) <- needs the Contacts-write permission
Described by its group, Contacts, rather than on its own.
Example
Reads the phone's address book, page by page, sorted A to Z by name.
Returns: nothing now. The answer comes later as the appmint:contacts event (and window.onAppMintContacts) with detail = { requestId, contacts: [{ id, displayName, photoUri, phones: [{ number, type }], emails: ['[email protected]'] }] }, or { requestId, error }. Needs: turn on Contacts in Step 4 (Access) when you build; the user is asked for permission on the first call.
Load the first 100 contacts. Match the answer on your own requestId.
function loadContacts() {
if (!(window.WebToApk && window.WebToApk.listContacts)) {
showMessage('Contacts work only inside the app.');
return;
}
var id = 'contacts-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return; // another call's answer
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) { showMessage('Could not read contacts: ' + e.detail.error); return; }
var list = document.getElementById('list');
list.innerHTML = '';
e.detail.contacts.forEach(function (c) {
var li = document.createElement('li');
var num = c.phones.length ? c.phones[0].number + ' (' + c.phones[0].type + ')' : '';
li.textContent = c.displayName + ' ' + num;
list.appendChild(li);
});
});
WebToApk.listContacts(id, 100, 0); // limit 100, start at 0
}Next page. Pass the number you already have as offset: WebToApk.listContacts(id, 100, 100).
Load the whole address book in pages of 500 until a short page says you reached the end:
function listPage(limit, offset) {
return new Promise(function (resolve, reject) {
var id = 'page-' + offset + '-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) reject(new Error(e.detail.error));
else resolve(e.detail.contacts);
});
WebToApk.listContacts(id, limit, offset);
});
}
async function loadEveryContact() {
if (!(window.WebToApk && window.WebToApk.listContacts)) return [];
var all = [];
while (true) {
var page = await listPage(500, all.length);
all = all.concat(page);
if (page.length < 500) return all; // last page
}
}Build an "Invite friends" list from mobile numbers only, and tell the user what to do when reading is refused:
function showInviteList() {
if (!(window.WebToApk && window.WebToApk.listContacts)) return;
var id = 'invite-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error === 'permission_denied') { showMessage('Allow Contacts to invite friends.'); return; }
if (e.detail.error) { showMessage('Contacts are not available: ' + e.detail.error); return; }
var mobiles = [];
e.detail.contacts.forEach(function (c) {
c.phones.forEach(function (p) {
if (p.type === 'mobile') mobiles.push({ name: c.displayName, number: p.number });
});
});
renderInviteList(mobiles); // your own list UI
});
WebToApk.listContacts(id, 500, 0);
}Notes: limit is clamped to 1-2000. Errors: not_enabled (Contacts was off at build time), permission_denied (the user said No), foreign_origin (called from a page that is not your app's own). Phone type is home, mobile, work, main, work_fax, home_fax or other. photoUri is a content:// address, not an image URL a page can load. To let the user choose just one person, use pickContact instead - it needs no permission. Contacts is personal data: your Play Store listing must declare it in the Data safety form and privacy policy.
pickContact bridge#
window.WebToApk.pickContact(requestId: String)
System contact picker - needs NO permission, and is the right choice whenever the page just wants the user to choose one person. Picks from the PHONE table, not the contact table: the picker grants read access only to the row it returns, so picking a phone row is what makes the number readable without READ_CONTACTS. Picking a bare contact would return a row with a name and no number, and reading the number from it would need the permission this method exists to avoid.
Example
Opens Android's own contact picker so the user chooses one phone number. No permission is needed.
Returns: nothing now. The answer is the appmint:contacts event (and window.onAppMintContacts) with detail = { requestId, contact: { id, displayName, photoUri, phones: [{ number, type: 'picked' }], emails: [] } }. If the user closes the picker: { requestId, error: 'cancelled' }. Needs: nothing - no build switch, no permission prompt.
Fill a form with the chosen person.
function chooseContact() {
if (!(window.WebToApk && window.WebToApk.pickContact)) {
showMessage('Open this page in the app to pick a contact.');
return;
}
var id = 'pick-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error === 'cancelled') return; // user closed the picker
if (e.detail.error) { showMessage('Could not open contacts'); return; }
if (!e.detail.contact) { showMessage('Could not read that contact'); return; }
var c = e.detail.contact;
document.getElementById('name').value = c.displayName;
document.getElementById('phone').value = c.phones.length ? c.phones[0].number : '';
});
WebToApk.pickContact(id);
}Keep a short list of emergency contacts. Save the name and number, not the id, so the list does not depend on the address book:
function pickOne() {
return new Promise(function (resolve, reject) {
var id = 'sos-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error === 'cancelled') resolve(null);
else if (e.detail.error) reject(new Error(e.detail.error));
else resolve(e.detail.contact);
});
WebToApk.pickContact(id);
});
}
document.getElementById('add-sos').addEventListener('click', async function () {
if (!(window.WebToApk && window.WebToApk.pickContact)) return;
var c = await pickOne();
if (!c || !c.phones.length) return; // closed, or nothing readable
var list = JSON.parse(localStorage.getItem('sos') || '[]');
list.push({ name: c.displayName, number: c.phones[0].number });
localStorage.setItem('sos', JSON.stringify(list));
renderSosList(list);
});Disable the button while the picker is open, and show the typing fields when the phone has no contacts app:
var pickBtn = document.getElementById('pick');
pickBtn.addEventListener('click', function () {
if (!(window.WebToApk && window.WebToApk.pickContact)) { showManualEntry(); return; }
var id = 'recipient-' + Date.now();
pickBtn.disabled = true; // one picker at a time
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
pickBtn.disabled = false;
if (e.detail.error === 'no_picker') { showManualEntry(); return; }
if (e.detail.error) return; // 'cancelled'
if (e.detail.contact && e.detail.contact.phones.length) {
document.getElementById('to').value = e.detail.contact.phones[0].number;
}
});
WebToApk.pickContact(id);
});Notes: You get the name and the ONE number the user tapped - never emails, never the rest of the book. emails is always empty. Errors: cancelled, no_picker (no contacts app on the phone). If the read fails after a pick, contact is null. Because nothing is read without the user's choice, this is the Play-friendly way to get a phone number; prefer it over listContacts when you only need one person. For AI-built apps and Google AI Studio imports the build scans your code; a pickContact( call does not tick Contacts - only listContacts, searchContacts and getContact do - so the picker adds no contacts-read permission.
searchContacts bridge#
window.WebToApk.searchContacts(requestId: String, query: String, limit: Int)
All async: cursor reads can be thousands of rows, and a blocking @JavascriptInterface return would run that on the JS thread - an ANR. Results arrive as `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: pass a STRING ('pick-' + Date.now()). A raw NUMBER also works - the bridge coerces it through a double ("1.787491234567E12"), and every reply canonicalizes it back (BridgeRequestId), so `res.requestId === Date.now()` strict-compares true either way. WebToApk.listContacts(id, limit, offset) WebToApk.searchContacts(id, query, limit) WebToApk.getContact(id, contactId) WebToApk.pickContact(id) <- NO permission needed (system picker) WebToApk.addContact(id, json) <- needs the Contacts-write permission
Described by its group, Contacts, rather than on its own.
Example
Finds contacts whose name matches a search word, without loading the whole address book.
Returns: nothing now. The answer is the appmint:contacts event (and window.onAppMintContacts) with detail = { requestId, contacts: [...] } - the same contact shape as listContacts - or { requestId, error }. Needs: turn on Contacts in Step 4 (Access) when you build; the user is asked for permission on the first call.
Search as the user types. Only the answer to the latest search is shown.
var lastSearchId = '';
function searchPeople(word) {
if (!(window.WebToApk && window.WebToApk.searchContacts)) return;
lastSearchId = 'search-' + Date.now();
WebToApk.searchContacts(lastSearchId, word, 20); // at most 20 results
}
window.addEventListener('appmint:contacts', function (e) {
if (!e.detail || e.detail.requestId !== lastSearchId) return; // old or other request
if (e.detail.error) { showMessage('Search failed: ' + e.detail.error); return; }
var names = e.detail.contacts.map(function (c) {
return c.displayName + (c.phones.length ? ' - ' + c.phones[0].number : '');
});
document.getElementById('results').textContent = names.join('\n') || 'No match';
});
document.getElementById('q').addEventListener('input', function (e) {
searchPeople(e.target.value);
});Wrap the search in a Promise and wait until the user stops typing, so fast typing does not start a search per key:
function searchOnce(word, limit) {
return new Promise(function (resolve, reject) {
var id = 'find-' + Date.now() + '-' + Math.random().toString(36).slice(2);
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) reject(new Error(e.detail.error)); else resolve(e.detail.contacts);
});
WebToApk.searchContacts(id, word, limit);
});
}
var timer = null;
document.getElementById('q').addEventListener('input', function (e) {
clearTimeout(timer);
var word = e.target.value.trim();
timer = setTimeout(function () {
if (!(window.WebToApk && window.WebToApk.searchContacts) || !word) return;
searchOnce(word, 10)
.then(function (people) { renderSuggestions(people); })
.catch(function (err) { showMessage('Search failed: ' + err.message); });
}, 300);
});Fill the email box from the address book when the typed name matches one person who has an email:
function suggestEmail(name) {
if (!(window.WebToApk && window.WebToApk.searchContacts) || name.length < 2) return;
var id = 'email-' + Date.now();
window.addEventListener('appmint:contacts', function handler(e) {
if (!e.detail || e.detail.requestId !== id) return;
window.removeEventListener('appmint:contacts', handler);
if (e.detail.error) return; // leave the box for the user to type
var withEmail = e.detail.contacts.filter(function (c) { return c.emails.length > 0; });
if (withEmail.length === 1) {
document.getElementById('email').value = withEmail[0].emails[0];
}
});
WebToApk.searchContacts(id, name, 5);
}
document.getElementById('name').addEventListener('change', function (e) {
suggestEmail(e.target.value.trim());
});Notes: An empty search word returns contacts from the start of the list (like listContacts). limit is clamped to 1-2000 and there is no offset. Errors: not_enabled, permission_denied, foreign_origin. Contacts is personal data: your Play Store listing must declare it in the Data safety form and privacy policy.