JavaScript bridge API
Контакты
Чтение, поиск, выбор и запись — для выбора контакта разрешение вообще не нужно.
addContact мост#
window.WebToApk.addContact(requestId: String, contactJson: String)
Системный выбор (разрешение не нужно) и запись нового контакта (WRITE_CONTACTS). Чтение — список и поиск — описано в группе «Контакты» выше.
Описывается в своей группе, Контакты: выбор и добавление, а не отдельно.
Пример
Сохраняет новый контакт прямо в адресную книгу телефона, не открывая приложение «Контакты».
Возвращает: сейчас ничего. Ответ приходит в событии appmint:contacts (и в window.onAppMintContacts) с detail = { requestId, ok: true, id: '123' } при успехе, { requestId, ok: false, id: null }, если сохранить не удалось, или { requestId, error }. Требуется: при сборке включите Редактирование контактов на шаге 4 (Доступ); при первом вызове у пользователя запрашивается разрешение.
Сохраните клиента. Контакт передаётся в виде строки JSON.
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]
}));
}Краткая форма. Одиночные значения тоже принимаются: { name: 'Ali', phone: '+911234567890', email: '[email protected]' }.
Кнопка «Сохранить в контакты» на странице визитки, в краткой форме и с 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);
});
});Импортируйте список команды по одному человеку, с рабочими и домашними номерами, и сообщите, сколько сохранено:
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');
}Примечания: Ошибки: not_enabled, permission_denied. Возвращаемый id — это новый идентификатор raw-contact. Телефон с type, отличным от home или work, сохраняется как mobile. Контакт сохраняется на устройстве (без аккаунта). Запись контактов — это чувствительное разрешение: ваша карточка в Play Store должна указывать его в форме Data safety и в политике конфиденциальности.
getContact мост#
window.WebToApk.getContact(requestId: String, contactId: String)
Всё асинхронно: чтение курсора может занимать тысячи строк, а блокирующий возврат @JavascriptInterface выполнял бы это в потоке JS — получился бы ANR. Результаты приходят как `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: передавайте СТРОКУ ('pick-' + Date.now()). Обычное ЧИСЛО тоже работает — мост приводит его через double ("1.787491234567E12"), а каждый ответ приводит его обратно (BridgeRequestId), так что строгое сравнение `res.requestId === Date.now()` в обоих случаях даёт true. WebToApk.listContacts(id, limit, offset) WebToApk.searchContacts(id, query, limit) WebToApk.getContact(id, contactId) WebToApk.pickContact(id) <- разрешение НЕ нужно (системный выбор) WebToApk.addContact(id, json) <- нужно разрешение на запись контактов
Описывается в своей группе, Контакты, а не отдельно.
Пример
Заново читает один контакт по его id (id из listContacts или searchContacts).
Возвращает: сейчас ничего. Ответ приходит в событии appmint:contacts (и в window.onAppMintContacts) с detail = { requestId, contact: { id, displayName, photoUri, phones: [{ number, type }], emails: [...] } }. contact равно null, если такого контакта больше нет. При сбое: { requestId, error }. Требуется: при сборке включите Контакты на шаге 4 (Доступ).
Обновите сохранённый контакт, используя хук страницы вместо слушателя событий.
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));
}Обновите сохранённый список идентификаторов избранных контактов и уберите те, что с тех пор удалены с телефона:
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);
}Проверьте, остался ли номер, сохранённый вашим приложением для клиента, в его карточке контакта:
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));
}Примечания: window.onAppMintContacts получает каждый ответ по контактам (список, поиск, выбор, добавление), поэтому всегда проверяйте requestId. Можно использовать его или addEventListener('appmint:contacts', ...) — сработают оба. Ошибки: not_enabled, permission_denied, foreign_origin. id из pickContact — это идентификатор строки телефона, а не идентификатор контакта, поэтому здесь он не подходит. Контакты — это личные данные: ваша карточка в Play Store должна указывать их в форме Data safety и в политике конфиденциальности.
listContacts мост#
window.WebToApk.listContacts(requestId: String, limit: Int, offset: Int)
Всё асинхронно: чтение курсора может занимать тысячи строк, а блокирующий возврат @JavascriptInterface выполнял бы это в потоке JS — получился бы ANR. Результаты приходят как `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: передавайте СТРОКУ ('pick-' + Date.now()). Обычное ЧИСЛО тоже работает — мост приводит его через double ("1.787491234567E12"), а каждый ответ приводит его обратно (BridgeRequestId), так что строгое сравнение `res.requestId === Date.now()` в обоих случаях даёт true. WebToApk.listContacts(id, limit, offset) WebToApk.searchContacts(id, query, limit) WebToApk.getContact(id, contactId) WebToApk.pickContact(id) <- разрешение НЕ нужно (системный выбор) WebToApk.addContact(id, json) <- нужно разрешение на запись контактов
Описывается в своей группе, Контакты, а не отдельно.
Пример
Читает адресную книгу телефона постранично, в порядке имён от А до Я.
Возвращает: сейчас ничего. Ответ приходит позже в событии appmint:contacts (и в window.onAppMintContacts) с detail = { requestId, contacts: [{ id, displayName, photoUri, phones: [{ number, type }], emails: ['[email protected]'] }] } или { requestId, error }. Требуется: при сборке включите Контакты на шаге 4 (Доступ); при первом вызове у пользователя запрашивается разрешение.
Загрузите первые 100 контактов. Сопоставьте ответ по своему 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
}Следующая страница. Передайте уже имеющееся число как offset: WebToApk.listContacts(id, 100, 100).
Загрузите всю адресную книгу страницами по 500, пока короткая страница не покажет, что вы дошли до конца:
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
}
}Составьте список «Пригласить друзей» только из мобильных номеров и подскажите пользователю, что делать, если чтение отклонено:
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);
}Примечания: limit ограничивается диапазоном 1–2000. Ошибки: not_enabled (при сборке «Контакты» были выключены), permission_denied (пользователь отказал), foreign_origin (вызов со страницы, которая не принадлежит вашему приложению). Тип телефона type: home, mobile, work, main, work_fax, home_fax или other. photoUri — это адрес content://, а не URL изображения, который страница может загрузить. Чтобы дать пользователю выбрать только одного человека, используйте pickContact — ему не нужно разрешение. Контакты — это личные данные: ваша карточка в Play Store должна указывать их в форме Data safety и в политике конфиденциальности.
pickContact мост#
window.WebToApk.pickContact(requestId: String)
Системный выбор контакта — НЕ требует разрешения и подходит всякий раз, когда странице нужно просто дать пользователю выбрать одного человека. Выбирает из таблицы PHONE, а не из таблицы контактов: выбор даёт доступ на чтение только к возвращённой строке, поэтому выбор строки с телефоном — это то, что делает номер доступным без READ_CONTACTS. Выбор «голого» контакта вернул бы строку с именем без номера, а чтение номера из неё потребовало бы разрешения, которого этот метод как раз позволяет избежать.
Пример
Открывает собственный выбор контактов Android, чтобы пользователь выбрал один номер телефона. Разрешение не нужно.
Возвращает: сейчас ничего. Ответ приходит в событии appmint:contacts (и в window.onAppMintContacts) с detail = { requestId, contact: { id, displayName, photoUri, phones: [{ number, type: 'picked' }], emails: [] } }. Если пользователь закрыл окно выбора: { requestId, error: 'cancelled' }. Требуется: ничего — ни переключателя при сборке, ни запроса разрешения.
Заполните форму данными выбранного человека.
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);
}Ведите короткий список экстренных контактов. Сохраняйте имя и номер, а не id, чтобы список не зависел от адресной книги:
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);
});Отключайте кнопку, пока открыт выбор, и показывайте поля для ввода, если на телефоне нет приложения контактов:
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);
});Примечания: Вы получаете имя и ОДИН номер, на который нажал пользователь, — никогда адреса почты и не остальную книгу. emails всегда пуст. Ошибки: cancelled, no_picker (на телефоне нет приложения контактов). Если чтение после выбора не удалось, contact равно null. Поскольку без выбора пользователя ничего не читается, это способ получить номер телефона, удобный для Play; предпочитайте его listContacts, когда нужен только один человек. Для приложений, созданных ИИ, и импортов из Google AI Studio сборка сканирует ваш код; вызов pickContact( не включает Контакты — это делают только listContacts, searchContacts и getContact — поэтому выбор контакта не добавляет разрешение на чтение контактов.
searchContacts мост#
window.WebToApk.searchContacts(requestId: String, query: String, limit: Int)
Всё асинхронно: чтение курсора может занимать тысячи строк, а блокирующий возврат @JavascriptInterface выполнял бы это в потоке JS — получился бы ANR. Результаты приходят как `appmint:contacts` / window.onAppMintContacts({requestId,...}). requestId: передавайте СТРОКУ ('pick-' + Date.now()). Обычное ЧИСЛО тоже работает — мост приводит его через double ("1.787491234567E12"), а каждый ответ приводит его обратно (BridgeRequestId), так что строгое сравнение `res.requestId === Date.now()` в обоих случаях даёт true. WebToApk.listContacts(id, limit, offset) WebToApk.searchContacts(id, query, limit) WebToApk.getContact(id, contactId) WebToApk.pickContact(id) <- разрешение НЕ нужно (системный выбор) WebToApk.addContact(id, json) <- нужно разрешение на запись контактов
Описывается в своей группе, Контакты, а не отдельно.
Пример
Находит контакты, имя которых совпадает с поисковым словом, не загружая всю адресную книгу.
Возвращает: сейчас ничего. Ответ приходит в событии appmint:contacts (и в window.onAppMintContacts) с detail = { requestId, contacts: [...] } — тот же формат контакта, что у listContacts — или { requestId, error }. Требуется: при сборке включите Контакты на шаге 4 (Доступ); при первом вызове у пользователя запрашивается разрешение.
Ищите по мере ввода. Показывается только ответ на последний поиск.
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);
});Оберните поиск в Promise и ждите, пока пользователь перестанет печатать, чтобы быстрый набор не запускал поиск на каждую клавишу:
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);
});Заполните поле почты из адресной книги, когда введённое имя совпадает с одним человеком, у которого есть адрес почты:
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());
});Примечания: Пустое слово поиска возвращает контакты с начала списка (как listContacts). limit ограничивается диапазоном 1–2000, смещения нет. Ошибки: not_enabled, permission_denied, foreign_origin. Контакты — это личные данные: ваша карточка в Play Store должна указывать их в форме Data safety и в политике конфиденциальности.