Создать APK бесплатно

JavaScript bridge API

Файлы: по одному

Системный выбор файлов и чтение/запись двоичных данных частями для файлов, слишком больших, чтобы держать их в строке.

AppMint.downloadFile помощник#

AppMint.downloadFile(data, fileName, mimeType?)

Пример

Сохраняет файл, созданный вашей страницей, — из строки base64, URL data:, URL blob: или Blob — под выбранным вами именем. Открывается экран Android «Сохранить как…» с подставленным именем.

Возвращает: (синхронно) true, когда сохранение передано Android (false только для URL data:, который не удалось разобрать). Выбрасывает TypeError, если data — не Blob и не строка. Папку по-прежнему выбирает пользователь, и он может отменить. Требуется: ничего — он всегда есть в приложении.

Строка Base64 и MIME-тип:

function exportCsv(rows) {
  const csv = rows.map(function (r) { return r.join(','); }).join('\n');
  const b64 = btoa(unescape(encodeURIComponent(csv)));          // UTF-8 safe
  if (window.AppMint && typeof AppMint.downloadFile === 'function') {
    AppMint.downloadFile(b64, 'Ledger_Q3.csv', 'text/csv');
  } else {
    const a = document.createElement('a');                       // a normal browser
    a.href = 'data:text/csv;base64,' + b64;
    a.download = 'Ledger_Q3.csv';
    a.click();
  }
}

URL data: (MIME-тип берётся из URL) или Blob:

const png = document.querySelector('canvas').toDataURL('image/png');
AppMint.downloadFile(png, 'Chart.png');

const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
AppMint.downloadFile(blob, 'Backup.json');                        // same as AppMint.saveBlob

PDF в формате base64, который сервер возвращает в JSON; к имени без расширения добавляется .pdf по MIME-типу:

async function downloadInvoice(id) {
  const res = await fetch('/api/invoices/' + id + '?format=base64');
  const body = await res.json();                                  // { base64: 'JVBERi0x…' }
  if (!window.AppMint || typeof AppMint.downloadFile !== 'function') {
    location.href = 'data:application/pdf;base64,' + body.base64;
    return;
  }
  AppMint.downloadFile(body.base64, 'Invoice_' + id, 'application/pdf');   // saved as Invoice_<id>.pdf
}

URL blob:, который вы уже создали для предпросмотра на экране:

// earlier: preview.src = URL.createObjectURL(editedImageBlob);
document.getElementById('save-photo').addEventListener('click', function () {
  const preview = document.getElementById('preview');
  if (window.AppMint && typeof AppMint.downloadFile === 'function') {
    AppMint.downloadFile(preview.src, 'Edited_Photo.jpg');
  }
});

Примечания: Используется только последняя часть fileName (без папок). К имени без расширения добавляется расширение по MIME-типу. Третий аргумент используется только для обычной строки base64. Если на телефоне вообще нет экрана «Сохранить как…», файл записывается прямо в «Загрузки». Ваш обычный код с <a download> тоже работает — см. downloads.

AppMint.saveBlob помощник#

AppMint.saveBlob(blob, fileName)

Пример

Сохраняет Blob (или File) под выбранным вами именем. Открывается экран Android «Сохранить как…» с подставленным именем.

Возвращает: (синхронно) true, когда сохранение передано Android. Выбрасывает TypeError, если первый аргумент не Blob. Требуется: ничего — он всегда есть в приложении.

async function saveReport() {
  const blob = await makePdfBlob();                    // e.g. jsPDF: doc.output('blob')
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Site_Report_2026-08.pdf');
    return;
  }
  const url = URL.createObjectURL(blob);               // a normal browser
  const a = document.createElement('a');
  a.href = url;
  a.download = 'Site_Report_2026-08.pdf';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
}

File приносит своё имя, поэтому имя можно не указывать:

const file = new File(['name,score\n'], 'Scores.csv', { type: 'text/csv' });
AppMint.saveBlob(file);            // saved as Scores.csv

Файл с вашего сервера, для которого нужен заголовок авторизации, сохранённый под выбранным вами именем:

async function saveStatement(month, token) {
  const res = await fetch('/api/statements/' + month, {
    headers: { Authorization: 'Bearer ' + token }
  });
  if (!res.ok) { alert('Could not fetch the statement (' + res.status + ')'); return; }
  const blob = await res.blob();
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Statement_' + month + '.pdf');
  }
}

Несколько файлов, собранных в один ZIP (здесь с JSZip) и сохранённых за один раз:

async function exportAll(notes) {
  const zip = new JSZip();
  notes.forEach(function (n) { zip.file(n.title + '.txt', n.body); });
  zip.file('index.json', JSON.stringify(notes.map(function (n) { return n.title; })));
  const blob = await zip.generateAsync({ type: 'blob' });
  if (window.AppMint && typeof AppMint.saveBlob === 'function') {
    AppMint.saveBlob(blob, 'Notes_' + new Date().toISOString().slice(0, 10) + '.zip');
  }
}

Примечания: Blob читается в память и отправляется в Android одним куском, поэтому очень большие blob (сотни МБ) могут исчерпать память. Папку выбирает пользователь, и он может отказаться; ничто не сообщает странице, что именно произошло.

AppMint.setDownloadName помощник#

AppMint.setDownloadName(url, fileName)

Пример

Задаёт URL blob: или data: имя файла, под которым его нужно сохранить, до того как ваш код (или библиотека) запустит загрузку.

Возвращает: ничего. Требуется: ничего — он всегда есть в приложении.

Полезно, когда библиотека скачивает URL, который вы создали раньше, а передать ей имя вы не можете:

const blob = new Blob([csvText], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
  AppMint.setDownloadName(url, 'Invoice_0042.csv');
}
location.href = url;           // saved as Invoice_0042.csv, not "download.csv"

PDF, который ваша страница открывает через window.open, попадает в просмотрщик PDF на телефоне; сначала задайте ему имя, чтобы он не назывался «download.pdf»:

function previewPdf(pdfBlob, invoiceNo) {
  const url = URL.createObjectURL(pdfBlob);
  if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
    AppMint.setDownloadName(url, 'Invoice_' + invoiceNo + '.pdf');
  }
  window.open(url, '_blank');
}

URL data: работает так же, например изображение диаграммы, на которое ссылается библиотека без атрибута download:

const dataUrl = chart.toBase64Image();         // e.g. Chart.js: 'data:image/png;base64,…'
if (window.AppMint && typeof AppMint.setDownloadName === 'function') {
  AppMint.setDownloadName(dataUrl, 'Sales_by_month.png');
}
const link = document.getElementById('chart-link');
link.href = dataUrl;                           // a tap on this link saves Sales_by_month.png
link.textContent = 'Save chart';

Примечания: Имя запоминается даже после URL.revokeObjectURL(url), потому что некоторые библиотеки отзывают URL до завершения сохранения. Это редко нужно: <a download="name">, URL.createObjectURL(file) (у File есть имя) и saveAs(blob, name) из FileSaver подхватываются автоматически. Имя 'download' игнорируется как «нет имени».

WebToApkFS.maxChunkSize помощник#

WebToApkFS.maxChunkSize()

Пример

Сообщает, какое максимальное число байт может передать один вызов readBytes или writeBytes (4 МБ), чтобы вы могли рассчитать размер порций в цикле.

Возвращает: (синхронно) число байтов или 0, когда метода моста нет. Требуется: отметьте Native Folder Access (SAF) на шаге 4 (Доступ) при сборке (именно это внедряет WebToApkFS).

function copyFile(fromUri, toPath) {
  if (!window.WebToApkFS) return { ok: false, error: 'not in the app' };
  const step = WebToApkFS.maxChunkSize() || 1048576;   // fall back to 1 MB pieces
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fromUri, offset, step);
    if (!r.ok) return r;
    if (r.bytesRead > 0) {
      const w = WebToApkFS.writeBytes(toPath, r.base64, offset, offset === 0 ? 'truncate' : 'patch');
      if (!w.ok) return w;
    }
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, size: offset };
  }
}

Показывайте ход чтения: размер порции подсказывает, сколько шагов займёт файл:

function readWithProgress(uri, onProgress) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok) return info;
  const step = WebToApkFS.maxChunkSize() || 1048576;
  const steps = Math.max(1, Math.ceil(info.size / step));
  for (let i = 0; i < steps; i++) {
    const r = WebToApkFS.readBytes(uri, i * step, step);
    if (!r.ok) return r;
    onProgress(Math.round(((i + 1) / steps) * 100), r.base64);
  }
  return { ok: true, size: info.size };
}

Добавьте большой массив байт в файл в выбранной папке, по одной допустимой порции за раз:

function appendBytes(path, bytes) {                    // bytes: a Uint8Array
  const step = WebToApkFS.maxChunkSize();
  if (!step) return { ok: false, error: 'not in the app' };
  for (let at = 0; at < bytes.length; at += step) {
    const piece = bytes.subarray(at, at + step);
    let bin = '';
    for (let i = 0; i < piece.length; i += 0x8000) bin += String.fromCharCode.apply(null, piece.subarray(i, i + 0x8000));
    const w = WebToApkFS.writeBytes(path, btoa(bin), 0, 'append');   // 'append' ignores the offset
    if (!w.ok) return w;
  }
  return { ok: true };
}

Примечания: Меньшая порция сохраняет отзывчивость страницы (каждый вызов синхронный); предел лишь говорит, насколько большим может быть один вызов.

WebToApkFS.pickFile помощник#

WebToApkFS.pickFile(mimeFilter)

Пример

Открывает выбор файлов Android, чтобы пользователь выбрал ОДИН файл; затем ваша страница может читать и записывать настоящие байты этого файла, даже после перезапуска приложения.

Возвращает: Promise<{ok, uri, name, size, mime, error}>. При успехе ok равно true, а uri — адрес content://, который вы передаёте в stat, readBytes, writeBytes. При неудаче ok равно false, а error — 'cancelled', 'disabled', 'no-picker', 'busy' (окно выбора уже открыто для более раннего вызова, который сохраняет свой ответ), 'foreign_origin' или 'unsupported' (нет приложения). Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

async function openOneFile() {
  if (!window.WebToApkFS) { alert('Open this page in the app to pick a file.'); return; }
  const f = await WebToApkFS.pickFile('application/pdf');   // '' = any file, 'image/*' = pictures
  if (!f.ok) {
    if (f.error !== 'cancelled') alert('Cannot open a file: ' + f.error);
    return;
  }
  console.log(f.name, f.size + ' bytes', f.mime);
  localStorage.setItem('lastFile', f.uri);   // the grant survives a restart
}

Используйте сохранённый uri снова позже, не спрашивая пользователя второй раз:

const uri = localStorage.getItem('lastFile');
if (uri && window.WebToApkFS) {
  const info = WebToApkFS.stat(uri);
  if (info.ok) console.log('still here:', info.name);
  else console.log('file is gone or access was removed:', info.error);
}

Фильтр принимает ОДИН шаблон, поэтому выбирайте шире и проверяйте имя самостоятельно:

async function importTable() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/*');        // CSV and TSV files are both text/*
  if (!f.ok) {
    if (f.error === 'busy') return;                     // a picker is already open
    if (f.error !== 'cancelled') alert('Cannot open a file: ' + f.error);
    return;
  }
  if (!/\.(csv|tsv)$/i.test(f.name)) {
    WebToApkFS.releaseFile(f.uri);
    alert(f.name + ' is not a CSV or TSV file.');
    return;
  }
  const r = WebToApkFS.readAll(f.uri);
  if (r.ok) showTable(atob(r.base64), f.name.toLowerCase().endsWith('.tsv') ? '\t' : ',');
  WebToApkFS.releaseFile(f.uri);
}

Примечания: Фильтр — это один шаблон MIME-типа ('image/*', 'text/csv'), а не список. Окно выбора запрашивает доступ на чтение И запись и сохраняет его. Android ограничивает число файлов, к которым приложение может так сохранять доступ, поэтому вызывайте WebToApkFS.releaseFile(uri), когда закончите работу с файлом. Для целой папки используйте вместо этого WebToApkFS.requestAccess(). WebToApkFS устанавливается, когда отмечены Native Folder Access или Secure Keys; его вызовы работают только с Native Folder Access (WebToApkFS.isAvailable()).

WebToApkFS.readAll помощник#

WebToApkFS.readAll(pathOrUri)

Пример

Читает весь файл в одну строку base64. Цикл по readBytes выполняется за вас, по 4 МБ за раз.

Возвращает: (синхронно) {ok:true, base64, size} или {ok:false, error} первой неудавшейся порции. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Загрузите выбранное изображение в <img>:

async function showPicture() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('image/*');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  document.getElementById('photo').src = 'data:' + f.mime + ';base64,' + r.base64;
}

Прочитайте текстовый файл в UTF-8 (работает для любого языка, не только английского):

function readUtf8(pathOrUri) {
  const r = WebToApkFS.readAll(pathOrUri);
  if (!r.ok) throw new Error(r.error);
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  return new TextDecoder('utf-8').decode(bytes);
}

Покажите контрольную сумму SHA-256 файла, например чтобы сравнить скачанный файл с опубликованным:

async function checksum() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
  const hex = Array.from(hash, function (b) { return b.toString(16).padStart(2, '0'); }).join('');
  console.log(f.name, r.size + ' bytes', hex);
}

Примечания: Весь файл оказывается в памяти (сначала как байты, затем как base64), а вызов блокирует страницу на время работы. Используйте его для файлов, которые легко помещаются в память; для больших файлов повторяйте WebToApkFS.readBytes и обрабатывайте каждую порцию. Для обычного текста внутри выбранной папки проще WebToApkFS.readText(path).

WebToApkFS.readBytes помощник#

WebToApkFS.readBytes(pathOrUri, offset, length)

Пример

Читает настоящие байты файла, начиная с заданной позиции, в base64. Безопасно для изображений, PDF, ZIP — декодирование текста ничего не меняет.

Возвращает: (синхронно) {ok:true, base64, bytesRead, offset, size, eof} или {ok:false, error} ('not-found', 'disabled', 'cannot-open', 'offset past end of file (N bytes)', 'foreign_origin', 'unsupported'). Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Прочитайте первые 64 КБ, например чтобы проверить заголовок файла:

async function checkPng() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('image/*');
  if (!f.ok) return;
  const r = WebToApkFS.readBytes(f.uri, 0, 65536);
  if (!r.ok) { alert('Read failed: ' + r.error); return; }
  const head = atob(r.base64).slice(1, 4);
  console.log(head === 'PNG' ? 'a PNG' : 'not a PNG', '- file is', r.size, 'bytes');
}

Читайте большой файл по частям, чтобы он не помещался в память целиком:

function eachChunk(uri, onChunk) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(uri, offset, step);
    if (!r.ok) return r;                 // stop on the first error
    onChunk(r.base64, offset);
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, size: offset };
  }
}

Прочитайте только конец файла, например 128-байтовый тег в конце MP3:

function readMp3Title(uri) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok || info.size < 128) return null;
  const r = WebToApkFS.readBytes(uri, info.size - 128, 128);
  if (!r.ok) return null;
  const tail = atob(r.base64);
  if (tail.slice(0, 3) !== 'TAG') return null;          // no old-style tag in this file
  return tail.slice(3, 33).replace(/\0+$/, '').trim();  // bytes 3-32 hold the title
}

Примечания: Один вызов читает не более WebToApkFS.maxChunkSize() байт (4 МБ); length 0 означает «столько, сколько позволяет один вызов». Повторяйте по bytesRead и остановитесь на eof. Вызов синхронный, поэтому в UI-потоке порции должны быть умеренными. Для небольших файлов цикл за вас выполняет WebToApkFS.readAll(uri).

WebToApkFS.readSealed помощник#

WebToApkFS.readSealed(pathOrUri, alias)

Пример

Читает файл, записанный WebToApkFS.writeSealed, и расшифровывает его тем же ключом из хранилища.

Возвращает: (синхронно) {ok:true, base64} (исходные байты) или {ok:false, error} — 'keystore-disabled', 'corrupt-sealed-file', код хранилища ключей ('no-such-key' после удаления ключа, 'auth-required', 'key-invalidated') или ошибку файла ('not-found' …). Требуется: отметьте Native Folder Access (SAF) и Secure Keys (encryption) на шаге 4 (Доступ) при сборке.

function loadSecret() {
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) return null;
  const out = WebToApkFS.readSealed('secret.bin', 'vault');
  if (out.ok) {
    const bytes = Uint8Array.from(atob(out.base64), function (c) { return c.charCodeAt(0); });
    return new TextDecoder().decode(bytes);
  }
  if (out.error === 'auth-required') {
    alert('Unlock your phone, then try again.');
  } else if (out.error === 'no-such-key') {
    alert('This note was deleted.');
  } else {
    alert('Could not open the note: ' + out.error);
  }
  return null;
}

Храните настройки приложения в запечатанном файле JSON, с значениями по умолчанию до первого сохранения:

function loadSettings() {
  const defaults = { theme: 'light', pinHint: '' };
  const out = WebToApkFS.readSealed('settings.sealed', 'settings');
  if (!out.ok) {
    if (out.error === 'not-found') return defaults;      // nothing saved yet
    throw new Error('Settings could not be opened: ' + out.error);
  }
  const bytes = Uint8Array.from(atob(out.base64), function (c) { return c.charCodeAt(0); });
  return Object.assign(defaults, JSON.parse(new TextDecoder().decode(bytes)));
}

Перенесите запечатанный файл на новый ключ, затем уничтожьте старый ключ:

function rekey(path, oldAlias, newAlias) {
  const plain = WebToApkFS.readSealed(path, oldAlias);
  if (!plain.ok) return plain;
  if (!AppMintKeystore.hasKey(newAlias)) {
    const k = AppMintKeystore.generateKey(newAlias, {});
    if (!k.ok) return k;
  }
  const w = WebToApkFS.writeSealed(path, plain.base64, newAlias);
  if (!w.ok) return w;
  AppMintKeystore.deleteKey(oldAlias);                  // only after the new copy is written
  return { ok: true };
}

Примечания: После выполнения AppMintKeystore.deleteKey(alias) он навсегда перестаёт работать — в этом и смысл. Весь файл читается и расшифровывается в памяти синхронно; держите запечатанные файлы небольшими (заметки, ключи, токены).

WebToApkFS.releaseFile помощник#

WebToApkFS.releaseFile(uri)

Пример

Возвращает долгосрочный доступ, который приложение получило, когда пользователь выбрал файл через WebToApkFS.pickFile.

Возвращает: (синхронно) true, когда разрешение снято, иначе false (нет разрешения, неверный URI, не в приложении). Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

async function importOnce() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/csv');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);
  if (r.ok) importCsv(atob(r.base64));
  WebToApkFS.releaseFile(f.uri);       // done with it: do not keep the grant
}

Когда пользователь выбирает новый файл по умолчанию, верните разрешение на старый:

async function chooseDefaultFile() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const old = localStorage.getItem('defaultFile');
  if (old && old !== f.uri) WebToApkFS.releaseFile(old);
  localStorage.setItem('defaultFile', f.uri);
}

Кнопка «Забыть недавние файлы», которая снимает разрешения на все файлы, запомненные приложением:

function forgetRecentFiles() {
  const recent = JSON.parse(localStorage.getItem('recentFiles') || '[]');
  let released = 0;
  for (const uri of recent) {
    if (WebToApkFS.releaseFile(uri)) released++;       // false: already gone or never granted
  }
  localStorage.removeItem('recentFiles');
  console.log('released', released, 'of', recent.length);
}

Примечания: Android ограничивает число выбранных файлов, к которым одно приложение может сохранять доступ. Приложение, которое выбирает много файлов (импортёр, просмотрщик), должно снимать разрешение с каждого по завершении. После снятия stat / readBytes / writeBytes для этого URI отвечают not-found. Файл при этом не удаляется.

WebToApkFS.stat помощник#

WebToApkFS.stat(pathOrUri)

Пример

Сообщает имя, размер, тип и дату файла, не читая его содержимое.

Возвращает: (синхронно) {ok:true, name, size, mime, lastModified, uri, isDirectory, canWrite} или {ok:false, error}, где error = 'not-found', 'disabled', 'foreign_origin' или 'unsupported'. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

async function showInfo() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('');
  if (!f.ok) return;
  const info = WebToApkFS.stat(f.uri);
  if (!info.ok) { alert('Cannot read file info: ' + info.error); return; }
  console.log(info.name, info.size + ' bytes', info.mime,
              new Date(info.lastModified).toLocaleString(),
              info.canWrite ? 'writable' : 'read only');
}

Путь внутри папки, которую пользователь предоставил через WebToApkFS.requestAccess(), тоже работает:

const info = WebToApkFS.stat('photos/cover.jpg');
if (info.ok && !info.isDirectory) console.log('cover is', info.size, 'bytes');

Повторно импортируйте запомненный файл, только если он изменился с прошлого импорта:

function importIfChanged(uri) {
  const info = WebToApkFS.stat(uri);
  if (!info.ok) return 'gone: ' + info.error;
  const seen = JSON.parse(localStorage.getItem('lastImport') || '{}');
  if (seen.size === info.size && seen.lastModified === info.lastModified) return 'unchanged';
  const r = WebToApkFS.readAll(uri);
  if (!r.ok) return 'read failed: ' + r.error;
  importData(atob(r.base64));
  localStorage.setItem('lastImport', JSON.stringify({ size: info.size, lastModified: info.lastModified }));
  return 'imported';
}

Примечания: pathOrUri — это URI вида content:// из pickFile() или путь относительно папки, к которой дан доступ. URI, который приложению не выдавался, возвращает not-found. lastModified — это миллисекунды с 1970 года (0, если провайдер их не сообщает).

WebToApkFS.writeAll помощник#

WebToApkFS.writeAll(pathOrUri, base64)

Пример

Заменяет всё содержимое файла вашими байтами. Данные сами делятся на порции по 4 МБ: первая порция усекает файл, остальные дописываются после неё.

Возвращает: (синхронно) {ok:true, bytesWritten} или {ok:false, error} первой неудавшейся части. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Сохраните текст в UTF-8 (любого языка) обратно в выбранный пользователем файл:

function bytesToBase64(bytes) {
  let s = '';
  for (let i = 0; i < bytes.length; i += 0x8000) {
    s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  }
  return btoa(s);
}

async function saveNote(text) {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/plain');
  if (!f.ok) return;
  const r = WebToApkFS.writeAll(f.uri, bytesToBase64(new TextEncoder().encode(text)));
  if (r.ok) console.log('saved', r.bytesWritten, 'bytes');
  else alert('Could not save: ' + r.error);
}

Запишите изображение с canvas в выбранную папку (файл создаётся, если его нет):

const b64 = document.querySelector('canvas').toDataURL('image/png').split(',')[1];
const r = WebToApkFS.writeAll('drawings/sketch.png', b64);
if (!r.ok) alert('Could not save: ' + r.error);

Сохраните резервную копию JSON в предоставленную папку, сначала запросив папку, если её нет, затем подтвердите её размер на диске:

async function backup(data) {
  if (!window.WebToApkFS || !WebToApkFS.isAvailable()) return;
  if (!WebToApkFS.hasAccess()) {
    const a = await WebToApkFS.requestAccess();
    if (!a.success) return;
  }
  const bytes = new TextEncoder().encode(JSON.stringify(data));
  let bin = '';
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  const r = WebToApkFS.writeAll('backup.json', btoa(bin));
  if (!r.ok) { alert('Backup failed: ' + r.error); return; }
  const info = WebToApkFS.stat('backup.json');
  console.log('backup is', info.ok ? info.size : '?', 'bytes');
}

Примечания: Пока данные записываются, весь ввод находится в памяти, а вызов блокирует страницу. Если последующая часть не записалась, в файле уже лежат предыдущие части — проверьте ok и сообщите пользователю. Чтобы сохранить файл в тайне, используйте WebToApkFS.writeSealed.

WebToApkFS.writeBytes помощник#

WebToApkFS.writeBytes(pathOrUri, base64, offset, mode)

Пример

Записывает настоящие байты в файл и сообщает, сколько записано. Может заменить байты в середине, перезаписать весь файл или добавить в конец.

Возвращает: (синхронно) {ok, bytesWritten, offset, size} — size перечитывается из файла после сброса данных на диск — или {ok:false, error}. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Patch — изменить 4 байта в позиции 100, остальное содержимое файла сохранить:

const r = WebToApkFS.writeBytes(fileUri, btoa('ABCD'), 100, 'patch');
if (r.ok) console.log('file is now', r.size, 'bytes');
else alert('Write failed: ' + r.error);

Truncate — заменить весь файл (смещение игнорируется):

const csv = 'name,score\nAsha,12\n';
const r = WebToApkFS.writeBytes('exports/scores.csv', btoa(csv), 0, 'truncate');
if (!r.ok) alert('Could not save: ' + r.error);

Append — добавить в конец (смещение игнорируется):

const line = new Date().toISOString() + ' opened\n';
WebToApkFS.writeBytes('log.txt', btoa(line), 0, 'append');

Примечания: mode — это 'patch' (по умолчанию), 'truncate' или 'append'. Не более 4 МБ за вызов (WebToApkFS.maxChunkSize()); для больших данных используйте WebToApkFS.writeAll. Путь внутри выбранной папки создаётся, если его нет; выбранный URI content:// должен быть таким, который выбрал пользователь. btoa принимает только текст Latin-1 — для другого текста сначала закодируйте в байты (см. WebToApkFS.writeAll). Ошибки: disabled, foreign_origin (страница на экране не принадлежит приложению), bad mode: x, bad base64, chunk too large (max N bytes), not-found, cannot-open, short write.

WebToApkFS.writeSealed помощник#

WebToApkFS.writeSealed(pathOrUri, base64, alias)

Пример

Записывает файл, зашифрованный аппаратным ключом из AppMintKeystore. Если позже удалить этот ключ, файл станет нечитаемым для всех — единственное настоящее «безопасное удаление» на телефоне.

Возвращает: (синхронно) {ok:true, bytesWritten} (размер зашифрованного файла на диске) или {ok:false, error} — 'keystore-disabled', код хранилища ключей вроде 'no-such-key', 'auth-required', 'key-invalidated' или ошибку файла. Требуется: при сборке отметьте Native Folder Access (SAF) и Secure Keys (encryption) на шаге 4 (Доступ).

function saveSecret(text) {
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) {
    alert('Secure storage is not available in this build.');
    return false;
  }
  if (!AppMintKeystore.hasKey('vault')) {
    const k = AppMintKeystore.generateKey('vault', {});
    if (!k.ok) { alert('Could not create key: ' + k.error); return false; }
  }
  const bytes = new TextEncoder().encode(text);
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  const r = WebToApkFS.writeSealed('secret.bin', btoa(bin), 'vault');
  if (!r.ok) alert('Could not save: ' + r.error);
  return r.ok;
}

Удалите по-настоящему: уничтожьте ключ, затем удалите файл:

AppMintKeystore.deleteKey('vault');   // secret.bin can never be decrypted again
WebToApkFS.delete('secret.bin');

Сохраните выбранное пользователем фото как необработанные байты в запечатанном виде в папку, к которой он дал доступ:

async function sealPhoto(file) {                  // a File from <input type="file" accept="image/*">
  if (!window.WebToApkFS || !window.AppMintKeystore || !AppMintKeystore.isAvailable()) return false;
  if (!WebToApkFS.hasAccess()) {
    const g = await WebToApkFS.requestAccess();
    if (!g.success) return false;
  }
  if (!AppMintKeystore.hasKey('vault')) AppMintKeystore.generateKey('vault', {});
  const dataUrl = await new Promise(function (resolve, reject) {
    const fr = new FileReader();
    fr.onload = function () { resolve(fr.result); };
    fr.onerror = reject;
    fr.readAsDataURL(file);
  });
  const b64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
  const r = WebToApkFS.writeSealed('photo-' + Date.now() + '.sealed', b64, 'vault');
  if (!r.ok) alert('Not saved: ' + r.error);
  return r.ok;
}

Ключ для дневника, который работает только 5 минут после разблокировки телефона, с пояснением каждой ошибки:

if (!AppMintKeystore.hasKey('diary')) {
  AppMintKeystore.generateKey('diary', { requireAuth: true, authValiditySeconds: 300 });
}

function saveDiary(entry) {
  const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(entry))));   // UTF-8 safe
  const r = WebToApkFS.writeSealed('diary.bin', b64, 'diary');
  if (r.ok) return true;
  if (r.error === 'auth-required') showMessage('Unlock your phone again, then tap Save.');
  else if (r.error === 'key-invalidated') showMessage('Your screen lock changed, so this key no longer works.');
  else showMessage('Could not save: ' + r.error);
  return false;
}

Примечания: Не «перезаписывайте нулями» для стирания: флеш-память телефона сохраняет старые блоки. Формат файла — список AES-GCM-блоков с префиксом длины; читайте его обратно только через WebToApkFS.readSealed и с тем же alias. Ключ, созданный с {requireAuth:true}, отвечает auth-required, пока пользователь недавно не разблокировал телефон.

downloads веб-стандарт#

<a download> and Blob downloads

Пример

Ваш обычный веб-код скачивания работает в приложении так же, как в Chrome. WebView сам игнорирует загрузки blob: и data:; приложение перехватывает их и сохраняет файл в папку Загрузки телефона под вашим именем файла, затем показывает «Скачано <имя> · Открыть» и уведомление «Загрузка завершена».

Возвращает: ничего странице. Требуется: ничего включать не нужно. Загрузки по веб-адресам (https://…) тоже попадают в папку «Загрузки» (для PDF или изображения сначала спрашивается «Открыть или скачать»), как и файлы, упакованные внутри приложения (<a href="manual.pdf" download>); на Android 9 и старше приложение сначала запрашивает разрешение на доступ к памяти. Большие файлы не проблема: файл передаётся в приложение по частям.

<a download> с Blob — самый частый случай:

<button id="save">Download report</button>
<script>
document.getElementById('save').addEventListener('click', function () {
  const blob = new Blob(['Name,Total\nAsha,120\n'], { type: 'text/csv' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'Report_2026-08.csv';     // this is the name the user sees
  document.body.appendChild(a);
  a.click();                             // caught by the app, saved to Downloads
  a.remove();
});
</script>

URL data:, FileSaver.js и navigator.msSaveBlob тоже работают:

const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'Drawing.png';
a.click();

if (typeof saveAs === 'function') saveAs(blob, 'Backup.json');   // FileSaver.js
if (navigator.msSaveBlob) navigator.msSaveBlob(blob, 'Backup.json');

window.open URL blob: или data: означает «показать», а не «сохранить»: PDF открывается в просмотрщике PDF приложения, другие типы — в приложении телефона для этого типа:

const pdfUrl = URL.createObjectURL(pdfBlob);
const w = window.open(pdfUrl);    // never null in the app, so no "popup blocked" message

Примечания: Без имени файл сохраняется как download.<ext>; второй файл с тем же именем получает имя name (1).ext. Объект File приносит своё имя (URL.createObjectURL(file)). <a href="/files/x" download="Name.pdf"> на вашем сайте сохраняет Name.pdf (Chrome игнорирует этот атрибут на ссылках чужих сайтов, и приложение тоже). Если на телефоне нет приложения, способного показать файл window.open, он сохраняется в «Загрузки». Чтобы пользователь сам выбрал папку, используйте showSaveFilePicker(). Чтобы сохранять без элемента <a>, используйте AppMint.saveBlob(blob, name) или AppMint.downloadFile(base64, name, mime).

input-file веб-стандарт#

<input type="file"> — camera, photos and files

Пример

Обычное поле выбора файла в приложении работает как в Chrome: оно открывает выбор фото, камеру или выбор файлов, а ваша страница получает обычные объекты File.

Возвращает: событие change поля с input.files (пусто, если пользователь отменил). Требуется: для фото и файлов ничего не нужно. Для capture (камера) при сборке отметьте Camera на шаге 4 (Доступ); при первом использовании приложение запросит разрешение на камеру.

Фото — открывает выбор фото Android (без запроса разрешения; страница получает только то, что выбрал пользователь):

<input type="file" id="pics" accept="image/*" multiple>
<div id="preview"></div>
<script>
document.getElementById('pics').addEventListener('change', function (e) {
  const box = document.getElementById('preview');
  for (let i = 0; i < e.target.files.length; i++) {
    const img = document.createElement('img');
    img.src = URL.createObjectURL(e.target.files[i]);
    img.style.width = '100px';
    box.appendChild(img);
  }
});
</script>

Камера — capture пропускает окно выбора и сразу открывает камеру:

<input type="file" id="snap" accept="image/*" capture="environment">
<input type="file" id="clip" accept="video/*" capture="environment">
<script>
document.getElementById('snap').addEventListener('change', function (e) {
  const photo = e.target.files[0];
  if (!photo) return;                        // user closed the camera
  const form = new FormData();
  form.append('photo', photo);
  fetch('/api/upload', { method: 'POST', body: form });
});
</script>

Любой файл или несколько типов (расширения тоже подходят):

<input type="file" accept=".pdf,.csv,application/json" multiple>
Вы пишетеЧто открывается
accept="image/*" / "video/*" / "image/*,video/*"выбор фото (фото, видео или то и другое)
добавить multipleпользователь может выбрать несколько
добавьте capture (с отмеченным Camera)камера напрямую — фото для image/*, видео для video/*
accept="image/*,.pdf" или нет acceptсистемный выбор файлов

Примечания: Если Camera не отмечена, capture игнорируется и открывается окно выбора. Если отмечен Native Folder Access (SAF), приложение показывает своё меню «Select Upload Source»: Take Photo / Record Video (если отмечена Camera), Choose Files и Upload Folder (отправляет все файлы папки). Если на Android 12 и старше отмечен Storage, при выборе не-фото файла сначала может запроситься разрешение на память. На телефонах без выбора фото открывается выбор файлов, который показывает те же фото.

showOpenFilePicker веб-стандарт#

showOpenFilePicker / showSaveFilePicker / showDirectoryPicker — File System Access

Пример

Стандартный File System Access API работает внутри приложения поверх системного выбора документов Android: откройте файл и сохраните обратно В ЭТОТ ЖЕ ФАЙЛ, создайте файл через экран «Сохранить как» или получите целую папку, в которой можно читать, создавать и удалять файлы.

Возвращает: showOpenFilePicker(options) → Promise с массивом FileSystemFileHandle; showSaveFilePicker(options) → один FileSystemFileHandle; showDirectoryPicker(options) → один FileSystemDirectoryHandle. Если выбор отменён, отклоняется с AbortError; без действия пользователя — с SecurityError; второй выбор при открытом первом — с NotAllowedError. Требуется: ничего включать не нужно и разрешения не требуются — файл выбирает пользователь.

let handle = null;

document.getElementById('open').addEventListener('click', async () => {
  try {
    [handle] = await showOpenFilePicker({
      types: [{ description: 'Text', accept: { 'text/plain': ['.txt', '.md'] } }]
    });
    const file = await handle.getFile();
    editor.value = await file.text();
    title.textContent = file.name;
  } catch (e) {
    if (e.name !== 'AbortError') alert('Could not open: ' + e.message);
  }
});

document.getElementById('save').addEventListener('click', async () => {
  if (!handle) handle = await showSaveFilePicker({ suggestedName: 'note.txt' });
  const w = await handle.createWritable();   // writes go to a copy…
  await w.write(editor.value);
  await w.close();                           // …that replaces the file only here
});

document.getElementById('export').addEventListener('click', async () => {
  const dir = await showDirectoryPicker({ mode: 'readwrite' });
  const out = await dir.getFileHandle('export.csv', { create: true });
  const w = await out.createWritable();
  await w.write(csvText);
  await w.close();
  for await (const [name, entry] of dir.entries()) console.log(entry.kind, name);
});

Выберите сразу несколько фото, начиная с папки «Изображения»:

document.getElementById('add-photos').addEventListener('click', async () => {
  try {
    const handles = await showOpenFilePicker({
      multiple: true,
      startIn: 'pictures',
      types: [{ description: 'Images', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
    });
    for (const h of handles) {
      const img = document.createElement('img');
      img.src = URL.createObjectURL(await h.getFile());
      gallery.appendChild(img);
    }
  } catch (e) {
    if (e.name !== 'AbortError') alert('Could not open: ' + e.message);
  }
});

Добавьте строку в конец файла журнала, не переписывая то, что в нём уже есть:

async function appendLine(handle, line) {      // handle from showOpenFilePicker / showSaveFilePicker
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);
  await w.write(line + '\n');
  await w.close();
}

Выберите папку для резервных копий ОДИН раз и затем сохраняйте в неё автоматически при каждом запуске: храните handle в IndexedDB, как в Chrome. Приложение сохраняет разрешение на папку между перезапусками, поэтому queryPermission() отвечает 'granted', и для резервной копии не нужны ни нажатие, ни выбор папки:

// Tiny IndexedDB helpers (idb-keyval's get/set work the same way).
function db() {
  return new Promise((ok, fail) => {
    const r = indexedDB.open('backup-settings', 1);
    r.onupgradeneeded = () => r.result.createObjectStore('kv');
    r.onsuccess = () => ok(r.result);
    r.onerror = () => fail(r.error);
  });
}
async function kvGet(key) {
  const d = await db();
  return new Promise((ok, fail) => {
    const r = d.transaction('kv').objectStore('kv').get(key);
    r.onsuccess = () => ok(r.result);
    r.onerror = () => fail(r.error);
  });
}
async function kvSet(key, value) {
  const d = await db();
  return new Promise((ok, fail) => {
    const t = d.transaction('kv', 'readwrite');
    t.objectStore('kv').put(value, key);
    t.oncomplete = () => ok();
    t.onerror = () => fail(t.error);
  });
}

// "Choose backup folder" button (the picker needs a tap).
document.getElementById('choose-folder').addEventListener('click', async () => {
  try {
    const dir = await showDirectoryPicker({ mode: 'readwrite', startIn: 'documents' });
    await kvSet('backupDir', dir);
    status.textContent = 'Backups go to ' + dir.name;
  } catch (e) {
    if (e.name !== 'AbortError') status.textContent = 'Could not choose: ' + e.message;
  }
});

// Called after every workout, and on a timer: no tap needed once the folder is chosen.
async function autoBackup(data) {
  const dir = await kvGet('backupDir');
  if (!dir) return 'no folder chosen yet';
  if ((await dir.queryPermission({ mode: 'readwrite' })) !== 'granted') {
    return 'folder access was removed: tap "Choose backup folder" again';
  }
  const file = await dir.getFileHandle('backup-' + new Date().toISOString().slice(0, 10) + '.json', { create: true });
  const w = await file.createWritable();
  await w.write(JSON.stringify(data));
  await w.close();
  return 'saved ' + file.name;
}

Если разрешение снято (пользователь сбросил доступ приложения или удалил папку), queryPermission() отвечает 'prompt'. По нажатию кнопки requestPermission() снова открывает окно выбора папки ИМЕННО в этой папке; если выбрать ту же папку, ответ будет 'granted', любая другая (или отмена) 'denied':

document.getElementById('reconnect').addEventListener('click', async () => {
  const dir = await kvGet('backupDir');
  if (dir && (await dir.requestPermission({ mode: 'readwrite' })) === 'granted') {
    status.textContent = 'Backups go to ' + dir.name + ' again';
  } else {
    status.textContent = 'Choose a backup folder';
  }
});

Очистите выбранную пользователем папку: удалите старые файлы .tmp и вложенные папки с именем cache:

document.getElementById('clean').addEventListener('click', async () => {
  const dir = await showDirectoryPicker({ mode: 'readwrite' });
  let removed = 0;
  for await (const entry of dir.values()) {
    if (entry.kind === 'file' && entry.name.endsWith('.tmp')) {
      await dir.removeEntry(entry.name);
      removed++;
    } else if (entry.kind === 'directory' && entry.name === 'cache') {
      await dir.removeEntry(entry.name, { recursive: true });
      removed++;
    }
  }
  alert(removed + ' item(s) removed');
});

Примечания: createWritable() следует модели подменного файла из спецификации: abort() (или закрытие страницы) оставляет оригинал нетронутым; { keepExistingData: true } начинает с текущего содержимого; write({type:'seek'|'truncate'|'write', …}), seek() и truncate() работают. Файл, который его приложение отдаёт только для чтения, отвечает queryPermission({mode:'readwrite'}) с 'denied', а createWritable() отклоняется с NoModificationAllowedError. getFile() передаёт документ потоком, поэтому большие файлы не проблема. Handle можно хранить в IndexedDB (в том числе внутри объектов и массивов) и использовать снова после перезапуска приложения: приложение сохраняет разрешение Android для каждой папки и файла, возвращённых окном выбора (последние 64; самое старое снимается первым). queryPermission() отвечает 'granted', пока разрешение действует, и 'prompt', когда его нет; тогда requestPermission() требует нажатия и снова открывает окно выбора в папке самого handle. В приложениях, собранных до 2026-10-03, handle терялся при перезапуске — пересоберите, и пользователь снова выберет папку один раз. types становятся фильтром файлов в окне выбора. Окна выбора есть только у страницы верхнего уровня. Внутренний транспорт: __fsPick (ответ приходит событием appmint:fs), __fsFile, __fsList, __fsChild, __fsRemove, __fsPermission, __fsWritableOpen/Write/Truncate/Close/Abort. Приложения, созданные ИИ, проверяют can('fileHandles').

__dlAbort мост#

window.WebToApk.__dlAbort(token: String)

Отбрасывает загрузку [token] (чтение страницы не удалось или она сдалась).

Пример

Описано вместе с WebToApk.__dlBegin — пример там показывает и этот.

Внутренний транспорт для <a download>, загрузок Blob, window.open(blobUrl) и AppMint.saveBlob / AppMint.downloadFile: файл передаётся со страницы в приложение частями, поэтому большой экспорт никогда не лежит в памяти одной гигантской строкой. Страницы используют стандартный код загрузки; никогда не вызывайте их напрямую, так как методы __ могут измениться без предупреждения.

Возвращает: __dlBegin(name, type, size) возвращает токен (или "", если на телефоне нет места); __dlChunk(token, base64) возвращает false, если срез не удалось записать; __dlEnd(token, action) завершает (save = «Загрузки», open = показать, share, copy). Требуется: ничего.

Используйте публичный API:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Экспортируйте рисунок с canvas тем же способом:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Сохраните резервную копию JSON, которую пользователь может оставить себе, с сообщением по завершении:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Примечания: Реализовано в оболочке приложения (ShellDownloads.kt).

__dlBegin мост#

window.WebToApk.__dlBegin(name: String, type: String, size: Double): String

Открывает загрузку размером [size] байт с именем [name] ([type] — её MIME-тип), которую страница будет передавать через __dlChunk. Возвращает токен или "", если места нет.

Пример

Внутренний транспорт для <a download>, загрузок Blob, window.open(blobUrl) и AppMint.saveBlob / AppMint.downloadFile: файл передаётся со страницы в приложение частями, поэтому большой экспорт никогда не лежит в памяти одной гигантской строкой. Страницы используют стандартный код загрузки; никогда не вызывайте их напрямую, так как методы __ могут измениться без предупреждения.

Возвращает: __dlBegin(name, type, size) возвращает токен (или "", если на телефоне нет места); __dlChunk(token, base64) возвращает false, если срез не удалось записать; __dlEnd(token, action) завершает (save = «Загрузки», open = показать, share, copy). Требуется: ничего.

Используйте публичный API:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Экспортируйте рисунок с canvas тем же способом:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Сохраните резервную копию JSON, которую пользователь может оставить себе, с сообщением по завершении:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Примечания: Реализовано в оболочке приложения (ShellDownloads.kt).

__dlChunk мост#

window.WebToApk.__dlChunk(token: String, base64: String): Boolean

Один срез загрузки [token] в base64; false, если передача не удалась.

Пример

Описано вместе с WebToApk.__dlBegin — пример там показывает и этот.

Внутренний транспорт для <a download>, загрузок Blob, window.open(blobUrl) и AppMint.saveBlob / AppMint.downloadFile: файл передаётся со страницы в приложение частями, поэтому большой экспорт никогда не лежит в памяти одной гигантской строкой. Страницы используют стандартный код загрузки; никогда не вызывайте их напрямую, так как методы __ могут измениться без предупреждения.

Возвращает: __dlBegin(name, type, size) возвращает токен (или "", если на телефоне нет места); __dlChunk(token, base64) возвращает false, если срез не удалось записать; __dlEnd(token, action) завершает (save = «Загрузки», open = показать, share, copy). Требуется: ничего.

Используйте публичный API:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Экспортируйте рисунок с canvas тем же способом:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Сохраните резервную копию JSON, которую пользователь может оставить себе, с сообщением по завершении:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Примечания: Реализовано в оболочке приложения (ShellDownloads.kt).

__dlEnd мост#

window.WebToApk.__dlEnd(token: String, action: String): Boolean

Завершает загрузку [token] и выполняет действие [action]: «save» (в «Загрузки», с панелью «Загружено · Открыть» и уведомлением), «open» (просмотр PDF или приложение телефона для этого типа), «share» (меню «Поделиться») или «copy» (изображение в буфер обмена).

Пример

Описано вместе с WebToApk.__dlBegin — пример там показывает и этот.

Внутренний транспорт для <a download>, загрузок Blob, window.open(blobUrl) и AppMint.saveBlob / AppMint.downloadFile: файл передаётся со страницы в приложение частями, поэтому большой экспорт никогда не лежит в памяти одной гигантской строкой. Страницы используют стандартный код загрузки; никогда не вызывайте их напрямую, так как методы __ могут измениться без предупреждения.

Возвращает: __dlBegin(name, type, size) возвращает токен (или "", если на телефоне нет места); __dlChunk(token, base64) возвращает false, если срез не удалось записать; __dlEnd(token, action) завершает (save = «Загрузки», open = показать, share, copy). Требуется: ничего.

Используйте публичный API:

const blob = new Blob([csvText], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'Report.csv';
a.click();   // lands in Downloads as "Report.csv"

Экспортируйте рисунок с canvas тем же способом:

canvas.toBlob(function (blob) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'drawing.png';
  a.click();
  setTimeout(function () { URL.revokeObjectURL(a.href); }, 10000);
}, 'image/png');

Сохраните резервную копию JSON, которую пользователь может оставить себе, с сообщением по завершении:

function exportBackup(data) {
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'backup-' + new Date().toISOString().slice(0, 10) + '.json';
  document.body.appendChild(a);
  a.click();
  a.remove();
  showMessage('Backup saved to Downloads.');
}

Примечания: Реализовано в оболочке приложения (ShellDownloads.kt).

__fsChild мост#

window.WebToApk.__fsChild(uri: String, name: String, kind: String, create: Boolean): String

getFileHandle / getDirectoryHandle (с `create`).

Пример

Внутренний транспорт для getFileHandle(name, {create}) и getDirectoryHandle(name, {create}). Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok, kind, name, uri, canWrite} или {ok:false, error} (NotFoundError, TypeMismatchError, NoModificationAllowedError). Требуется: папка из showDirectoryPicker().

Используйте публичный API:

const dir = await showDirectoryPicker();
const photos = await dir.getDirectoryHandle('photos', { create: true });
const file = await photos.getFileHandle('list.json', { create: true });

Проверьте, существует ли файл в папке, прежде чем создавать его:

async function exists(dir, name) {
  try {
    await dir.getFileHandle(name);                     // no {create} = look up only
    return true;
  } catch (e) {
    if (e.name === 'NotFoundError') return false;
    if (e.name === 'TypeMismatchError') return true;   // a folder already has that name
    throw e;
  }
}

Сохраните настройки приложения как JSON во вложенную папку «config», создавая обе при необходимости:

async function saveSettings(root, settings) {
  const config = await root.getDirectoryHandle('config', { create: true });
  const file = await config.getFileHandle('settings.json', { create: true });
  const w = await file.createWritable();
  await w.write(JSON.stringify(settings, null, 2));
  await w.close();
}
// root = await showDirectoryPicker()  (from a button tap)

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsFile мост#

window.WebToApk.__fsFile(uri: String): String

getFile(): `{ok, name, type, size, lastModified, url}` (тело передаётся потоком с url).

Пример

Внутренний транспорт для FileSystemFileHandle.getFile(). Страницы используют стандартный API; никогда не вызывайте это напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok, name, type, size, lastModified, url} — содержимое передаётся потоком с url (собственный origin приложения, с поддержкой диапазонов) — или {ok:false, error} (NotFoundError, TypeMismatchError, foreign_origin). Требуется: handle от одного из окон выбора.

Используйте публичный API:

const [handle] = await showOpenFilePicker();
const file = await handle.getFile();
console.log(file.name, file.size, await file.text());

Покажите выбранное изображение на странице, не читая его в строку:

document.querySelector('#choose').addEventListener('click', async function () {
  const [handle] = await showOpenFilePicker({ types: [{ accept: { 'image/*': [] } }] });
  const file = await handle.getFile();                    // a real File, streamed from the app
  const img = document.querySelector('#preview');
  if (img.src) URL.revokeObjectURL(img.src);
  img.src = URL.createObjectURL(file);
  img.alt = file.name + ' (' + file.type + ')';
});

Перечитайте открытый ранее файл и заметьте, когда он изменился или был удалён:

async function reloadIfChanged(handle, lastSeen) {
  try {
    const file = await handle.getFile();
    if (file.lastModified === lastSeen) return null;      // unchanged
    return { data: JSON.parse(await file.text()), lastModified: file.lastModified };
  } catch (e) {
    if (e.name === 'NotFoundError') alert(handle.name + ' was moved or deleted.');
    else throw e;
    return null;
  }
}

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsList мост#

window.WebToApk.__fsList(uri: String): String

Содержимое handle папки: `{ok, entries:[{kind, name, uri, canWrite}]}`.

Пример

Внутренний транспорт для перебора FileSystemDirectoryHandle (entries(), keys(), values(), for await). Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok, entries:[{kind, name, uri, canWrite}]} или {ok:false, error}. Требуется: папка из showDirectoryPicker().

Используйте публичный API:

const dir = await showDirectoryPicker();
for await (const [name, entry] of dir.entries()) console.log(entry.kind, name);

Подсчитайте файлы в папке и всех её вложенных папках:

async function countFiles(dir) {
  let n = 0;
  for await (const entry of dir.values()) {
    if (entry.kind === 'file') n++;
    else n += await countFiles(entry);                 // entry is a FileSystemDirectoryHandle
  }
  return n;
}
document.querySelector('#scan').addEventListener('click', async function () {
  const dir = await showDirectoryPicker();
  console.log(dir.name, 'holds', await countFiles(dir), 'files');
});

Составьте отсортированный список фото в выбранной пользователем папке:

document.querySelector('#open-album').addEventListener('click', async function () {
  const dir = await showDirectoryPicker({ startIn: 'pictures' });
  const names = [];
  for await (const name of dir.keys()) {
    if (/\.(jpe?g|png|webp)$/i.test(name)) names.push(name);
  }
  names.sort(function (a, b) { return a.localeCompare(b); });
  renderAlbum(dir, names);                             // dir.getFileHandle(name) opens each one
});

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsPermission мост#

window.WebToApk.__fsPermission(rootUri: String, uri: String): String

queryPermission() / requestPermission(): `{ok, state:'granted'|'prompt'}` для handle, корневой выбранный URI которого — [rootUri]; 'granted', пока у приложения есть разрешение на этот выбор, в том числе между перезапусками (разрешение сохраняется; handle возвращается из IndexedDB).

Пример

Внутренний транспорт для FileSystemHandle.queryPermission() и requestPermission(). Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok:true, state:'granted'}, пока у приложения есть разрешение на выбранную папку или файл, к которым относится handle (сохраняется между перезапусками), {ok:true, state:'prompt'}, когда его больше нет, или {ok:false, error:'foreign_origin'}. Для файла только для чтения страница получает 'denied' на {mode:'readwrite'} без вызова. Требуется: handle от одного из окон выбора, в том числе прочитанный из IndexedDB.

Используйте публичный API:

const dir = await kvGet('backupDir');                 // a handle stored in IndexedDB earlier
if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') {
  const f = await dir.getFileHandle('backup.json', { create: true });
  const w = await f.createWritable();
  await w.write(JSON.stringify(state));
  await w.close();
}

Если разрешение пропало, запросите его снова по нажатию: окно выбора папки откроется именно в этой папке, и доступ даёт только она же:

document.getElementById('reconnect').addEventListener('click', async () => {
  const dir = await kvGet('backupDir');
  const state = await dir.requestPermission({ mode: 'readwrite' });   // 'granted' | 'denied'
  status.textContent = state === 'granted' ? 'Backups resumed' : 'Choose a backup folder';
});

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt). Весь File System Access API описан в showOpenFilePicker.

__fsPick мост#

window.WebToApk.__fsPick(requestId: String, kind: String, optionsJson: String): String

Открывает окно выбора [kind] (open | save | directory); handle приходят как `appmint:fs`.

Пример

Внутренний транспорт для showOpenFilePicker(), showSaveFilePicker() и showDirectoryPicker(). Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: {ok:true}, когда окно выбора на экране, или {ok:false, error} (NotAllowedError, если оно уже открыто, foreign_origin). Handle приходят событием appmint:fs {requestId, handles:[{kind, name, uri, canWrite}]} или {requestId, error:'cancelled'}. Требуется: ничего включать не нужно.

Используйте публичный API:

const [handle] = await showOpenFilePicker({ types: [{ accept: { 'image/*': ['.png', '.jpg'] } }] });
const file = await handle.getFile();

Сохраните отчёт с предлагаемым именем по нажатию кнопки (окнам выбора нужно действие пользователя):

document.querySelector('#export').addEventListener('click', async function () {
  try {
    const handle = await showSaveFilePicker({
      suggestedName: 'report.csv',
      types: [{ description: 'CSV', accept: { 'text/csv': ['.csv'] } }]
    });
    const w = await handle.createWritable();
    await w.write(buildCsv());
    await w.close();
  } catch (e) {
    if (e.name !== 'AbortError') alert('Export failed: ' + e.message);   // AbortError = cancelled
  }
});

Позвольте пользователю выбрать сразу несколько фото, начиная с папки «Изображения»:

document.querySelector('#add-photos').addEventListener('click', async function () {
  let handles;
  try {
    handles = await showOpenFilePicker({
      multiple: true,
      startIn: 'pictures',
      types: [{ accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
    });
  } catch (e) { return; }                                 // cancelled or already open
  for (const h of handles) addToGallery(await h.getFile());
});

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt). Тот же код страницы без изменений работает в Chrome на компьютере.

__fsRemove мост#

window.WebToApk.__fsRemove(uri: String, name: String, recursive: Boolean): String

removeEntry(name, {recursive}).

Пример

Внутренний транспорт для FileSystemDirectoryHandle.removeEntry(name, {recursive}). Страницы используют стандартный API; никогда не вызывайте это напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok:true} или {ok:false, error} (NotFoundError, InvalidModificationError для непустой папки без recursive). Требуется: папка из showDirectoryPicker().

Используйте публичный API:

const dir = await showDirectoryPicker();
await dir.removeEntry('old-drafts', { recursive: true });

Удалите один файл и считайте, что «уже удалён» — это тоже готово:

async function deleteFile(dir, name) {
  try {
    await dir.removeEntry(name);
  } catch (e) {
    if (e.name !== 'NotFoundError') throw e;   // NotFoundError: nothing left to delete
  }
}

Удалите папку, спрашивая сначала только если в ней ещё что-то есть:

async function removeFolder(dir, name) {
  try {
    await dir.removeEntry(name);                          // succeeds for an empty folder
  } catch (e) {
    if (e.name !== 'InvalidModificationError') throw e;
    if (!confirm('"' + name + '" is not empty. Delete it and everything inside?')) return false;
    await dir.removeEntry(name, { recursive: true });
  }
  return true;
}

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsWritableAbort мост#

window.WebToApk.__fsWritableAbort(token: String)

abort(): подмена отбрасывается; документ не затронут.

Пример

Внутренний транспорт для FileSystemWritableFileStream.abort() — подменная копия отбрасывается, файл остаётся как был. Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: ничего. Требуется: открытый поток для записи.

Используйте публичный API:

const w = await handle.createWritable();
try { await w.write(await buildExport()); await w.close(); }
catch (e) { await w.abort(); }

Позвольте пользователю отменить долгий экспорт; ничего недописанного в файл не попадёт:

let cancelled = false;
document.getElementById('cancel').onclick = function () { cancelled = true; };

async function exportAll(handle, pages) {
  const w = await handle.createWritable();
  for (const page of pages) {
    if (cancelled) { await w.abort(); return 'cancelled'; }   // the file keeps its old content
    await w.write(await renderPage(page));
  }
  await w.close();
  return 'saved';
}

Скачивайте прямо в файл через pipeTo; остановка загрузки прерывает запись:

const ctl = new AbortController();
document.getElementById('stop').onclick = function () { ctl.abort(); };

async function downloadInto(handle, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error('HTTP ' + res.status);
  const w = await handle.createWritable();
  try {
    await res.body.pipeTo(w, { signal: ctl.signal });     // closes the file when the body ends
    return 'saved';
  } catch (e) {
    return e.name === 'AbortError' ? 'stopped, file unchanged' : 'failed: ' + e.message;
  }
}

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsWritableClose мост#

window.WebToApk.__fsWritableClose(token: String): String

close(): подменная копия заменяет документ.

Пример

Внутренний транспорт для FileSystemWritableFileStream.close() — подменная копия заменяет файл. Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok:true} или {ok:false, error} (NoModificationAllowedError, InvalidModificationError, когда приложение файла не может его укоротить). Требуется: открытый поток записи.

Используйте публичный API:

const w = await handle.createWritable();
await w.write(json);
await w.close();   // nothing changes on disk before this

Объясните пользователю, почему сохранение не удалось (close() — то место, где оно может не получиться):

async function saveText(handle, text) {
  const w = await handle.createWritable();
  await w.write(text);
  try {
    await w.close();
    return true;
  } catch (e) {
    if (e.name === 'InvalidModificationError') alert('This file\'s app cannot make it shorter. Save a copy instead.');
    else if (e.name === 'NoModificationAllowedError') alert('This file cannot be changed.');
    else alert('Save failed: ' + e.message);
    return false;
  }
}

Запишите файл несколькими частями; читатели по-прежнему видят старое содержимое, пока одна close():

async function saveRows(handle, rows) {
  const w = await handle.createWritable();
  await w.write('id,name,total\n');
  for (const r of rows) await w.write(r.id + ',' + r.name + ',' + r.total + '\n');
  console.log('before close:', (await handle.getFile()).size, 'bytes (old file)');
  await w.close();
  console.log('after close:', (await handle.getFile()).size, 'bytes');
}

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsWritableOpen мост#

window.WebToApk.__fsWritableOpen(uri: String, keepExisting: Boolean): String

createWritable(): файл подмены, скопированный из файла, когда [keepExisting].

Пример

Внутренний транспорт для FileSystemFileHandle.createWritable({keepExistingData}). Страницы используют стандартный API; никогда не вызывайте это напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok, token} для приватной подменной копии или {ok:false, error} (NoModificationAllowedError для файла только для чтения). Требуется: handle от одного из окон выбора.

Используйте публичный API:

const handle = await showSaveFilePicker({ suggestedName: 'report.csv' });
const w = await handle.createWritable({ keepExistingData: false });
await w.write('a,b\n1,2\n');
await w.close();

Добавьте строку в конец существующего файла журнала, сохранив то, что в нём уже есть:

async function appendLine(handle, line) {
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);                                     // write after the old content
  await w.write(new Date().toISOString() + ' ' + line + '\n');
  await w.close();                                        // only now does the file change
}

Сохраните canvas как PNG и оставьте файл нетронутым, если что-то пойдёт не так:

document.querySelector('#save-png').addEventListener('click', async function () {
  const handle = await showSaveFilePicker({
    suggestedName: 'drawing.png',
    types: [{ accept: { 'image/png': ['.png'] } }]
  });
  const blob = await new Promise(function (r) { document.querySelector('canvas').toBlob(r, 'image/png'); });
  const w = await handle.createWritable();
  try {
    await w.write(blob);
    await w.close();
  } catch (e) {
    try { await w.abort(); } catch (x) {}                 // nothing reaches the file before close()
    alert('Could not save: ' + e.message);
  }
});

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsWritableTruncate мост#

window.WebToApk.__fsWritableTruncate(token: String, size: Double): String

truncate(size) для потока записи.

Пример

Внутренний транспорт для FileSystemWritableFileStream.truncate(size). Страницы используют стандартный API; никогда не вызывайте это напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok:true} или {ok:false, error}. Требуется: открытый поток записи.

Используйте публичный API:

const w = await handle.createWritable({ keepExistingData: true });
await w.truncate(0);
await w.write('fresh start');
await w.close();

Оставьте только первые 1000 байт файла и отрежьте всё после них:

async function keepFirstKilobyte(handle) {
  const w = await handle.createWritable({ keepExistingData: true });
  await w.truncate(1000);   // a file shorter than this grows instead (see the next example)
  await w.close();
}

Зарезервируйте место: усечение до большего размера увеличивает файл, а новые байты читаются как нули:

async function makeEmptySlotFile(handle, slots, slotSize) {
  const w = await handle.createWritable();
  await w.truncate(slots * slotSize);                  // e.g. 100 slots of 256 bytes, all zero
  await w.write({ type: 'write', position: 0, data: new Uint8Array([1, 0, 0, 0]) });
  await w.close();
  console.log('file is now', (await handle.getFile()).size, 'bytes');
}

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt).

__fsWritableWrite мост#

window.WebToApk.__fsWritableWrite(token: String, position: Double, base64: String): String

Одна порция write() потока для записи в позиции [position].

Пример

Внутренний транспорт для FileSystemWritableFileStream.write() — одна порция в позиции подменной копии. Страницы используют стандартный API; никогда не вызывайте его напрямую — методы __ могут измениться без предупреждения.

Возвращает: JSON {ok:true} или {ok:false, error}. Требуется: открытый поток записи.

Используйте публичный API:

const w = await handle.createWritable({ keepExistingData: true });
await w.write({ type: 'write', position: 0, data: new Blob(['HEADER\n']) });
await w.close();

Добавьте строку в конец файла журнала, выбранного пользователем (для выбора файла нужно нажатие):

document.getElementById('addLine').addEventListener('click', async () => {
  const [handle] = await showOpenFilePicker({
    types: [{ description: 'Text', accept: { 'text/plain': ['.txt', '.log'] } }]
  });
  const size = (await handle.getFile()).size;
  const w = await handle.createWritable({ keepExistingData: true });
  await w.seek(size);                                   // start writing after the old content
  await w.write(new Date().toISOString() + ' checked in\n');
  await w.close();
});

Сохраните рисунок с canvas как новый PNG через «Сохранить как» (Blob записывается как необработанные байты):

document.getElementById('saveDrawing').addEventListener('click', async () => {
  const handle = await showSaveFilePicker({
    suggestedName: 'drawing.png',
    types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }]
  });
  const blob = await new Promise(function (ok) { document.querySelector('canvas').toBlob(ok, 'image/png'); });
  const w = await handle.createWritable();
  await w.write(blob);
  await w.close();
});

Примечания: Реализовано в оболочке приложения (FileSystemAccess.kt). Большие данные отправляются по частям.

getMaxIoChunkSize мост#

window.WebToApk.getMaxIoChunkSize(): Long

Предел в байтах на один вызов [readFileBase64] / [writeFileBase64] для расчёта порций.

Пример

Ограничение в байтах на один вызов readFileBase64 / writeFileBase64 (4 МБ). Обычно страницы используют WebToApkFS.maxChunkSize().

Возвращает: (синхронно) число (4194304). Требуется: для самого вызова ничего; методам чтения и записи, которые оно описывает, нужно, чтобы на шаге 4 (Доступ) был отмечен Native Folder Access (SAF).

Публичный способ:

const step = window.WebToApkFS ? WebToApkFS.maxChunkSize() : 0;

Прямой вызов:

let step = 1048576;
if (window.WebToApk && typeof WebToApk.getMaxIoChunkSize === 'function') {
  step = Number(WebToApk.getMaxIoChunkSize()) || step;
}
console.log('read/write up to', step, 'bytes per call');

Подсчитайте, сколько вызовов bridge потребуется для файла, чтобы задать размер индикатора прогресса до копирования:

function plannedSteps(fileUri) {
  const step = WebToApkFS.maxChunkSize();          // 0 when the helper is not available
  const info = WebToApkFS.stat(fileUri);
  if (!step || !info.ok) return 0;
  const steps = Math.max(1, Math.ceil(info.size / step));
  document.querySelector('#progress').max = steps;
  return steps;
}

Скопируйте один файл в другой, по одной порции максимального размера за раз:

function copyFile(fromUri, toPath) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fromUri, offset, step);
    if (!r.ok) return r;
    const w = WebToApkFS.writeBytes(toPath, r.base64, offset, offset === 0 ? 'truncate' : 'patch');
    if (!w.ok) return w;
    offset += r.bytesRead;
    if (r.eof || r.bytesRead <= 0) return { ok: true, bytes: offset };
  }
}
// copyFile(pickedUri, 'photo-backup.jpg')  (a file in the granted folder)

Примечания: Запись больше этого размера отклоняется с chunk too large (max N bytes); чтение больше обрезается до этого размера.

pickFile мост#

window.WebToApk.pickFile(requestId: String, mimeFilter: String)

Открывает системное окно выбора одного файла (ACTION_OPEN_DOCUMENT). В отличие от [requestFolderAccess], даёт доступ ровно к одному файлу — именно это нужно редактору или программе уничтожения файлов; разрешение на чтение и запись сохраняется, поэтому URI продолжает работать после завершения процесса. Ответ приходит в `WebToApkOnFilePickResult(success, fileJson, requestId)`; при неудаче `error` — «cancelled», «disabled», «no-picker», «foreign_origin» или «busy» (окно выбора уже открыто для более раннего запроса, который сохраняет свой ответ). @param mimeFilter, например «image/\*», «application/pdf» или «» для любого файла. (Звёздочка экранирована обратной косой чертой только потому, что блочные комментарии Kotlin вкладываются.)

Пример

Открывает системное окно выбора ОДНОГО файла. Страницы обычно используют помощника на Promise WebToApkFS.pickFile(mimeFilter); этот необработанный метод отвечает через обратный вызов.

Возвращает: сейчас ничего. Позже оболочка вызывает window.WebToApkOnFilePickResult(success, fileJson, requestId), где fileJson — это строка JSON {ok, uri, name, size, mime} при успехе или {ok:false, uri:'', error} с error = 'cancelled', 'disabled', 'no-picker', 'busy' или 'foreign_origin'. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Публичный способ (рекомендуется):

const f = await WebToApkFS.pickFile('image/*');
if (f.ok) console.log('picked', f.name, f.uri);

Прямой вызов с вашей собственной проверкой requestId:

function pickRaw(mime) {
  return new Promise(function (resolve) {
    if (!window.WebToApk || typeof WebToApk.pickFile !== 'function') { resolve(null); return; }
    const myId = 'pick_' + Date.now();
    window.WebToApkOnFilePickResult = function (success, fileJson, requestId) {
      if (requestId !== myId) return;
      const r = JSON.parse(fileJson);           // fileJson is a string
      resolve(success && r.ok ? r : null);      // null = cancelled or not allowed
    };
    WebToApk.pickFile(myId, mime || '');        // '' = any file
  });
}

Прикрепите PDF и покажите его имя и размер, отличая отмену от настоящей проблемы:

document.querySelector('#attach').addEventListener('click', async function () {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('application/pdf');
  if (!f.ok) {
    if (f.error === 'cancelled' || f.error === 'busy') return;   // user closed it / one already open
    alert('Could not open the file picker: ' + f.error);         // 'disabled', 'no-picker', ...
    return;
  }
  document.querySelector('#attachment').textContent =
    f.name + ' (' + Math.round(f.size / 1024) + ' KB)';
  localStorage.setItem('attachmentUri', f.uri);   // the grant is kept across restarts
});

Откройте текстовый файл и загрузите его содержимое в редактор:

async function openTextFile() {
  if (!window.WebToApkFS) return;
  const f = await WebToApkFS.pickFile('text/*');
  if (!f.ok) return;
  const r = WebToApkFS.readAll(f.uri);            // {ok, base64, size} or {ok:false, error}
  if (!r.ok) { alert('Could not read ' + f.name + ': ' + r.error); return; }
  const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
  document.querySelector('#editor').value = new TextDecoder('utf-8').decode(bytes);
  document.querySelector('#editor').dataset.uri = f.uri;   // save back to the same file later
}

Примечания: Одновременно может быть открыт только один выбор: вызов, сделанный пока один открыт, сразу получает ответ error:'busy', а открытый сохраняет свой ответ. Разрешение сохраняется (чтение и запись), пока вы не вызовете releaseFileAccess(uri). Файл здесь не читается — используйте readFileBase64 / WebToApkFS.readBytes с uri.

readFileBase64 мост#

window.WebToApk.readFileBase64(pathOrUri: String, offset: Long, length: Long): String

Читает до [length] необработанных байтов начиная с [offset] и возвращает их в base64. @param pathOrUri URI `content://` из [pickFile] или путь относительно папки, предоставленной через [requestFolderAccess]. @param length сколько байтов читать; ограничивается 4 МБ за вызов. 0 или отрицательное значение означает «столько, сколько позволяет один вызов». @return JSON `{ok, base64, bytesRead, offset, size, eof, error}`. `eof` говорит циклу чтения по частям, когда остановиться, и ему не нужно самому сравнивать с `size`.

Пример

Читает необработанные байты файла с заданного смещения и возвращает их в base64. Обычно страницы используют WebToApkFS.readBytes(pathOrUri, offset, length) (тот же результат, уже разобранный) или WebToApkFS.readAll(pathOrUri).

Возвращает: (синхронно) строку JSON {ok:true, base64, bytesRead, offset, size, eof} или {ok:false, error}. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Публичный способ:

const r = WebToApkFS.readBytes(fileUri, 0, 65536);
if (r.ok) console.log(r.bytesRead, 'bytes read, end of file:', r.eof);

Прямой вызов — обратите внимание, что результат — это строка, которую нужно разобрать через JSON.parse, а не просто base64:

if (window.WebToApk && typeof WebToApk.readFileBase64 === 'function') {
  const r = JSON.parse(WebToApk.readFileBase64(fileUri, 0, 0));   // length 0 = up to 4 MB
  if (r.ok) {
    const bytes = Uint8Array.from(atob(r.base64), function (c) { return c.charCodeAt(0); });
    console.log('first byte', bytes[0], 'of', r.size);
  } else {
    console.log('read failed:', r.error);
  }
}

Проверьте, что выбранный файл действительно PNG, прочитав только его первые 8 байт:

function isPng(fileUri) {
  const r = WebToApkFS.readBytes(fileUri, 0, 8);
  if (!r.ok || r.bytesRead < 8) return false;
  const head = atob(r.base64);
  const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
  return sig.every(function (b, i) { return head.charCodeAt(i) === b; });
}

Читайте большой файл по частям с индикатором прогресса, не держа его целиком в памяти:

function scanInChunks(fileUri, onChunk) {
  const step = WebToApkFS.maxChunkSize() || 1048576;
  let offset = 0;
  for (;;) {
    const r = WebToApkFS.readBytes(fileUri, offset, step);
    if (!r.ok) throw new Error('read failed: ' + r.error);
    onChunk(r.base64);                                    // process this piece, then drop it
    offset += r.bytesRead;
    document.querySelector('#progress').value = r.size ? offset / r.size : 1;
    if (r.eof || r.bytesRead <= 0) break;
  }
  return offset;                                          // total bytes read
}

Примечания: pathOrUri — это URI вида content:// из pickFile или путь относительно папки из requestFolderAccess. Не более 4 МБ за вызов (getMaxIoChunkSize()); повторяйте по bytesRead до eof. Ошибки: not-found, disabled, cannot-open, offset must be >= 0, offset past end of file (N bytes), foreign_origin (страница с сайта, не являющегося собственным сайтом приложения).

releaseFileAccess мост#

window.WebToApk.releaseFileAccess(uriString: String): String

Снимает сохранённое разрешение на один файл, полученное через [pickFile]. Android ограничивает число URI-разрешений, которые приложение может сохранять, поэтому приложение, выбирающее много файлов, должно снимать разрешения с тех, с которыми закончило.

Пример

Снимает сохранённое разрешение на чтение и запись для файла, выбранного через pickFile. Обычно страницы используют WebToApkFS.releaseFile(uri), который возвращает настоящее булево значение.

Возвращает: (синхронно) строку "true" или "false". Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ) (иначе всегда "false").

Публичный способ:

if (window.WebToApkFS) WebToApkFS.releaseFile(fileUri);

Прямой вызов:

if (window.WebToApk && typeof WebToApk.releaseFileAccess === 'function') {
  const released = WebToApk.releaseFileAccess(fileUri) === 'true';   // a string, not a boolean
  console.log(released ? 'access released' : 'nothing to release');
}

Ведите список «недавних файлов» не длиннее 10 и снимайте разрешение с любого файла, который из него выпал:

function rememberRecent(uri) {
  let recent = JSON.parse(localStorage.getItem('recentFiles') || '[]');
  recent = [uri].concat(recent.filter(function (u) { return u !== uri; }));
  while (recent.length > 10) {
    const dropped = recent.pop();
    if (window.WebToApkFS) WebToApkFS.releaseFile(dropped);   // Android caps kept grants
  }
  localStorage.setItem('recentFiles', JSON.stringify(recent));
}

Закройте текущий документ: снимите его разрешение и забудьте сохранённый URI:

document.querySelector('#close-doc').addEventListener('click', function () {
  const uri = localStorage.getItem('openDocUri');
  if (uri && window.WebToApkFS) {
    const released = WebToApkFS.releaseFile(uri);   // true / false, already a boolean
    console.log(released ? 'grant released' : 'no grant was held for this file');
  }
  localStorage.removeItem('openDocUri');
  document.querySelector('#editor').value = '';
});

Примечания: Android ограничивает, сколько разрешений на файлы может хранить приложение, поэтому освобождайте файлы, которые больше не нужны. Сам файл не затрагивается.

statFile мост#

window.WebToApk.statFile(pathOrUri: String): String

Метаданные файла или папки без чтения их содержимого. Относительный путь указывает на файл или вложенную папку выбранной папки; для папки ответ `isDirectory: true`, `size` 0 и `mime` `vnd.android.document/directory`. @return JSON `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}` или `{ok:false, error}` с `not-found`, `disabled` или `foreign_origin`.

Пример

Читает метаданные файла (имя, размер, тип), не читая его байты. Обычно страницы используют WebToApkFS.stat(pathOrUri), который разбирает JSON за вас.

Возвращает: (синхронно) строку JSON {ok, name, size, mime, lastModified, uri, isDirectory, canWrite} или {ok:false, error} ('not-found', 'disabled', 'foreign_origin'). Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Публичный способ:

const info = WebToApkFS.stat(fileUri);
if (info.ok) console.log(info.name, info.size);

Прямой вызов:

if (window.WebToApk && typeof WebToApk.statFile === 'function') {
  const info = JSON.parse(WebToApk.statFile(fileUri));   // or 'notes/today.txt' in the granted folder
  if (info.ok) console.log(info.name, info.size + ' bytes', info.mime, info.canWrite);
  else console.log('stat failed:', info.error);
}

Откажитесь от слишком большого файла, не прочитав из него ничего:

const MAX_BYTES = 20 * 1024 * 1024;
function canImport(fileUri) {
  const info = WebToApkFS.stat(fileUri);
  if (!info.ok) { alert('File not available: ' + info.error); return false; }
  if (info.size > MAX_BYTES) {
    alert(info.name + ' is ' + Math.round(info.size / 1048576) + ' MB; the limit is 20 MB.');
    return false;
  }
  return true;
}

Найдите вложенную папку в выбранной папке и предлагайте «Сохранить сюда» только если в неё можно писать:

function checkExportFolder() {
  const info = WebToApkFS.stat('exports');          // a path relative to the granted folder
  const btn = document.querySelector('#save-here');
  if (!info.ok) {                                    // 'not-found' = the folder is not there yet
    btn.textContent = 'Create "exports" and save';
    return WebToApkFS.mkdir('exports');
  }
  btn.disabled = !(info.isDirectory && info.canWrite);
  console.log('exports last changed', new Date(info.lastModified).toLocaleString());
  return true;
}

Примечания: Принимает URI вида content:// из pickFile или путь относительно папки, к которой дан доступ через requestFolderAccess. Отвечает только собственным страницам приложения или настроенному сайту; страница с любого другого сайта получает foreign_origin.

writeFileBase64 мост#

window.WebToApk.writeFileBase64(pathOrUri: String, base64: String, offset: Long, mode: String): String

Записывает необработанные байты и подтверждает, сколько записано. @param mode `"patch"` пишет со смещения [offset], оставляя остальную часть файла нетронутой (что нужно для многопроходной перезаписи), `"truncate"` заменяет файл, `"append"` игнорирует [offset] и пишет в конец. @return JSON `{ok, bytesWritten, offset, size, error}` — `size` перечитывается из файла после fsync, так что вызывающий может по-настоящему проверить запись, а не верить boolean.

Пример

Записывает байты base64 в файл со смещением (patch), поверх всего файла (truncate) или в конец (append). Обычно страницы используют WebToApkFS.writeBytes(pathOrUri, base64, offset, mode) или WebToApkFS.writeAll(pathOrUri, base64).

Возвращает: (синхронно) строку JSON {ok, bytesWritten, offset, size} (size перечитывается после fsync) или {ok:false, error}. Требуется: при сборке отметьте Native Folder Access (SAF) на шаге 4 (Доступ).

Публичный способ:

const r = WebToApkFS.writeBytes(fileUri, btoa('hello'), 0, 'truncate');
if (r.ok) console.log('saved', r.bytesWritten, 'bytes');

Прямой вызов:

if (window.WebToApk && typeof WebToApk.writeFileBase64 === 'function') {
  const r = JSON.parse(WebToApk.writeFileBase64('notes/today.txt', btoa('Buy milk\n'), 0, 'append'));
  if (!r.ok) console.log('write failed:', r.error);
  else console.log('file is now', r.size, 'bytes');
}

Сохраните рисунок с canvas как PNG в файл, выбранный пользователем, заменив прежнее содержимое:

document.querySelector('#save').addEventListener('click', async function () {
  const f = await WebToApkFS.pickFile('image/png');
  if (!f.ok) return;
  const base64 = document.querySelector('canvas').toDataURL('image/png').split(',')[1];
  const r = WebToApkFS.writeAll(f.uri, base64);   // truncates, then writes in 4 MB chunks
  if (r.ok) console.log('saved', r.bytesWritten, 'bytes to', f.name);
  else alert('Save failed: ' + r.error);
});

Перезапишите файл на месте нулями перед удалением и убедитесь, что каждый проход выполнен:

function zeroFill(fileUri) {
  const info = WebToApkFS.stat(fileUri);
  if (!info.ok) return false;
  const slice = 32768;
  for (let at = 0; at < info.size; at += slice) {
    const len = Math.min(slice, info.size - at);
    const zeros = btoa(String.fromCharCode.apply(null, new Uint8Array(len)));
    const w = WebToApkFS.writeBytes(fileUri, zeros, at, 'patch');   // the rest of the file is kept
    if (!w.ok || w.bytesWritten !== len) { console.log('write failed at', at, w.error); return false; }
  }
  return true;   // each call is fsync'ed before it answers
}

Примечания: Режим — 'patch' (по умолчанию, если пусто), 'truncate' или 'append'. Один вызов принимает не более 4 МБ декодированных байтов (getMaxIoChunkSize()). Относительный путь создаётся внутри предоставленной папки, если его нет. Ошибки: disabled, foreign_origin (страница на экране не принадлежит приложению), bad mode: x, offset must be >= 0, bad base64, chunk too large (max N bytes), not-found, cannot-open, short write.