Create Free APK

JavaScript bridge API

Files: one file at a time

The system picker and chunked binary read/write, for files too large to hold in a string.

AppMint.downloadFile helper#

AppMint.downloadFile(data, fileName, mimeType?)

Example

Saves a file your page made - from a base64 string, a data: URL, a blob: URL or a Blob - with the file name you choose. Android's "Save as…" sheet opens with that name filled in.

Returns: (sync) true once the save was handed to Android (false only for a data: URL it could not parse). Throws TypeError if data is not a Blob or string. The user still picks the folder, and can cancel. Needs: nothing - it is always present in the app.

Base64 string plus a MIME type:

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();
  }
}

A data: URL (the MIME type comes from the URL) or a 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

A base64 PDF your server returns in JSON; a name without an extension gets .pdf from the MIME type:

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
}

A blob: URL you already made for an on-screen preview:

// 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');
  }
});

Notes: Only the last part of fileName is used (no folders). A name without an extension gets one from the MIME type. The third argument is used only for a plain base64 string. If the phone has no "Save as…" screen at all, the file is written straight into Downloads. Your normal <a download> code also works - see downloads.

AppMint.saveBlob helper#

AppMint.saveBlob(blob, fileName)

Example

Saves a Blob (or File) with the name you choose. Android's "Save as…" sheet opens with that name filled in.

Returns: (sync) true once the save was handed to Android. Throws TypeError when the first argument is not a Blob. Needs: nothing - it is always present in the app.

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);
}

A File brings its own name, so the name can be left out:

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

A file from your server that needs a sign-in header, saved under a name you choose:

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');
  }
}

Several files bundled into one ZIP (here with JSZip) and saved in one go:

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');
  }
}

Notes: The Blob is read into memory and sent to Android in one piece, so very large blobs (hundreds of MB) may run out of memory. The user picks the folder and may cancel; nothing tells the page which one happened.

AppMint.setDownloadName helper#

AppMint.setDownloadName(url, fileName)

Example

Gives a blob: or data: URL the file name it should be saved under, before your code (or a library) triggers the download.

Returns: nothing. Needs: nothing - it is always present in the app.

Useful when a library downloads a URL you built earlier and you cannot pass it a name:

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"

A PDF your page opens with window.open goes to the phone's PDF viewer; name it first so it is not "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');
}

A data: URL works the same way, e.g. a chart image a library links to without a download attribute:

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';

Notes: The name is remembered even after URL.revokeObjectURL(url), because some libraries revoke before the save finishes. You rarely need this: <a download="name">, URL.createObjectURL(file) (a File has a name) and FileSaver's saveAs(blob, name) are all picked up automatically. The name 'download' is ignored as "no name".

WebToApkFS.maxChunkSize helper#

WebToApkFS.maxChunkSize()

Example

Tells you the most bytes one readBytes or writeBytes call can move (4 MB), so you can size your chunk loop.

Returns: (sync) a number of bytes, or 0 when the bridge method is missing. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build (that is what injects 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 };
  }
}

Show read progress: the chunk size tells you how many steps a file takes:

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 };
}

Append a large byte array to a file in the granted folder, one allowed piece at a time:

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 };
}

Notes: A smaller chunk keeps the page more responsive (each call is synchronous); the cap only says how big one call may be.

WebToApkFS.pickFile helper#

WebToApkFS.pickFile(mimeFilter)

Example

Opens the Android file picker so the user chooses ONE file; your page may then read and write that file's real bytes, even after the app restarts.

Returns: Promise<{ok, uri, name, size, mime, error}>. On success ok is true and uri is a content:// address you pass to stat, readBytes, writeBytes. On failure ok is false and error is 'cancelled', 'disabled', 'no-picker', 'busy' (a picker is already open for an earlier call, which keeps its own answer), 'foreign_origin' or 'unsupported' (no app). Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

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
}

Use the saved uri again later, without asking the user a second time:

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);
}

The filter takes ONE pattern, so pick broadly and check the name yourself:

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);
}

Notes: The filter is one MIME type pattern ('image/*', 'text/csv'), not a list. The picker asks for read AND write access and keeps it. Android limits how many files an app may keep this way, so call WebToApkFS.releaseFile(uri) when you are done with a file. For a whole folder use WebToApkFS.requestAccess() instead. WebToApkFS is installed when Native Folder Access or Secure Keys is ticked; its calls work only with Native Folder Access (WebToApkFS.isAvailable()).

WebToApkFS.readAll helper#

WebToApkFS.readAll(pathOrUri)

Example

Reads a whole file into one base64 string. It loops over readBytes for you, 4 MB at a time.

Returns: (sync) {ok:true, base64, size}, or the first failing chunk's {ok:false, error}. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Load a picked image into an <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;
}

Read a UTF-8 text file (works for any language, not only English):

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);
}

Show a file's SHA-256 checksum, for example to compare a download with the published one:

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);
}

Notes: The whole file ends up in memory (as bytes and then as base64), and the call blocks the page while it runs. Use it for files that easily fit in memory; for large files loop WebToApkFS.readBytes and handle each chunk. For plain text inside the granted folder, WebToApkFS.readText(path) is simpler.

WebToApkFS.readBytes helper#

WebToApkFS.readBytes(pathOrUri, offset, length)

Example

Reads the real bytes of a file, starting at a byte position, as base64. Safe for images, PDFs, ZIPs - nothing is changed by text decoding.

Returns: (sync) {ok:true, base64, bytesRead, offset, size, eof} or {ok:false, error} ('not-found', 'disabled', 'cannot-open', 'offset past end of file (N bytes)', 'foreign_origin', 'unsupported'). Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Read the first 64 KB, for example to check a file header:

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');
}

Read a big file piece by piece, so it never has to fit in memory at once:

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 };
  }
}

Read only the end of a file, for example the 128-byte tag at the end of an 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
}

Notes: One call reads at most WebToApkFS.maxChunkSize() bytes (4 MB); length 0 means "as much as one call allows". Loop on bytesRead and stop on eof. The call is synchronous, so keep chunks moderate on the UI thread. For small files WebToApkFS.readAll(uri) does the loop for you.

WebToApkFS.readSealed helper#

WebToApkFS.readSealed(pathOrUri, alias)

Example

Reads a file written by WebToApkFS.writeSealed and decrypts it with the same keystore key.

Returns: (sync) {ok:true, base64} (the original bytes), or {ok:false, error} - 'keystore-disabled', 'corrupt-sealed-file', a keystore code ('no-such-key' after the key was deleted, 'auth-required', 'key-invalidated') or a file error ('not-found' …). Needs: tick Native Folder Access (SAF) and Secure Keys (encryption) in Step 4 (Access) when you build.

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;
}

Keep app settings in a sealed JSON file, with defaults before the first save:

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)));
}

Move a sealed file to a new key, then destroy the old key:

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 };
}

Notes: It fails for good once AppMintKeystore.deleteKey(alias) has run - that is the point. The whole file is read and decrypted in memory, synchronously; keep sealed files small (notes, keys, tokens).

WebToApkFS.releaseFile helper#

WebToApkFS.releaseFile(uri)

Example

Gives back the long-term access your app got when the user picked a file with WebToApkFS.pickFile.

Returns: (sync) true when the grant was released, false otherwise (no grant, bad URI, not in the app). Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

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
}

When the user picks a new default file, give back the grant for the old one:

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);
}

A "Forget recent files" button that releases every file the app remembered:

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);
}

Notes: Android caps how many picked files one app may keep access to. An app that picks many files (an importer, a viewer) should release each one when finished. After release, stat / readBytes / writeBytes on that URI answer not-found. It does not delete the file.

WebToApkFS.stat helper#

WebToApkFS.stat(pathOrUri)

Example

Tells you a file's name, size, type and date without reading its contents.

Returns: (sync) {ok:true, name, size, mime, lastModified, uri, isDirectory, canWrite}, or {ok:false, error} with error = 'not-found', 'disabled', 'foreign_origin' or 'unsupported'. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

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');
}

A path inside the folder the user granted with WebToApkFS.requestAccess() works too:

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

Re-import a remembered file only when it changed since the last import:

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';
}

Notes: pathOrUri is a content:// URI from pickFile() or a path relative to the granted folder. A URI your app was never given returns not-found. lastModified is milliseconds since 1970 (0 when the provider does not say).

WebToApkFS.writeAll helper#

WebToApkFS.writeAll(pathOrUri, base64)

Example

Replaces a file's whole contents with your bytes. It splits the data into 4 MB pieces for you: the first piece truncates the file, the rest are patched in after it.

Returns: (sync) {ok:true, bytesWritten}, or the first failing piece's {ok:false, error}. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Save UTF-8 text (any language) back to a file the user picked:

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);
}

Write a canvas image into the granted folder (the file is created if missing):

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);

Save a JSON backup into the granted folder, asking for a folder first if there is none, then confirm its size on disk:

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');
}

Notes: The whole input sits in memory while it is written, and the call blocks the page. If a later piece fails, the file already holds the earlier pieces - check ok and tell the user. To keep a file secret, use WebToApkFS.writeSealed instead.

WebToApkFS.writeBytes helper#

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

Example

Writes real bytes into a file and tells you how many landed. It can patch bytes in the middle, replace the whole file, or append to the end.

Returns: (sync) {ok, bytesWritten, offset, size} - size is read back from the file after the data is flushed to disk - or {ok:false, error}. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

Patch - change 4 bytes at position 100, keep the rest of the file:

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 - replace the whole file (offset is ignored):

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 - add to the end (offset is ignored):

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

Notes: mode is 'patch' (default), 'truncate' or 'append'. At most 4 MB per call (WebToApkFS.maxChunkSize()); for bigger data use WebToApkFS.writeAll. A path inside the granted folder is created if it does not exist; a picked content:// URI must be one the user chose. btoa only accepts Latin-1 text - for other text encode to bytes first (see WebToApkFS.writeAll). Errors: disabled, foreign_origin (the page on screen is not the app's own), bad mode: x, bad base64, chunk too large (max N bytes), not-found, cannot-open, short write.

WebToApkFS.writeSealed helper#

WebToApkFS.writeSealed(pathOrUri, base64, alias)

Example

Writes a file encrypted with a hardware-backed key from AppMintKeystore. Deleting that key later makes the file unreadable for everyone - the only real "secure delete" on a phone.

Returns: (sync) {ok:true, bytesWritten} (encrypted size on disk), or {ok:false, error} - 'keystore-disabled', a keystore code such as 'no-such-key', 'auth-required', 'key-invalidated', or a file error. Needs: tick Native Folder Access (SAF) and Secure Keys (encryption) in Step 4 (Access) when you build.

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;
}

Delete it for real - destroy the key, then remove the file:

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

Seal a photo the user chose, as raw bytes, into the folder they granted:

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;
}

A diary key that works only for 5 minutes after the phone was unlocked, with each error explained:

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;
}

Notes: Do not "overwrite with zeros" to erase: phone flash storage keeps the old blocks. The file format is a list of length-prefixed AES-GCM pieces; read it back only with WebToApkFS.readSealed and the same alias. A key created with {requireAuth:true} answers auth-required until the user has unlocked recently.

downloads web standard#

<a download> and Blob downloads

Example

Your normal web download code works in the app, the way it does in Chrome. A WebView ignores blob: and data: downloads on its own; the app catches them and saves the file to the phone's Downloads folder under your file name, then shows "Downloaded <name> · Open" and a "Download complete" notification.

Returns: nothing to the page. Needs: nothing to switch on. Web-address downloads (https://…) go to the Downloads folder too (a PDF or image first asks "View or Download"), and so do files packed inside the app (<a href="manual.pdf" download>); on Android 9 and older the app asks for storage permission first. Large files are fine: the file is streamed to the app in pieces.

<a download> with a Blob - the most common pattern:

<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>

A data: URL, FileSaver.js, and navigator.msSaveBlob also work:

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 of a blob: or data: URL means "show it", not "save it": a PDF opens in the app's PDF viewer, other types in the phone's app for that type:

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

Notes: Without a name the file is saved as download.<ext>; a second file with the same name becomes name (1).ext. A File object brings its own name (URL.createObjectURL(file)). <a href="/files/x" download="Name.pdf"> on your own site keeps Name.pdf (Chrome ignores the attribute on other sites' links, and so does the app). If no app on the phone can show a window.open file, it is saved to Downloads instead. To let the user choose the folder, use showSaveFilePicker(). To save without an <a> element, use AppMint.saveBlob(blob, name) or AppMint.downloadFile(base64, name, mime).

input-file web standard#

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

Example

A normal file input works in the app like in Chrome: it opens the photo picker, the camera, or the file picker, and your page gets ordinary File objects.

Returns: the input's change event with input.files (empty when the user cancels). Needs: photos and files need nothing. For capture (the camera) tick Camera in Step 4 (Access) when you build; the app asks for the camera permission the first time.

Photos - opens the Android photo picker (no permission prompt; the page gets only what the user chose):

<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>

Camera - capture skips the picker and opens the camera straight away:

<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>

Any file, or several types (extensions work too):

<input type="file" accept=".pdf,.csv,application/json" multiple>
You writeWhat opens
accept="image/*" / "video/*" / "image/*,video/*"the photo picker (photos, videos, or both)
add multiplethe user can pick several
add capture (with Camera ticked)the camera directly - photo for image/*, video for video/*
accept="image/*,.pdf" or no acceptthe system file picker

Notes: Without Camera ticked, capture is ignored and the picker opens. When Native Folder Access (SAF) is ticked, the app shows its own "Select Upload Source" menu instead: Take Photo / Record Video (if Camera is ticked), Choose Files, and Upload Folder (sends every file in a folder). With Storage ticked on Android 12 and older, choosing a non-photo file may ask for storage permission first. Phones without the photo picker get the file picker, which shows the same photos.

showOpenFilePicker web standard#

showOpenFilePicker / showSaveFilePicker / showDirectoryPicker — File System Access

Example

The standard File System Access API works inside the app, over Android's own document picker: open a file and save back INTO THAT FILE, create a file with a Save-as sheet, or get a whole folder to read, create and delete files in.

Returns: showOpenFilePicker(options) → Promise of an array of FileSystemFileHandle; showSaveFilePicker(options) → one FileSystemFileHandle; showDirectoryPicker(options) → one FileSystemDirectoryHandle. A cancelled picker rejects AbortError; no user gesture rejects SecurityError; a second picker while one is open rejects NotAllowedError. Needs: nothing to switch on and no permission - the user picks.

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);
});

Pick several photos at once, starting in the Pictures folder:

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);
  }
});

Add a line to the end of a log file without rewriting what is already there:

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();
}

Choose a backup folder ONCE and save into it automatically on every later launch - keep the handle in IndexedDB, exactly as in Chrome. The app keeps the folder's grant across restarts, so queryPermission() answers 'granted' and the backup needs no tap and no picker:

// 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;
}

If the grant was removed (the user cleared the app's access, or deleted the folder), queryPermission() answers 'prompt'. From a button tap, requestPermission() re-opens the folder picker AT that folder; choosing the same folder answers 'granted', anything else (or cancel) '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';
  }
});

Clean a folder the user picked: delete old .tmp files and sub-folders named 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');
});

Notes: createWritable() follows the spec's swap-file model - abort() (or the page going away) leaves the original untouched; { keepExistingData: true } starts from the current content; write({type:'seek'|'truncate'|'write', …}), seek() and truncate() work. A file its app offers read-only answers queryPermission({mode:'readwrite'}) with 'denied' and createWritable() rejects NoModificationAllowedError. getFile() streams the document, so large files are fine. Handles can be stored in IndexedDB (including inside objects and arrays) and used again after the app restarts: the app keeps Android's grant for every folder and file a picker returned (the 64 most recent; the oldest is released first). queryPermission() answers 'granted' while that grant is held and 'prompt' once it is gone; requestPermission() then needs a tap and re-opens the picker at the handle's own folder. Apps built before 2026-10-03 lost the handle on restart - rebuild, and the user picks the folder once more. types become the picker's file filter. Only the top-level page has the pickers. Internal transports: __fsPick (answered with the appmint:fs event), __fsFile, __fsList, __fsChild, __fsRemove, __fsPermission, __fsWritableOpen/Write/Truncate/Close/Abort. AI-built apps check can('fileHandles').

__dlAbort bridge#

window.WebToApk.__dlAbort(token: String)

Drops download [token] (the page's read failed or it gave up).

Example

Documented together with WebToApk.__dlBegin - the example there shows this one too.

Internal transport behind <a download>, Blob downloads, window.open(blobUrl) and AppMint.saveBlob / AppMint.downloadFile: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the __ methods may change without notice.

Returns: __dlBegin(name, type, size) returns a token (or "" when the phone has no room); __dlChunk(token, base64) returns false if a slice could not be written; __dlEnd(token, action) finishes (save = Downloads, open = show it, share, copy). Needs: nothing.

Use the public 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"

Export a drawing from a canvas through the same route:

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');

Save a JSON backup the user can keep, with a message when it is done:

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.');
}

Notes: Implemented in the app shell (ShellDownloads.kt).

__dlBegin bridge#

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

Opens a download of [size] bytes named [name] ([type] = its MIME type) that the page will stream with __dlChunk. Returns a token, or "" when there is no room.

Example

Internal transport behind <a download>, Blob downloads, window.open(blobUrl) and AppMint.saveBlob / AppMint.downloadFile: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the __ methods may change without notice.

Returns: __dlBegin(name, type, size) returns a token (or "" when the phone has no room); __dlChunk(token, base64) returns false if a slice could not be written; __dlEnd(token, action) finishes (save = Downloads, open = show it, share, copy). Needs: nothing.

Use the public 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"

Export a drawing from a canvas through the same route:

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');

Save a JSON backup the user can keep, with a message when it is done:

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.');
}

Notes: Implemented in the app shell (ShellDownloads.kt).

__dlChunk bridge#

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

One base64 slice of the download [token]; false when the transfer failed.

Example

Documented together with WebToApk.__dlBegin - the example there shows this one too.

Internal transport behind <a download>, Blob downloads, window.open(blobUrl) and AppMint.saveBlob / AppMint.downloadFile: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the __ methods may change without notice.

Returns: __dlBegin(name, type, size) returns a token (or "" when the phone has no room); __dlChunk(token, base64) returns false if a slice could not be written; __dlEnd(token, action) finishes (save = Downloads, open = show it, share, copy). Needs: nothing.

Use the public 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"

Export a drawing from a canvas through the same route:

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');

Save a JSON backup the user can keep, with a message when it is done:

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.');
}

Notes: Implemented in the app shell (ShellDownloads.kt).

__dlEnd bridge#

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

Finishes download [token] and acts on it: [action] "save" (Downloads, with the "Downloaded · Open" bar and notification), "open" (PDF viewer or the phone's app for the type), "share" (share sheet) or "copy" (image to the clipboard).

Example

Documented together with WebToApk.__dlBegin - the example there shows this one too.

Internal transport behind <a download>, Blob downloads, window.open(blobUrl) and AppMint.saveBlob / AppMint.downloadFile: the file is streamed from the page to the app in slices, so a large export is never one giant string in memory. Pages use the standard download code; never call these directly, because the __ methods may change without notice.

Returns: __dlBegin(name, type, size) returns a token (or "" when the phone has no room); __dlChunk(token, base64) returns false if a slice could not be written; __dlEnd(token, action) finishes (save = Downloads, open = show it, share, copy). Needs: nothing.

Use the public 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"

Export a drawing from a canvas through the same route:

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');

Save a JSON backup the user can keep, with a message when it is done:

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.');
}

Notes: Implemented in the app shell (ShellDownloads.kt).

__fsChild bridge#

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

getFileHandle / getDirectoryHandle (with `create`).

Example

Internal transport behind getFileHandle(name, {create}) and getDirectoryHandle(name, {create}). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok, kind, name, uri, canWrite} or {ok:false, error} (NotFoundError, TypeMismatchError, NoModificationAllowedError). Needs: a folder from showDirectoryPicker().

Use the public API:

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

Check whether a file already exists in the folder before creating it:

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;
  }
}

Save the app settings as JSON into a "config" sub-folder, creating both as needed:

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)

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsFile bridge#

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

getFile(): `{ok, name, type, size, lastModified, url}` (the body streams from url).

Example

Internal transport behind FileSystemFileHandle.getFile(). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok, name, type, size, lastModified, url} - the body streams from url (the app's own origin, with ranges) - or {ok:false, error} (NotFoundError, TypeMismatchError, foreign_origin). Needs: a handle from one of the pickers.

Use the public API:

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

Show a picked image on the page without reading it into a string:

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 + ')';
});

Re-read a file you opened earlier and notice when it has changed or been deleted:

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;
  }
}

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsList bridge#

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

A directory handle's entries: `{ok, entries:[{kind, name, uri, canWrite}]}`.

Example

Internal transport behind iterating a FileSystemDirectoryHandle (entries(), keys(), values(), for await). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok, entries:[{kind, name, uri, canWrite}]} or {ok:false, error}. Needs: a folder from showDirectoryPicker().

Use the public API:

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

Count the files in a folder and all its sub-folders:

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');
});

Build a sorted list of the photos in a folder the user chose:

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
});

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsPermission bridge#

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

queryPermission() / requestPermission(): `{ok, state:'granted'|'prompt'}` for a handle whose picked root is [rootUri] - 'granted' while the app holds that pick's grant, across relaunches (the grant is persisted; a handle comes back from IndexedDB).

Example

Internal transport behind FileSystemHandle.queryPermission() and requestPermission(). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok:true, state:'granted'} while the app holds the grant of the picked folder or file the handle comes from (kept across restarts), {ok:true, state:'prompt'} once it is gone, or {ok:false, error:'foreign_origin'}. A read-only file answers 'denied' for {mode:'readwrite'} in the page, without a call. Needs: a handle from one of the pickers, also one read back from IndexedDB.

Use the public 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();
}

Ask again from a tap when the grant is gone - the folder picker opens at that folder, and only the same folder grants:

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';
});

Notes: Implemented in the app shell (FileSystemAccess.kt). See showOpenFilePicker for the whole File System Access API.

__fsPick bridge#

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

Opens the [kind] picker (open | save | directory); handles arrive as `appmint:fs`.

Example

Internal transport behind showOpenFilePicker(), showSaveFilePicker() and showDirectoryPicker(). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: {ok:true} once the picker is on screen, or {ok:false, error} (NotAllowedError when one is already open, foreign_origin). The handles arrive as the appmint:fs event {requestId, handles:[{kind, name, uri, canWrite}]} or {requestId, error:'cancelled'}. Needs: nothing to switch on.

Use the public API:

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

Save a report with a suggested name, from a button tap (the pickers need a user gesture):

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
  }
});

Let the user pick several photos at once, starting in the Pictures folder:

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());
});

Notes: Implemented in the app shell (FileSystemAccess.kt). The same page code works unchanged in desktop Chrome.

__fsRemove bridge#

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

removeEntry(name, {recursive}).

Example

Internal transport behind FileSystemDirectoryHandle.removeEntry(name, {recursive}). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok:true} or {ok:false, error} (NotFoundError, InvalidModificationError for a non-empty folder without recursive). Needs: a folder from showDirectoryPicker().

Use the public API:

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

Delete one file and treat "already gone" as done:

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

Remove a folder, asking first only when it still has something in it:

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;
}

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsWritableAbort bridge#

window.WebToApk.__fsWritableAbort(token: String)

abort(): the swap is dropped; the document is untouched.

Example

Internal transport behind FileSystemWritableFileStream.abort() - the swap copy is dropped and the file stays as it was. Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: nothing. Needs: an open writable stream.

Use the public API:

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

Let the user cancel a long export; nothing half-written reaches the file:

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';
}

Download straight into a file with pipeTo; stopping the download aborts the write:

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;
  }
}

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsWritableClose bridge#

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

close(): the swap replaces the document.

Example

Internal transport behind FileSystemWritableFileStream.close() - the swap copy replaces the file. Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok:true} or {ok:false, error} (NoModificationAllowedError, InvalidModificationError when the file's app cannot shorten it). Needs: an open writable stream.

Use the public API:

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

Tell the user why a save did not go through (close() is where it can fail):

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;
  }
}

Write a file in several pieces; readers still see the old content until the one 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');
}

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsWritableOpen bridge#

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

createWritable(): a swap file, copied from the file when [keepExisting].

Example

Internal transport behind FileSystemFileHandle.createWritable({keepExistingData}). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok, token} for a private swap copy, or {ok:false, error} (NoModificationAllowedError for a read-only file). Needs: a handle from one of the pickers.

Use the public 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();

Append a line to the end of an existing log file, keeping what is already in it:

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
}

Save a canvas as a PNG, and leave the file untouched if anything goes wrong:

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);
  }
});

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsWritableTruncate bridge#

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

truncate(size) on a writable stream.

Example

Internal transport behind FileSystemWritableFileStream.truncate(size). Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok:true} or {ok:false, error}. Needs: an open writable stream.

Use the public API:

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

Keep only the first 1,000 bytes of a file and cut everything after them:

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();
}

Reserve space: truncating to a larger size grows the file, and the new bytes read back as zeros:

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');
}

Notes: Implemented in the app shell (FileSystemAccess.kt).

__fsWritableWrite bridge#

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

One chunk of a writable stream's write(), at [position].

Example

Internal transport behind FileSystemWritableFileStream.write() - one chunk at a position in the swap copy. Pages use the standard API; never call this directly - the __ methods may change without notice.

Returns: JSON {ok:true} or {ok:false, error}. Needs: an open writable stream.

Use the public API:

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

Add a line to the end of a log file the user picked (a file picker needs a tap):

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();
});

Save a canvas drawing as a new PNG with "Save as" (a Blob is written as its raw bytes):

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();
});

Notes: Implemented in the app shell (FileSystemAccess.kt). Large data is sent in chunks.

getMaxIoChunkSize bridge#

window.WebToApk.getMaxIoChunkSize(): Long

The per-call byte cap for [readFileBase64] / [writeFileBase64], for chunk maths.

Example

The per-call byte limit of readFileBase64 / writeFileBase64 (4 MB). Pages normally use WebToApkFS.maxChunkSize().

Returns: (sync) a number (4194304). Needs: nothing to call it; the read/write methods it describes need Native Folder Access (SAF) ticked in Step 4 (Access).

The public way:

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

The raw call:

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');

Work out how many bridge calls a file will take, to size a progress bar before copying it:

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;
}

Copy one file into another, one maximum-size chunk at a time:

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)

Notes: A write larger than this is refused with chunk too large (max N bytes); a larger read is cut to this size.

pickFile bridge#

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

Opens the system single-file picker (ACTION_OPEN_DOCUMENT). Unlike [requestFolderAccess] this grants access to exactly one file, which is what an editor or a shredder should ask for; the read/write grant is persisted so the URI keeps working after the process is killed. The answer arrives on `WebToApkOnFilePickResult(success, fileJson, requestId)`; a failure's `error` is "cancelled", "disabled", "no-picker", "foreign_origin", or "busy" (a picker is already open for an earlier request, which keeps its own answer). @param mimeFilter e.g. "image/\*", "application/pdf", or "" for any file. (The star is backslash-escaped only because Kotlin block comments nest.)

Example

Opens the system picker for ONE file. Pages normally use the Promise helper WebToApkFS.pickFile(mimeFilter); this raw method answers through a callback.

Returns: nothing now. Later the shell calls window.WebToApkOnFilePickResult(success, fileJson, requestId), where fileJson is a JSON string {ok, uri, name, size, mime} on success or {ok:false, uri:'', error} with error = 'cancelled', 'disabled', 'no-picker', 'busy' or 'foreign_origin'. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

The public way (recommended):

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

The raw call, matching on your own 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
  });
}

Attach a PDF and show its name and size, telling a cancel apart from a real problem:

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
});

Open a text file and load its contents into an editor:

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
}

Notes: Only one pick can be open at a time: a call made while one is open is answered at once with error:'busy', and the open one keeps its own answer. The grant is kept (read and write) until you call releaseFileAccess(uri). The file is not read here - use readFileBase64 / WebToApkFS.readBytes with the uri.

readFileBase64 bridge#

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

Reads up to [length] raw bytes starting at [offset] and returns them base64. @param pathOrUri a `content://` URI from [pickFile], or a path relative to the folder granted by [requestFolderAccess]. @param length bytes to read; clamped to 4 MB per call. 0 or negative means "as much as one call allows". @return JSON `{ok, base64, bytesRead, offset, size, eof, error}`. `eof` tells a chunking loop when to stop without having to compare against `size` itself.

Example

Reads raw bytes from a file at a byte offset and returns them as base64. Pages normally use WebToApkFS.readBytes(pathOrUri, offset, length) (same result, already parsed) or WebToApkFS.readAll(pathOrUri).

Returns: (sync) a JSON string {ok:true, base64, bytesRead, offset, size, eof} or {ok:false, error}. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

The public way:

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

The raw call - note the result is a string you must JSON.parse, not bare 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);
  }
}

Check a picked file really is a PNG by reading only its first 8 bytes:

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; });
}

Read a large file chunk by chunk with a progress bar, never holding it all in memory:

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
}

Notes: pathOrUri is a content:// URI from pickFile or a path relative to the folder from requestFolderAccess. At most 4 MB per call (getMaxIoChunkSize()); loop on bytesRead until eof. Errors: not-found, disabled, cannot-open, offset must be >= 0, offset past end of file (N bytes), foreign_origin (a page from a site other than the app's own).

releaseFileAccess bridge#

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

Releases a persisted single-file grant taken by [pickFile]. Android caps how many URI grants an app may persist, so an app that picks many files should release the ones it is done with.

Example

Releases the kept read/write grant for a file chosen with pickFile. Pages normally use WebToApkFS.releaseFile(uri), which returns a real boolean.

Returns: (sync) the string "true" or "false". Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build (always "false" otherwise).

The public way:

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

The raw call:

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');
}

Keep a "recent files" list of at most 10 and release the grant of any file that falls off it:

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));
}

Close the current document: release its grant and forget the saved 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 = '';
});

Notes: Android limits how many file grants an app may keep, so release files you no longer need. The file itself is not touched.

statFile bridge#

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

File or folder metadata without reading any of it. A relative path names a file or a sub-folder of the granted folder; a folder answers `isDirectory: true` with `size` 0 and `mime` `vnd.android.document/directory`. @return JSON `{ok, name, size, mime, lastModified, uri, isDirectory, canWrite}`, or `{ok:false, error}` with `not-found`, `disabled` or `foreign_origin`.

Example

Reads a file's metadata (name, size, type) without reading its bytes. Pages normally use WebToApkFS.stat(pathOrUri), which parses the JSON for you.

Returns: (sync) a JSON string {ok, name, size, mime, lastModified, uri, isDirectory, canWrite}, or {ok:false, error} ('not-found', 'disabled', 'foreign_origin'). Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

The public way:

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

The raw call:

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);
}

Refuse a file that is too big before reading any of it:

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;
}

Look up a sub-folder of the granted folder and only offer "Save here" when it is writable:

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;
}

Notes: Accepts a content:// URI from pickFile or a path relative to the folder granted with requestFolderAccess. It only answers the app's own pages or its configured website; a page from any other site gets foreign_origin.

writeFileBase64 bridge#

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

Writes raw bytes and confirms how many landed. @param mode `"patch"` writes at [offset] leaving the rest of the file intact (what a multi-pass overwrite needs), `"truncate"` replaces the file, `"append"` ignores [offset] and writes at the end. @return JSON `{ok, bytesWritten, offset, size, error}` - `size` is re-read from the file after an fsync, so a caller can verify the write for real rather than trust a boolean.

Example

Writes base64 bytes into a file at an offset (patch), over the whole file (truncate), or at the end (append). Pages normally use WebToApkFS.writeBytes(pathOrUri, base64, offset, mode) or WebToApkFS.writeAll(pathOrUri, base64).

Returns: (sync) a JSON string {ok, bytesWritten, offset, size} (size re-read after an fsync) or {ok:false, error}. Needs: tick Native Folder Access (SAF) in Step 4 (Access) when you build.

The public way:

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

The raw call:

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');
}

Save a canvas drawing as a PNG into a file the user picked, replacing what was there:

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);
});

Overwrite a file in place with zeros before deleting it, and confirm every pass landed:

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
}

Notes: Mode is 'patch' (default when empty), 'truncate' or 'append'. One call takes at most 4 MB of decoded bytes (getMaxIoChunkSize()). A relative path is created inside the granted folder when missing. Errors: disabled, foreign_origin (the page on screen is not the app's own), bad mode: x, offset must be >= 0, bad base64, chunk too large (max N bytes), not-found, cannot-open, short write.

Generated from the app runtime and its example files on every docs build. Read it as Markdown · All families.

Checked against the shipped bridge on 2026-11-04.